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,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();