refactor: stop reading joinColumnName from relation field settings (#20304)

## Summary

`joinColumnName` on relation field settings is always derivable from the
field name (and the target object name for morph relations). This PR
stops reading it from settings anywhere in production code; the stored
value is no longer used.

The settings field is **not** removed from data yet — a follow-up can
drop it once we are confident nothing depends on the stored value.

## Helpers

The helpers are split by layer because frontend and backend hold morph
relations differently: the frontend has a base name plus a
`morphRelations[]` array, the backend has one row per target with the
name already morph-resolved.

| Helper | Layer | When to use |
|---|---|---|
| `computeRelationGqlFieldJoinColumnName` | Shared / frontend
(`gqlField`) | Non-morph relation on the frontend. |
| `computeMorphRelationGqlFieldName` | Shared / frontend (`gqlField`) |
Need the per-target morph gqlField name (e.g. `targetCompany`). |
| `computeMorphRelationGqlFieldJoinColumnName` | Shared / frontend
(`gqlField`) | Per-target morph join column on the frontend. Prefer over
the non-morph helper for any morph field — it forces the per-target
inputs. |
| `computeMorphOrRelationFieldJoinColumnName` | Backend
(`FlatFieldMetadata.name`) | Any backend read or write — the flat name
is already morph-resolved, so one helper covers both cases. |
| `computeMorphRelationFlatFieldName` | Backend
(`FlatFieldMetadata.name`) | **Mutation paths only** (create / update /
object rename). Reads consume the stored `field.name` and never call
this. |

## Test plan

- [x] Typecheck and lint (front, server, shared)
- [x] Existing unit tests pass
- [ ] CI green
This commit is contained in:
Charles Bochet
2026-05-07 09:53:00 +02:00
committed by GitHub
parent 4b56ad0607
commit 10876138d2
73 changed files with 592 additions and 387 deletions
@@ -14,6 +14,7 @@ import {
isDefined,
} from 'twenty-shared/utils';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { transformActorField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util';
import { transformAddressField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-address-field.util';
import { transformArrayField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util';
@@ -56,12 +57,10 @@ import { transformLinksValue } from 'src/engine/core-modules/record-transformer/
import { transformPhonesValue } from 'src/engine/core-modules/record-transformer/utils/transform-phones-value.util';
import { transformRichTextValue } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text.util';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@Injectable()
@@ -245,14 +244,9 @@ export class DataArgProcessorService {
);
}
const joinColumnName = isFlatFieldMetadataOfType(
fieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
? computeMorphOrRelationFieldJoinColumnName({
name: fieldMetadata.name,
})
: relationSettings.joinColumnName;
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: fieldMetadata.name,
});
if (key === joinColumnName) {
return validateUUIDFieldOrThrow(value, key);
@@ -8,6 +8,7 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { validateAndTransformOperatorAndValue } from 'src/engine/api/common/common-args-processors/filter-arg-processor/utils/validate-and-transform-operator-and-value.util';
@@ -17,7 +18,6 @@ import {
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -4,6 +4,7 @@ import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
@@ -191,8 +192,9 @@ export class ProcessNestedRelationsV2Helper {
select: columnsToSelect,
});
const joinColumnName =
sourceFieldMetadata.settings.joinColumnName ?? `${sourceFieldName}Id`;
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: sourceFieldName,
});
const relationIds = this.getUniqueIds({
records: parentObjectRecords,
@@ -200,14 +202,28 @@ export class ProcessNestedRelationsV2Helper {
relationType === RelationType.ONE_TO_MANY ? 'id' : joinColumnName,
});
if (
relationType === RelationType.ONE_TO_MANY &&
!isDefined(targetRelationName)
) {
throw new GraphqlQueryRunnerException(
`Could not resolve target relation for one-to-many field ${sourceFieldName}`,
GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const fieldMetadataTargetRelationColumnName =
targetRelation &&
isFieldMetadataEntityOfType(
targetRelation,
FieldMetadataType.MORPH_RELATION,
)
? `${targetRelation.settings?.joinColumnName}`
: `${targetRelationName}Id`;
computeMorphOrRelationFieldJoinColumnName({
name:
targetRelation &&
isFieldMetadataEntityOfType(
targetRelation,
FieldMetadataType.MORPH_RELATION,
)
? targetRelation.name
: (targetRelationName as string),
});
const { relationResults, relationAggregatedFieldsResult } =
await this.findRelations({
@@ -15,6 +15,7 @@ import { isDefined } from 'twenty-shared/utils';
import { FindOptionsRelations, In, ObjectLiteral } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import {
CommonQueryRunnerException,
@@ -385,10 +386,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
| FieldMetadataSettingsMapping['RELATION']
| undefined;
if (
relationSettings?.relationType !== RelationType.MANY_TO_ONE ||
!relationSettings?.joinColumnName
) {
if (relationSettings?.relationType !== RelationType.MANY_TO_ONE) {
continue;
}
@@ -405,7 +403,9 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
objectMetadata: objMetadata,
fieldName: field.name,
fieldId: field.id,
joinColumnName: relationSettings.joinColumnName,
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
name: field.name,
}),
});
}
@@ -9,8 +9,8 @@ import {
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -11,8 +11,7 @@ export const getIsFlatFieldAJunctionRelationField = ({
const isJunctionRelationField =
isDefined(flatField.settings) &&
'relationType' in flatField.settings &&
flatField.settings.relationType === RelationType.MANY_TO_ONE &&
isDefined(flatField.settings.joinColumnName);
flatField.settings.relationType === RelationType.MANY_TO_ONE;
// TODO: refactor this when we remove hard-coded activity relations
const isActivityRelationField =