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
@@ -99,10 +99,6 @@ export class ActorFromAuthContextService {
},
);
this.logger.log(
`Injecting ${fieldName} from auth context for object ${objectMetadataNameSingular} and workspace ${workspace.id}`,
);
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
@@ -20,6 +20,15 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
imagePath: 'https://twenty.com/images/lab/is-dashboards-enabled.png',
},
},
{
key: FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED,
metadata: {
label: 'Junction Relations',
description:
'Enable many-to-many relations through junction tables configuration',
imagePath: 'https://twenty.com/images/lab/is-junction-relations.png',
},
},
{
key: FeatureFlagKey.IS_IF_ELSE_ENABLED,
metadata: {
@@ -10,6 +10,7 @@ export enum FeatureFlagKey {
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_TIMELINE_ACTIVITY_MIGRATED = 'IS_TIMELINE_ACTIVITY_MIGRATED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED',
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
@@ -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,
@@ -220,6 +220,7 @@ describe('WorkspaceEntityManager', () => {
IS_TIMELINE_ACTIVITY_MIGRATED: false,
IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false,
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_IF_ELSE_ENABLED: false,
IS_SSE_DB_EVENTS_ENABLED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
@@ -76,6 +76,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_SSE_DB_EVENTS_ENABLED,
workspaceId: workspaceId,
@@ -0,0 +1,53 @@
import { COMPANY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/company-data-seeds.constant';
import { PERSON_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/person-data-seeds.constant';
type EmploymentHistoryDataSeed = {
id: string;
personId: string;
companyId: string;
};
export const EMPLOYMENT_HISTORY_DATA_SEED_COLUMNS: (keyof EmploymentHistoryDataSeed)[] =
['id', 'personId', 'companyId'];
export const EMPLOYMENT_HISTORY_DATA_SEED_IDS = {
ID_1: '20202020-e001-4000-8000-000000000001',
ID_2: '20202020-e001-4000-8000-000000000002',
ID_3: '20202020-e001-4000-8000-000000000003',
ID_4: '20202020-e001-4000-8000-000000000004',
ID_5: '20202020-e001-4000-8000-000000000005',
};
// Sample employment histories: some people have worked at different companies
export const EMPLOYMENT_HISTORY_DATA_SEEDS: EmploymentHistoryDataSeed[] = [
// Mark Young (Person 1) previously worked at Company 2
{
id: EMPLOYMENT_HISTORY_DATA_SEED_IDS.ID_1,
personId: PERSON_DATA_SEED_IDS.ID_1,
companyId: COMPANY_DATA_SEED_IDS.ID_2,
},
// Mark Young (Person 1) also previously worked at Company 3
{
id: EMPLOYMENT_HISTORY_DATA_SEED_IDS.ID_2,
personId: PERSON_DATA_SEED_IDS.ID_1,
companyId: COMPANY_DATA_SEED_IDS.ID_3,
},
// Gabriel Robinson (Person 2) previously worked at Company 4
{
id: EMPLOYMENT_HISTORY_DATA_SEED_IDS.ID_3,
personId: PERSON_DATA_SEED_IDS.ID_2,
companyId: COMPANY_DATA_SEED_IDS.ID_4,
},
// Kimberly Gordon (Person 3) previously worked at Company 1
{
id: EMPLOYMENT_HISTORY_DATA_SEED_IDS.ID_4,
personId: PERSON_DATA_SEED_IDS.ID_3,
companyId: COMPANY_DATA_SEED_IDS.ID_1,
},
// Cindy Baker (Person 4) previously worked at Company 5
{
id: EMPLOYMENT_HISTORY_DATA_SEED_IDS.ID_5,
personId: PERSON_DATA_SEED_IDS.ID_4,
companyId: COMPANY_DATA_SEED_IDS.ID_5,
},
];
@@ -0,0 +1,45 @@
import { COMPANY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/company-data-seeds.constant';
import { PERSON_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/person-data-seeds.constant';
import { PET_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/pet-data-seeds.constant';
type PetCareAgreementDataSeed = {
id: string;
petId: string;
// Morph relation creates separate columns for each target type
caretakerPersonId: string | null;
caretakerCompanyId: string | null;
};
export const PET_CARE_AGREEMENT_DATA_SEED_COLUMNS: (keyof PetCareAgreementDataSeed)[] =
['id', 'petId', 'caretakerPersonId', 'caretakerCompanyId'];
export const PET_CARE_AGREEMENT_DATA_SEED_IDS = {
ID_1: '20202020-c001-4000-8000-000000000001',
ID_2: '20202020-c001-4000-8000-000000000002',
ID_3: '20202020-c001-4000-8000-000000000003',
};
// Sample pet care agreements: Toby the pet has multiple caretakers (both persons and companies)
export const PET_CARE_AGREEMENT_DATA_SEEDS: PetCareAgreementDataSeed[] = [
// Toby is cared for by Mark Young (Person 1)
{
id: PET_CARE_AGREEMENT_DATA_SEED_IDS.ID_1,
petId: PET_DATA_SEED_IDS.ID_1,
caretakerPersonId: PERSON_DATA_SEED_IDS.ID_1,
caretakerCompanyId: null,
},
// Toby is also cared for by Gabriel Robinson (Person 2)
{
id: PET_CARE_AGREEMENT_DATA_SEED_IDS.ID_2,
petId: PET_DATA_SEED_IDS.ID_1,
caretakerPersonId: PERSON_DATA_SEED_IDS.ID_2,
caretakerCompanyId: null,
},
// Toby is also cared for by Company 1 (a pet care service company)
{
id: PET_CARE_AGREEMENT_DATA_SEED_IDS.ID_3,
petId: PET_DATA_SEED_IDS.ID_1,
caretakerPersonId: null,
caretakerCompanyId: COMPANY_DATA_SEED_IDS.ID_1,
},
];
@@ -45,6 +45,10 @@ import {
DASHBOARD_DATA_SEED_COLUMNS,
getDashboardDataSeeds,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/dashboard-data-seeds.constant';
import {
EMPLOYMENT_HISTORY_DATA_SEED_COLUMNS,
EMPLOYMENT_HISTORY_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/employment-history-data-seeds.constant';
import {
MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_DATA_SEEDS,
@@ -81,6 +85,10 @@ import {
PERSON_DATA_SEED_COLUMNS,
PERSON_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/person-data-seeds.constant';
import {
PET_CARE_AGREEMENT_DATA_SEED_COLUMNS,
PET_CARE_AGREEMENT_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/pet-care-agreement-data-seeds.constant';
import {
PET_DATA_SEED_COLUMNS,
PET_DATA_SEEDS,
@@ -208,6 +216,17 @@ const getRecordSeedsBatches = (
pgColumns: MESSAGE_THREAD_DATA_SEED_COLUMNS,
recordSeeds: MESSAGE_THREAD_DATA_SEEDS,
},
// Junction tables
{
tableName: '_employmentHistory',
pgColumns: EMPLOYMENT_HISTORY_DATA_SEED_COLUMNS,
recordSeeds: EMPLOYMENT_HISTORY_DATA_SEEDS,
},
{
tableName: '_petCareAgreement',
pgColumns: PET_CARE_AGREEMENT_DATA_SEED_COLUMNS,
recordSeeds: PET_CARE_AGREEMENT_DATA_SEEDS,
},
];
// Batch 5: Depends on batch 4 entities
@@ -0,0 +1,25 @@
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { type FieldMetadataSeed } from 'src/engine/workspace-manager/dev-seeder/metadata/types/field-metadata-seed.type';
// Note: The `pet` relation is created automatically when the junction field
// `caretakers` is created on Pet (as the inverse side)
// Morph relation to caretaker (Person or Company)
export const PET_CARE_AGREEMENT_CARETAKER_MORPH_SEED: FieldMetadataSeed & {
targetObjectMetadataNames: string[];
} = {
type: FieldMetadataType.MORPH_RELATION,
label: 'Caretaker',
name: 'caretaker',
icon: 'IconUser',
morphRelationsCreationPayload: [
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId: 'to-be-resolved-later',
targetFieldLabel: 'Cared For Pets',
targetFieldIcon: 'IconPaw',
},
],
targetObjectMetadataNames: ['person', 'company'],
};
@@ -8,14 +8,14 @@ export const PET_CUSTOM_RELATION_FIELD_SEEDS: (FieldMetadataSeed & {
})[] = [
{
type: FieldMetadataType.MORPH_RELATION,
label: 'Owner of the Pet (rocket or survey)',
name: 'owner',
label: 'Polymorphic Owner',
name: 'polymorphicOwner',
icon: 'IconRelationManyToOne',
morphRelationsCreationPayload: [
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId: 'to-be-resolved-later',
targetFieldLabel: 'Owned by',
targetFieldLabel: 'Owned Pets',
targetFieldIcon: 'IconRelationOneToMany',
},
],
@@ -26,14 +26,14 @@ export const PET_CUSTOM_RELATION_FIELD_SEEDS: (FieldMetadataSeed & {
},
{
type: FieldMetadataType.MORPH_RELATION,
label: 'Helper of the Pet (rocket or survey)',
name: 'helper',
label: 'Polymorphic Helper',
name: 'polymorphicHelper',
icon: 'IconRelationOneToMany',
morphRelationsCreationPayload: [
{
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId: 'to-be-resolved-later',
targetFieldLabel: 'Helped by',
targetFieldLabel: 'Helped Pets',
targetFieldIcon: 'IconRelationOneToMany',
},
],
@@ -0,0 +1,10 @@
import { type ObjectMetadataSeed } from 'src/engine/workspace-manager/dev-seeder/metadata/types/object-metadata-seed.type';
export const EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED: ObjectMetadataSeed = {
labelPlural: 'Employment Histories',
labelSingular: 'Employment History',
namePlural: 'employmentHistories',
nameSingular: 'employmentHistory',
icon: 'IconBriefcase',
skipNameField: true,
};
@@ -0,0 +1,10 @@
import { type ObjectMetadataSeed } from 'src/engine/workspace-manager/dev-seeder/metadata/types/object-metadata-seed.type';
export const PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED: ObjectMetadataSeed = {
labelPlural: 'Pet Care Agreements',
labelSingular: 'Pet Care Agreement',
namePlural: 'petCareAgreements',
nameSingular: 'petCareAgreement',
icon: 'IconPaw',
skipNameField: true,
};
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
@@ -14,15 +14,46 @@ import {
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { COMPANY_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/company-custom-field-seeds.constant';
import { PERSON_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/person-custom-field-seeds.constant';
import { PET_CARE_AGREEMENT_CARETAKER_MORPH_SEED } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/pet-care-agreement-custom-relation-field-seeds.constant';
import { PET_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/pet-custom-field-seeds.constant';
import { PET_CUSTOM_RELATION_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/pet-custom-relation-field-seeds.constant';
import { SURVEY_RESULT_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/survey-results-field-seeds.constant';
import { EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/employment-history-custom-object-seed.constant';
import { PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/pet-care-agreement-custom-object-seed.constant';
import { PET_CUSTOM_OBJECT_SEED } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/pet-custom-object-seed.constant';
import { ROCKET_CUSTOM_OBJECT_SEED } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/rocket-custom-object-seed.constant';
import { SURVEY_RESULT_CUSTOM_OBJECT_SEED } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/survey-results-object-seed.constant';
import { type FieldMetadataSeed } from 'src/engine/workspace-manager/dev-seeder/metadata/types/field-metadata-seed.type';
import { type ObjectMetadataSeed } from 'src/engine/workspace-manager/dev-seeder/metadata/types/object-metadata-seed.type';
type MorphRelationSeed = FieldMetadataSeed & {
targetObjectMetadataNames: string[];
};
type JunctionFieldSeed = {
sourceObjectName: string;
name: string;
label: string;
icon: string;
targetObjectName: string;
targetFieldLabel: string;
targetFieldIcon: string;
};
type JunctionConfigSeed = {
objectName: string;
fieldName: string;
junctionTargetFieldRef: string;
label?: string;
};
// Helper type for flat entity maps
type FlatMaps = {
fieldMaps: { byId: Record<string, { name: string; morphId?: string }> };
objectMaps: { byId: Record<string, { fieldMetadataIds: string[] }> };
objectIdByName: Record<string, string>;
};
@Injectable()
export class DevSeederMetadataService {
constructor(
@@ -36,10 +67,11 @@ export class DevSeederMetadataService {
{
objects: { seed: ObjectMetadataSeed; fields?: FieldMetadataSeed[] }[];
fields: { objectName: string; seeds: FieldMetadataSeed[] }[];
relations?: {
objectName: string;
seeds: (FieldMetadataSeed & { targetObjectMetadataNames: string[] })[];
}[];
morphRelations?: { objectName: string; seeds: MorphRelationSeed[] }[];
// Junction fields create relations to junction objects (inverses auto-created)
junctionFields?: JunctionFieldSeed[];
// Configure junction settings on fields after all relations exist
junctionConfigs?: JunctionConfigSeed[];
}
> = {
[SEED_APPLE_WORKSPACE_ID]: {
@@ -50,16 +82,83 @@ export class DevSeederMetadataService {
seed: SURVEY_RESULT_CUSTOM_OBJECT_SEED,
fields: SURVEY_RESULT_CUSTOM_FIELD_SEEDS,
},
// Junction objects (minimal pivots)
{ seed: EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED },
{ seed: PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED },
],
fields: [
{ objectName: 'company', seeds: COMPANY_CUSTOM_FIELD_SEEDS },
{ objectName: 'person', seeds: PERSON_CUSTOM_FIELD_SEEDS },
],
relations: [
morphRelations: [
{
objectName: PET_CUSTOM_OBJECT_SEED.nameSingular,
seeds: PET_CUSTOM_RELATION_FIELD_SEEDS,
},
{
objectName: PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED.nameSingular,
seeds: [PET_CARE_AGREEMENT_CARETAKER_MORPH_SEED],
},
],
junctionFields: [
// Employment History: Person <-> Company
{
sourceObjectName: 'person',
name: 'previousCompanies',
label: 'Previous Companies',
icon: 'IconBuildingSkyscraper',
targetObjectName: EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED.nameSingular,
targetFieldLabel: 'Person',
targetFieldIcon: 'IconUser',
},
{
sourceObjectName: 'company',
name: 'previousEmployees',
label: 'Previous Employees',
icon: 'IconUser',
targetObjectName: EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED.nameSingular,
targetFieldLabel: 'Company',
targetFieldIcon: 'IconBuildingSkyscraper',
},
// Pet Care Agreement: Pet -> caretakers
{
sourceObjectName: PET_CUSTOM_OBJECT_SEED.nameSingular,
name: 'caretakers',
label: 'Caretakers',
icon: 'IconUser',
targetObjectName: PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED.nameSingular,
targetFieldLabel: 'Pet',
targetFieldIcon: 'IconCat',
},
],
junctionConfigs: [
// Employment History junction configs
{
objectName: 'person',
fieldName: 'previousCompanies',
junctionTargetFieldRef: `${EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED.nameSingular}.company`,
},
{
objectName: 'company',
fieldName: 'previousEmployees',
junctionTargetFieldRef: `${EMPLOYMENT_HISTORY_CUSTOM_OBJECT_SEED.nameSingular}.person`,
},
// Pet Care Agreement junction configs
{
objectName: PET_CUSTOM_OBJECT_SEED.nameSingular,
fieldName: 'caretakers',
junctionTargetFieldRef: `${PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED.nameSingular}.caretakerPerson`,
},
{
objectName: 'company',
fieldName: 'caredForPets',
junctionTargetFieldRef: `${PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED.nameSingular}.pet`,
},
{
objectName: 'person',
fieldName: 'caredForPets',
junctionTargetFieldRef: `${PET_CARE_AGREEMENT_CUSTOM_OBJECT_SEED.nameSingular}.pet`,
},
],
},
[SEED_YCOMBINATOR_WORKSPACE_ID]: {
@@ -91,7 +190,6 @@ export class DevSeederMetadataService {
);
}
// TODO get
for (const obj of config.objects) {
await this.seedCustomObject({
dataSourceId: dataSourceMetadata.id,
@@ -174,60 +272,113 @@ export class DevSeederMetadataService {
);
}
if (!config.relations) {
return;
}
// 1. Seed morph relations (creates inverses on target objects)
let maps = await this.getFreshFlatMaps(workspaceId);
for (const relation of config.relations) {
await this.seedCustomRelation({
for (const relation of config.morphRelations ?? []) {
await this.seedMorphRelations({
workspaceId,
relation,
objectIdByNameSingular: maps.objectIdByName,
});
}
// 2. Seed junction fields (creates relations + inverses on junction objects)
// Use same maps for all - matches original working behavior
maps = await this.getFreshFlatMaps(workspaceId);
for (const field of config.junctionFields ?? []) {
await this.seedJunctionField({ workspaceId, field, flatMaps: maps });
}
// 3. Configure junction settings (after all fields exist)
if (config.junctionConfigs && config.junctionConfigs.length > 0) {
maps = await this.getFreshFlatMaps(workspaceId);
for (const junctionConfig of config.junctionConfigs) {
await this.applyJunctionConfig({
workspaceId,
junctionConfig,
flatMaps: maps,
});
}
}
}
private async seedCustomRelation({
private async getFreshFlatMaps(workspaceId: string): Promise<FlatMaps> {
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
});
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
return {
fieldMaps: flatFieldMetadataMaps as FlatMaps['fieldMaps'],
objectMaps: flatObjectMetadataMaps as FlatMaps['objectMaps'],
objectIdByName: idByNameSingular,
};
}
private async applyJunctionConfig({
workspaceId,
junctionConfig,
flatMaps,
}: {
workspaceId: string;
junctionConfig: JunctionConfigSeed;
flatMaps: FlatMaps;
}): Promise<void> {
const [targetObjectName, targetFieldName] =
junctionConfig.junctionTargetFieldRef.split('.');
const junctionTargetFieldId = this.findFieldId(
targetObjectName,
targetFieldName,
flatMaps,
);
const fieldId = this.findFieldId(
junctionConfig.objectName,
junctionConfig.fieldName,
flatMaps,
);
await this.fieldMetadataService.updateOneField({
workspaceId,
updateFieldInput: {
id: fieldId,
...(junctionConfig.label && { label: junctionConfig.label }),
settings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldId,
},
},
});
}
private async seedMorphRelations({
workspaceId,
relation,
objectIdByNameSingular,
}: {
workspaceId: string;
relation: {
objectName: string;
seeds: (FieldMetadataSeed & { targetObjectMetadataNames: string[] })[];
};
}): Promise<void> {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const { idByNameSingular: objectIdByNameSingular } =
buildObjectIdByNameMaps(flatObjectMetadataMaps);
const createFieldInputs = this.createFieldInputs({
relation,
objectIdByNameSingular,
});
await this.fieldMetadataService.createManyFields({
createFieldInputs,
workspaceId,
});
}
private createFieldInputs({
relation,
objectIdByNameSingular,
}: {
relation: {
objectName: string;
seeds: (FieldMetadataSeed & { targetObjectMetadataNames: string[] })[];
seeds: MorphRelationSeed[];
};
objectIdByNameSingular: Record<string, string>;
}): Omit<CreateFieldInput, 'workspaceId'>[] {
}): Promise<void> {
const objectMetadataId = objectIdByNameSingular[relation.objectName];
if (!isDefined(objectMetadataId)) {
@@ -236,10 +387,11 @@ export class DevSeederMetadataService {
);
}
const relationFieldInputs = relation.seeds.map((seed) => ({
const createFieldInputs = relation.seeds.map((seed) => ({
type: seed.type,
label: seed.label,
name: seed.name,
icon: seed.icon,
objectMetadataId,
morphRelationsCreationPayload: seed.targetObjectMetadataNames.map(
(targetObjectMetadataName) => {
@@ -268,6 +420,74 @@ export class DevSeederMetadataService {
),
}));
return relationFieldInputs;
await this.fieldMetadataService.createManyFields({
createFieldInputs,
workspaceId,
});
}
private async seedJunctionField({
workspaceId,
field,
flatMaps,
}: {
workspaceId: string;
field: JunctionFieldSeed;
flatMaps: FlatMaps;
}): Promise<void> {
const sourceObjectId = flatMaps.objectIdByName[field.sourceObjectName];
const targetObjectId = flatMaps.objectIdByName[field.targetObjectName];
if (!isDefined(sourceObjectId)) {
throw new Error(`Source object not found: ${field.sourceObjectName}`);
}
if (!isDefined(targetObjectId)) {
throw new Error(`Target object not found: ${field.targetObjectName}`);
}
await this.fieldMetadataService.createManyFields({
createFieldInputs: [
{
type: FieldMetadataType.RELATION,
name: field.name,
label: field.label,
icon: field.icon,
objectMetadataId: sourceObjectId,
relationCreationPayload: {
type: RelationType.ONE_TO_MANY,
targetFieldLabel: field.targetFieldLabel,
targetFieldIcon: field.targetFieldIcon,
targetObjectMetadataId: targetObjectId,
},
},
],
workspaceId,
});
}
private findFieldId(
objectName: string,
fieldName: string,
flatMaps: FlatMaps,
): string {
const objectId = flatMaps.objectIdByName[objectName];
if (!isDefined(objectId)) {
throw new Error(`Object not found: ${objectName}`);
}
const objectMetadata = flatMaps.objectMaps.byId[objectId];
if (!isDefined(objectMetadata)) {
throw new Error(`Object metadata not found: ${objectName}`);
}
for (const fieldId of objectMetadata.fieldMetadataIds) {
if (flatMaps.fieldMaps.byId[fieldId]?.name === fieldName) {
return fieldId;
}
}
throw new Error(`Field not found: ${objectName}.${fieldName}`);
}
}
@@ -3,4 +3,6 @@ import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metad
export type ObjectMetadataSeed = Omit<
CreateObjectInput,
'workspaceId' | 'dataSourceId' | 'fields'
>;
> & {
skipNameField?: boolean;
};
@@ -609,6 +609,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
['emailsPrimaryEmail'],
],
labelIdentifierFieldMetadataName: 'name',
imageIdentifierFieldMetadataName: 'avatarUrl',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
@@ -838,6 +839,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
isSystem: true,
isSearchable: true,
labelIdentifierFieldMetadataName: 'name',
imageIdentifierFieldMetadataName: 'avatarUrl',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
@@ -18,6 +18,7 @@ export type CreateStandardObjectContext<O extends AllStandardObjectName> = {
shortcut?: string | null;
duplicateCriteria?: string[][] | null;
labelIdentifierFieldMetadataName: AllStandardObjectFieldName<O>;
imageIdentifierFieldMetadataName?: AllStandardObjectFieldName<O>;
};
export type CreateStandardObjectArgs<
@@ -45,6 +46,7 @@ export const createStandardObjectFlatMetadata = <
shortcut = null,
duplicateCriteria = null,
labelIdentifierFieldMetadataName,
imageIdentifierFieldMetadataName,
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
@@ -76,7 +78,11 @@ export const createStandardObjectFlatMetadata = <
standardObjectMetadataRelatedEntityIds[nameSingular].fields[
labelIdentifierFieldMetadataName
].id,
imageIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: imageIdentifierFieldMetadataName
? standardObjectMetadataRelatedEntityIds[nameSingular].fields[
imageIdentifierFieldMetadataName
].id
: null,
targetTableName: 'DEPRECATED',
fieldMetadataIds: [],
indexMetadataIds: [],
@@ -8,10 +8,7 @@ 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 {
isSearchableFieldType,
type SearchableFieldType,
} from 'src/engine/workspace-manager/utils/is-searchable-field.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/utils/is-searchable-field.util';
import { isSearchableSubfield } from 'src/engine/workspace-manager/utils/is-searchable-subfield.util';
export type FieldTypeAndNameMetadata = {
@@ -22,18 +19,13 @@ export type FieldTypeAndNameMetadata = {
export const getTsVectorColumnExpressionFromFields = (
fieldsUsedForSearch: FieldTypeAndNameMetadata[],
): string => {
const filteredFieldsUsedForSearch = fieldsUsedForSearch.filter((field) =>
isSearchableFieldType(field.type),
);
if (filteredFieldsUsedForSearch.length < 1) {
throw new Error('No searchable fields found');
}
const columnExpressions = fieldsUsedForSearch.flatMap(
getColumnExpressionsFromField,
);
const concatenatedExpression = columnExpressions.join(" || ' ' || ");
const concatenatedExpression =
columnExpressions.length > 0
? columnExpressions.join(" || ' ' || ")
: 'NULL';
// Note: changing this expression requires reindexing/backfilling existing searchVector values.
return `to_tsvector('simple', ${concatenatedExpression})`;
@@ -8,6 +8,7 @@ const SEARCHABLE_FIELD_TYPES = [
FieldMetadataType.PHONES,
FieldMetadataType.RICH_TEXT,
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.UUID,
] as const;
export type SearchableFieldType = (typeof SEARCHABLE_FIELD_TYPES)[number];
@@ -89,7 +89,6 @@ export class FlatObjectMetadataValidatorService {
...validateFlatObjectMetadataIdentifiers({
flatObjectMetadata: updatedFlatObjectMetadata,
flatFieldMetadataMaps,
buildOptions,
}),
);
}
@@ -199,7 +198,6 @@ export class FlatObjectMetadataValidatorService {
...validateFlatObjectMetadataIdentifiers({
flatObjectMetadata: flatObjectMetadataToValidate,
flatFieldMetadataMaps,
buildOptions,
}),
);
objectValidationResult.errors.push(