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
@@ -0,0 +1,30 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { isDefined } from 'twenty-shared/utils';
export const buildIdentifierGqlFields = (
objectMetadata: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
>,
): RecordGqlFields => {
const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadata);
const imageIdentifierField =
getImageIdentifierFieldMetadataItem(objectMetadata);
return {
id: true,
...(isDefined(labelIdentifierField) && {
[labelIdentifierField.name]: true,
}),
...(isDefined(imageIdentifierField) && {
[imageIdentifierField.name]: true,
}),
};
};
@@ -7,7 +7,12 @@ import { isDefined } from 'twenty-shared/utils';
export type GenerateDepthRecordGqlFields = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'id' | 'nameSingular' | 'fields' | 'labelIdentifierFieldMetadataId'
| 'id'
| 'nameSingular'
| 'namePlural'
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
>[];
activityObjectNameSingular:
| CoreObjectNameSingular.Note
@@ -3,17 +3,24 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { generateJunctionRelationGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export type GenerateDepthRecordGqlFieldsFromFields = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'id' | 'fields' | 'labelIdentifierFieldMetadataId' | 'nameSingular'
| 'id'
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
| 'namePlural'
>[];
fields: Pick<
FieldMetadataItem,
'name' | 'type' | 'settings' | 'morphRelations' | 'relation'
'id' | 'name' | 'type' | 'settings' | 'morphRelations' | 'relation'
>[];
depth: 0 | 1;
shouldOnlyLoadRelationIdentifiers?: boolean;
@@ -47,6 +54,20 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
}
if (isJunctionRelationField(fieldMetadata)) {
const junctionGqlFields = generateJunctionRelationGqlFields({
fieldMetadataItem: fieldMetadata,
objectMetadataItems,
});
if (isDefined(junctionGqlFields) && depth === 1) {
return {
...recordGqlFields,
[fieldMetadata.name]: junctionGqlFields,
};
}
}
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem);
@@ -0,0 +1,112 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { buildIdentifierGqlFields } from '@/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields';
import {
getJunctionConfig,
type JunctionObjectMetadataItem,
} from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type JunctionFieldMetadataItem = Pick<
FieldMetadataItem,
'id' | 'name' | 'type' | 'settings' | 'morphRelations' | 'relation'
>;
type GenerateJunctionRelationGqlFieldsArgs = {
fieldMetadataItem: JunctionFieldMetadataItem;
objectMetadataItems: JunctionObjectMetadataItem[];
};
const buildRegularTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
): RecordGqlFields => {
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetField.relation?.targetObjectMetadata.id,
);
if (!isDefined(targetObjectMetadata)) {
return {};
}
return {
[targetField.name]: buildIdentifierGqlFields(targetObjectMetadata),
};
};
const buildMorphTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
): RecordGqlFields => {
const morphRelations = targetField.morphRelations;
if (!Array.isArray(morphRelations) || morphRelations.length === 0) {
return {};
}
const result: RecordGqlFields = {};
for (const morphRelation of morphRelations) {
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === morphRelation.targetObjectMetadata.id,
);
if (!isDefined(targetObjectMetadata)) {
continue;
}
const computedFieldName = computeMorphRelationFieldName({
fieldName: morphRelation.sourceFieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata);
}
return result;
};
const buildTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
): RecordGqlFields => {
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
return buildMorphTargetFieldGqlFields(targetField, objectMetadataItems);
}
return buildRegularTargetFieldGqlFields(targetField, objectMetadataItems);
};
// Generates GraphQL fields for a junction relation, including the nested target objects
export const generateJunctionRelationGqlFields = ({
fieldMetadataItem,
objectMetadataItems,
}: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => {
const junctionConfig = getJunctionConfig({
settings: fieldMetadataItem.settings,
relationObjectMetadataId:
fieldMetadataItem.relation?.targetObjectMetadata.id ?? '',
objectMetadataItems,
});
if (!isDefined(junctionConfig)) {
return null;
}
const { junctionObjectMetadata, targetFields } = junctionConfig;
const junctionTargetFields = targetFields.reduce<RecordGqlFields>(
(acc, targetField) => ({
...acc,
...buildTargetFieldGqlFields(targetField, objectMetadataItems),
}),
{},
);
return {
...buildIdentifierGqlFields(junctionObjectMetadata),
...junctionTargetFields,
};
};