feat: implement generic many-to-many junction relation support (#16820)

## Overview

This PR implements **generic many-to-many relation support** through
junction tables (also known as associative entities or join tables).
This replaces the need for hardcoded taskTarget/noteTarget logic and
provides a flexible foundation for modeling complex entity
relationships.

## Architecture

### Data Model

Many-to-many relationships are implemented using a **junction object
pattern**:

```
┌─────────┐       ┌──────────────────┐       ┌─────────┐
│  Pet    │──────>│   PetRocket      │<──────│ Rocket  │
│         │ 1:N   │  (junction)      │  N:1  │         │
│ rockets ├───────┤ pet    : Pet     ├───────┤         │
└─────────┘       │ rocket : Rocket  │       └─────────┘
                  └──────────────────┘
```

The junction object (PetRocket) has:
- A `MANY_TO_ONE` relation to **Pet** (the source)
- A `MANY_TO_ONE` relation to **Rocket** (the target)

The source object (Pet) has a `ONE_TO_MANY` relation pointing to the
junction, with **field settings** that specify which target field to
follow.

### Field Settings Schema

Junction configuration is stored in `FieldMetadataRelationSettings`:

```typescript
{
  relationType: "ONE_TO_MANY",
  // Points to the target field on the junction object
  junctionTargetFieldId?: string;      // For regular relations
  junctionTargetMorphId?: string;      // For polymorphic relations
}
```

**Two configuration modes:**
1. **`junctionTargetFieldId`** - References a specific `RELATION` field
on the junction
2. **`junctionTargetMorphId`** - References a `morphId` group for
polymorphic targets (e.g., link to Person OR Company)

### GraphQL Query Generation

When a junction relation is detected, the GraphQL fields are generated
to fetch the nested target:

```graphql
query GetPetWithRockets {
  pet(id: "...") {
    rockets {           # ONE_TO_MANY to junction
      id
      rocket {          # Target field on junction
        id
        name
        __typename
      }
    }
  }
}
```

For polymorphic junction targets:
```graphql
caretakerPerson { id, name }
caretakerCompany { id, name }
```

## Frontend Architecture

### Display Flow

1. **Detection**: `hasJunctionConfig()` checks if field has junction
settings
2. **Config Resolution**: `getJunctionConfig()` resolves junction object
metadata and target fields
3. **Record Extraction**: `extractTargetRecordsFromJunction()` extracts
target records from junction records
4. **Rendering**: Target records displayed as chips (not junction
records)

### Edit Flow

1. **Picker Opening**: Initializes the multi-record picker with:
   - Searchable object types (derived from junction target fields)
   - Pre-selected items (extracted from existing junction records)
   
2. **Selection Handling**: Manages create/delete of junction records:
   - **Select**: Creates new junction record with source + target IDs
   - **Deselect**: Finds and deletes the junction record
- **Optimistic Updates**: Manually updates Recoil store before API call

### Key Trade-offs

| Decision | Trade-off |
|----------|-----------|
| Junction records managed manually | More control over optimistic
updates, but requires manual cache management |
| Settings stored per-field | Flexible (same junction can power
different views), but requires UI to configure |
| Polymorphic via morphId groups | Supports N target types, but adds
query complexity |
| Feature flag gated | Safe rollout, but requires flag management |

## Backend Changes

- **Validation**: Junction target field must exist and be a valid
`MANY_TO_ONE` relation
- **Settings**: Extended `FieldMetadataRelationSettings` type with
junction fields
- **Dev Seeder**: Added sample junction objects (PetRocket,
EmploymentHistory, PetCareAgreement) for testing

## How to Test

1. Enable the `IS_JUNCTION_RELATIONS_ENABLED` feature flag
2. Create objects with junction pattern (Pet → PetRocket → Rocket)
3. Configure the junction target in field settings (advanced mode)
4. Verify:
- Display shows target objects (Rockets), not junction records
(PetRockets)
   - Picker allows selecting/deselecting targets
   - Changes persist correctly



https://github.com/user-attachments/assets/d04f057a-228c-4de8-af48-76bb2d72cac1

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2026-01-20 21:58:13 +01:00
committed by GitHub
parent 0e7e471312
commit 3ed67b825e
102 changed files with 3895 additions and 476 deletions
@@ -17,6 +17,7 @@ export class CreateFieldInput extends OmitType(
'updatedAt',
'standardOverrides',
'applicationId',
'morphId',
] as const,
InputType,
) {
@@ -24,12 +24,12 @@ import {
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import {
FieldMetadataOptions,
FieldMetadataSettings,
type FieldMetadataOptions,
type FieldMetadataSettings,
FieldMetadataType,
} from 'twenty-shared/types';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
@@ -150,6 +150,11 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
@Field({ nullable: true })
isLabelSyncedWithName?: boolean;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
morphId?: string;
@IsDateString(undefined, {
message: ({ value }) =>
`Field metadata created at is invalid got ${JSON.stringify(value)} isDate: ${value instanceof Date}`,
@@ -30,6 +30,7 @@ export class UpdateFieldInput extends OmitType(
'isCustom',
'standardOverrides',
'applicationId',
'morphId',
] as const,
) {
@HideField()
@@ -0,0 +1,31 @@
import { type AllFieldMetadataSettings } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type JunctionTargetSettings = {
junctionTargetFieldId?: string;
};
// Extracts junction target settings from untyped settings input
// This function handles the boundary where settings come from external API input
export const extractJunctionTargetSettingsFromSettings = (
settings: AllFieldMetadataSettings | null | undefined,
): JunctionTargetSettings => {
if (!isDefined(settings)) {
return {};
}
if (typeof settings !== 'object' || settings === null) {
return {};
}
const result: JunctionTargetSettings = {};
if (
'junctionTargetFieldId' in settings &&
typeof settings.junctionTargetFieldId === 'string'
) {
result.junctionTargetFieldId = settings.junctionTargetFieldId;
}
return result;
};
@@ -26,6 +26,7 @@ export const fromFlatFieldMetadataToFieldMetadataDto = (
isSystem,
isUIReadOnly,
options,
morphId,
applicationId,
} = flatFieldMetadata;
@@ -51,6 +52,7 @@ export const fromFlatFieldMetadataToFieldMetadataDto = (
isNullable: isNullable ?? false,
isUnique: isUnique ?? false,
settings: settings ?? undefined,
morphId: morphId ?? undefined,
applicationId: applicationId ?? undefined,
};
};
@@ -10,6 +10,7 @@ import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadat
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { extractJunctionTargetSettingsFromSettings } from 'src/engine/metadata-modules/flat-field-metadata/utils/extract-junction-target-settings-from-settings.util';
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
import { getDefaultFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
@@ -20,10 +21,12 @@ import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/me
type ComputeFieldMetadataRelationSettingsForRelationTypeArgs = {
relationType: RelationType;
joinColumnName: string;
junctionTargetFieldId?: string;
};
const computeFieldMetadataRelationSettingsForRelationType = ({
relationType,
joinColumnName,
junctionTargetFieldId,
}: ComputeFieldMetadataRelationSettingsForRelationTypeArgs) => {
if (relationType === RelationType.MANY_TO_ONE) {
return {
@@ -35,6 +38,7 @@ const computeFieldMetadataRelationSettingsForRelationType = ({
return {
relationType: RelationType.ONE_TO_MANY,
...(junctionTargetFieldId && { junctionTargetFieldId }),
};
};
@@ -73,10 +77,15 @@ export const generateMorphOrRelationFlatFieldMetadataPair = ({
const { relationCreationPayload } = createFieldInput;
const { junctionTargetFieldId } = extractJunctionTargetSettingsFromSettings(
createFieldInput.settings,
);
const sourceFlatFieldMetadataSettings =
computeFieldMetadataRelationSettingsForRelationType({
joinColumnName: sourceFlatObjectMetadataJoinColumnName,
relationType: relationCreationPayload.type,
junctionTargetFieldId,
});
const targetRelationTargetFieldMetadataId = v4();
const sourceRelationTargetFieldMetadataId = v4();
@@ -0,0 +1,122 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
import { type 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 { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
type ValidateJunctionTargetSettingsArgs = {
flatFieldMetadata: FlatFieldMetadata<MorphOrRelationFieldMetadataType>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
};
const createError = (
code: FieldMetadataExceptionCode,
message: string,
userFriendlyMessage: MessageDescriptor,
value?: string,
): FlatFieldMetadataValidationError => ({
code,
message,
userFriendlyMessage,
...(value !== undefined && { value }),
});
export const validateJunctionTargetSettings = ({
flatFieldMetadata,
flatFieldMetadataMaps,
}: ValidateJunctionTargetSettingsArgs): FlatFieldMetadataValidationError[] => {
const { settings } = flatFieldMetadata;
const junctionTargetFieldId = settings.junctionTargetFieldId;
if (!isDefined(junctionTargetFieldId) || junctionTargetFieldId.length === 0) {
return [];
}
if (settings.relationType !== RelationType.ONE_TO_MANY) {
return [
createError(
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
'Junction configuration can only be set on ONE_TO_MANY relations',
msg`Junction configuration is only valid for ONE_TO_MANY relations`,
),
];
}
if (!isValidUuid(junctionTargetFieldId)) {
return [
createError(
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
`Invalid junction target field ID: ${junctionTargetFieldId}`,
msg`Invalid junction target field ID`,
junctionTargetFieldId,
),
];
}
const targetField = findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>({
flatEntityId: junctionTargetFieldId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(targetField)) {
return [
createError(
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
`Junction target field not found: ${junctionTargetFieldId}`,
msg`Junction target field not found`,
junctionTargetFieldId,
),
];
}
if (
targetField.objectMetadataId !==
flatFieldMetadata.relationTargetObjectMetadataId
) {
return [
createError(
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
`Junction target field ${junctionTargetFieldId} is not on the junction object`,
msg`Junction target field must be on the junction object`,
junctionTargetFieldId,
),
];
}
if (!isMorphOrRelationFlatFieldMetadata(targetField)) {
return [
createError(
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
`Junction target field ${junctionTargetFieldId} is not a relation field`,
msg`Junction target field must be a relation field`,
junctionTargetFieldId,
),
];
}
// MORPH_RELATION fields are polymorphic and don't require MANY_TO_ONE check
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
return [];
}
if (targetField.settings.relationType !== RelationType.MANY_TO_ONE) {
return [
createError(
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
`Junction target field ${junctionTargetFieldId} is not a MANY_TO_ONE relation`,
msg`Junction target field must be a MANY_TO_ONE relation`,
junctionTargetFieldId,
),
];
}
return [];
};
@@ -9,6 +9,7 @@ import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-mo
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { validateJunctionTargetSettings } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util';
import { validateMorphOrRelationFlatFieldJoinColumName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util';
import { validateMorphOrRelationFlatFieldOnDelete } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util';
import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type';
@@ -217,5 +218,12 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
);
}
errors.push(
...validateJunctionTargetSettings({
flatFieldMetadata: flatFieldMetadataToValidate,
flatFieldMetadataMaps,
}),
);
return errors;
};
@@ -57,8 +57,17 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
applicationId: workspaceCustomApplicationId,
},
workspaceId,
skipNameField: createObjectInput.skipNameField,
});
const createdAt = new Date().toISOString();
// Use nameField.id if it exists, otherwise use idField.id (for junction tables without name)
const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField as
| FlatFieldMetadata
| undefined;
const labelIdentifierFieldMetadataId =
nameField?.id ?? defaultFlatFieldForCustomObjectMaps.fields.idField.id;
const flatObjectMetadataToCreate: FlatObjectMetadata = {
fieldMetadataIds: [],
viewIds: [],
@@ -78,8 +87,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
isSearchable: true,
isUIReadOnly: false,
isSystem: false,
labelIdentifierFieldMetadataId:
defaultFlatFieldForCustomObjectMaps.fields.nameField.id,
labelIdentifierFieldMetadataId,
labelPlural: capitalize(createObjectInput.labelPlural),
labelSingular: capitalize(createObjectInput.labelSingular),
namePlural: createObjectInput.namePlural,
@@ -1,5 +1,4 @@
import { msg } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import {
isDefined,
isLabelIdentifierFieldMetadataTypes,
@@ -8,23 +7,19 @@ import {
import { type 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 { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
export const validateFlatObjectMetadataIdentifiers = ({
flatObjectMetadata,
flatFieldMetadataMaps,
buildOptions,
}: {
flatObjectMetadata: Pick<
FlatObjectMetadata,
'labelIdentifierFieldMetadataId' | 'imageIdentifierFieldMetadataId'
>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
buildOptions: WorkspaceMigrationBuilderOptions;
}) => {
const errors: FlatObjectMetadataValidationError[] = [];
@@ -45,27 +40,13 @@ export const validateFlatObjectMetadataIdentifiers = ({
'labelIdentifierFieldMetadataId validation failed: related field metadata not found',
userFriendlyMessage: msg`Field declared as label identifier not found`,
});
} else {
if (!isLabelIdentifierFieldMetadataTypes(flatFieldMetadata.type)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataId validation failed: field type not compatible',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
});
}
if (
!buildOptions.isSystemBuild &&
isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.UUID)
) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataId validation failed: field type uuid is reserved for system object metadata',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
});
}
} else if (!isLabelIdentifierFieldMetadataTypes(flatFieldMetadata.type)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataId validation failed: field type not compatible',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
});
}
}
@@ -67,6 +67,11 @@ export class CreateObjectInput {
@HideField()
universalIdentifier?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
skipNameField?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
@@ -375,6 +375,8 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
viewId: flatDefaultViewToCreate.id,
workspaceId,
labelIdentifierFieldMetadataId:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataId,
});
const validateAndBuildResult =
@@ -483,14 +485,21 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
viewId,
workspaceId,
workspaceCustomApplicationId,
labelIdentifierFieldMetadataId,
}: {
workspaceCustomApplicationId: string;
objectFlatFieldMetadatas: FlatFieldMetadata[];
viewId: string;
workspaceId: string;
labelIdentifierFieldMetadataId: string | null;
}) {
const defaultViewFields = objectFlatFieldMetadatas
.filter((field) => field.name !== 'id' && field.name !== 'deletedAt')
.filter(
(field) =>
field.name !== 'deletedAt' &&
// Include 'id' only if it's the label identifier (e.g., for junction tables)
(field.name !== 'id' || field.id === labelIdentifierFieldMetadataId),
)
.map((field, index) =>
fromCreateViewFieldInputToFlatViewFieldToCreate({
createViewFieldInput: {
@@ -17,6 +17,7 @@ type BuildDefaultFlatFieldMetadataForCustomObjectArgs = {
flatObjectMetadata: NonNullableRequired<
Pick<FlatObjectMetadata, 'id' | 'applicationId'>
>;
skipNameField?: boolean;
};
export type DefaultFlatFieldForCustomObjectMaps = ReturnType<
@@ -26,6 +27,7 @@ export type DefaultFlatFieldForCustomObjectMaps = ReturnType<
export const buildDefaultFlatFieldMetadatasForCustomObject = ({
workspaceId,
flatObjectMetadata: { id: objectMetadataId, applicationId },
skipNameField = false,
}: BuildDefaultFlatFieldMetadataForCustomObjectArgs) => {
const createdAt = new Date().toISOString();
const idFieldId = v4();
@@ -65,42 +67,45 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
applicationId,
};
const nameFieldId = v4();
const nameField: FlatFieldMetadata<FieldMetadataType.TEXT> = {
type: FieldMetadataType.TEXT,
id: nameFieldId,
viewFieldIds: [],
mainGroupByFieldMetadataViewIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
universalIdentifier: nameFieldId,
workspaceId,
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
name: 'name',
label: 'Name',
icon: 'IconAbc',
description: 'Name',
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
isUIReadOnly: false,
defaultValue: null,
viewFilterIds: [],
const nameFieldId = skipNameField ? null : v4();
const nameField: FlatFieldMetadata<FieldMetadataType.TEXT> | null =
skipNameField
? null
: {
type: FieldMetadataType.TEXT,
id: nameFieldId!,
viewFieldIds: [],
mainGroupByFieldMetadataViewIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
universalIdentifier: nameFieldId!,
workspaceId,
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
name: 'name',
label: 'Name',
icon: 'IconAbc',
description: 'Name',
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
isUIReadOnly: false,
defaultValue: null,
viewFilterIds: [],
createdAt,
updatedAt: createdAt,
options: null,
standardOverrides: null,
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
settings: null,
morphId: null,
applicationId,
};
createdAt,
updatedAt: createdAt,
options: null,
standardOverrides: null,
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
settings: null,
morphId: null,
applicationId,
};
const createdAtFieldId = v4();
const createdAtField: FlatFieldMetadata<FieldMetadataType.DATE_TIME> = {
@@ -355,7 +360,9 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
settings: {
asExpression: getTsVectorColumnExpressionFromFields([nameField]),
asExpression: getTsVectorColumnExpressionFromFields(
nameField ? [nameField] : [],
),
generatedType: 'STORED',
},
morphId: null,
@@ -365,7 +372,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
return {
fields: {
idField,
nameField,
...(nameField && { nameField }),
createdAtField,
updatedAtField,
updatedByField,