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
@@ -7,16 +7,13 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { type ColumnType, type EntitySchemaColumnOptions } 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 { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import { serializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-default-value';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import {
type EntitySchemaFieldMetadata,
type EntitySchemaFieldMetadataMaps,
@@ -57,18 +54,14 @@ export class EntitySchemaColumnFactory {
if (isRelation) {
const isManyToOneRelation =
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE;
const joinColumnName = fieldMetadata.settings?.joinColumnName;
if (!isManyToOneRelation) {
continue;
}
if (!isDefined(joinColumnName)) {
throw new TwentyORMException(
`Field ${fieldMetadata.id} of type ${fieldMetadata.type} is a many to one relation but does not have a join column name`,
TwentyORMExceptionCode.MALFORMED_METADATA,
);
}
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: fieldMetadata.name,
});
entitySchemaColumnMap[joinColumnName] = {
name: joinColumnName,
@@ -1,4 +1,8 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import {
type FieldMetadataType,
RelationType as TwentyRelationType,
} from 'twenty-shared/types';
import { type RelationType } from 'typeorm/metadata/types/RelationTypes';
import {
@@ -10,6 +14,7 @@ import {
type EntitySchemaFieldMetadataMaps,
type EntitySchemaObjectMetadataMaps,
} from 'src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { converRelationTypeToTypeORMRelationType } from 'src/engine/twenty-orm/utils/convert-relation-type-to-typeorm-relation-type.util';
interface RelationDetails {
@@ -65,12 +70,19 @@ export function determineSchemaRelationDetails(
throw new Error('Target field metadata not found');
}
const isManyToOne =
fieldMetadata.settings.relationType === TwentyRelationType.MANY_TO_ONE;
return {
relationType,
target: targetObjectMetadata.nameSingular,
inverseSide: targetFieldMetadata.name,
joinColumn: fieldMetadata.settings.joinColumnName
? { name: fieldMetadata.settings.joinColumnName }
joinColumn: isManyToOne
? {
name: computeMorphOrRelationFieldJoinColumnName({
name: fieldMetadata.name,
}),
}
: undefined,
};
}
@@ -1,8 +1,8 @@
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import {
type FieldMetadataSettingsMapping,
RelationType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const formatColumnNameForRelationField = (
fieldName: string,
@@ -13,11 +13,7 @@ export const formatColumnNameForRelationField = (
}
if (fieldMetadataSettings.relationType === RelationType.MANY_TO_ONE) {
if (!isDefined(fieldMetadataSettings.joinColumnName)) {
throw new Error(`Join column name is not defined for field ${fieldName}`);
}
return fieldMetadataSettings.joinColumnName;
return computeMorphOrRelationFieldJoinColumnName({ name: fieldName });
}
return fieldName;
@@ -47,6 +47,7 @@ import {
isMatchingUUIDFilter,
} from 'twenty-shared/utils';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -208,8 +209,8 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
objectFields.find(
(field) =>
field.type === FieldMetadataType.RELATION &&
(field.settings as { joinColumnName?: string } | undefined)
?.joinColumnName === filterKey,
computeMorphOrRelationFieldJoinColumnName({ name: field.name }) ===
filterKey,
);
if (!isDefined(objectMetadataField)) {
@@ -412,11 +413,9 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
}
case FieldMetadataType.RELATION: {
const isJoinColumn =
(
objectMetadataField.settings as
| { joinColumnName?: string }
| undefined
)?.joinColumnName === filterKey;
computeMorphOrRelationFieldJoinColumnName({
name: objectMetadataField.name,
}) === filterKey;
if (isJoinColumn) {
return isMatchingUUIDFilter({