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:
+5
@@ -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,
|
||||
|
||||
+53
@@ -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,
|
||||
},
|
||||
];
|
||||
+45
@@ -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,
|
||||
},
|
||||
];
|
||||
+19
@@ -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
|
||||
|
||||
+25
@@ -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'],
|
||||
};
|
||||
+6
-6
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
+10
@@ -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,
|
||||
};
|
||||
+10
@@ -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,
|
||||
};
|
||||
+268
-48
@@ -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
-1
@@ -3,4 +3,6 @@ import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metad
|
||||
export type ObjectMetadataSeed = Omit<
|
||||
CreateObjectInput,
|
||||
'workspaceId' | 'dataSourceId' | 'fields'
|
||||
>;
|
||||
> & {
|
||||
skipNameField?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user