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
@@ -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,