Nested serialized relation property (#17490)

## Introduction
Handling nested serializedRelation references mapping
Removed the brand signature omit for the moment
I want to determine if it's really problematic later in the devx 
## Motivation

```ts
@ObjectType('RatioAggregateConfig')
export class RatioAggregateConfigDTO {
  @Field(() => UUIDScalarType)
  @IsUUID()
  @IsNotEmpty()
  fieldMetadataId: SerializedRelation;

  @Field(() => String)
  @IsString()
  @IsNotEmpty()
  optionValue: string;
}


@ObjectType('AggregateChartConfiguration')
export class AggregateChartConfigurationDTO
  implements PageLayoutWidgetConfigurationBase
{
// ...
  @Field(() => RatioAggregateConfigDTO, { nullable: true })
  @ValidateNested()
  @Type(() => RatioAggregateConfigDTO)
  @IsOptional()
  ratioAggregateConfig?: RatioAggregateConfigDTO;
}

```

Blocking https://github.com/twentyhq/twenty/pull/17452
This commit is contained in:
Paul Rastoin
2026-01-27 19:11:57 +01:00
committed by GitHub
parent 9162e62664
commit e1f92bd951
6 changed files with 280 additions and 196 deletions
@@ -1,173 +0,0 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type SerializedRelation } from 'twenty-shared/types';
import { type FormatJsonbSerializedRelation } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type';
import {
type JSONB_PROPERTY_BRAND,
type JsonbProperty,
} from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
type BrandedObjectWithRelation = JsonbProperty<{
name: string;
targetFieldMetadataId: SerializedRelation;
}>;
type BrandedObjectWithoutRelation = JsonbProperty<{
name: string;
count: number;
}>;
type UnbrandedObject = {
name: string;
targetFieldMetadataId: SerializedRelation;
};
// eslint-disable-next-line unused-imports/no-unused-vars
type BrandedObjectAssertions = [
// Branded object with SerializedRelation: Id suffix renamed to UniversalIdentifier
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
>
>,
// Branded object without SerializedRelation: no renaming, just removes brand
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithoutRelation>,
{
name: string;
count: number;
}
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnbrandedObjectAssertions = [
// Unbranded objects pass through unchanged
Expect<
Equal<FormatJsonbSerializedRelation<UnbrandedObject>, UnbrandedObject>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type PrimitiveAssertions = [
// Primitives pass through unchanged
Expect<Equal<FormatJsonbSerializedRelation<string>, string>>,
Expect<Equal<FormatJsonbSerializedRelation<number>, number>>,
Expect<Equal<FormatJsonbSerializedRelation<null>, null>>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ArrayAssertions = [
// Array of branded objects: transforms each element
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation[]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[]
>
>,
// Array of unbranded objects: passes through unchanged
Expect<
Equal<FormatJsonbSerializedRelation<UnbrandedObject[]>, UnbrandedObject[]>
>,
// Array of primitives: passes through unchanged
Expect<Equal<FormatJsonbSerializedRelation<string[]>, string[]>>,
// Nested array of branded objects: transforms innermost elements
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation[][]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[][]
>
>,
// Array of unbranded and branded objects union: transforms branded element
Expect<
Equal<
FormatJsonbSerializedRelation<
(BrandedObjectWithRelation | UnbrandedObject)[]
>,
(
| {
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
| UnbrandedObject
)[]
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnionAssertions = [
// Union with null: transforms branded object, keeps null
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation | null>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null
>
>,
// Array of union: transforms elements appropriately
Expect<
Equal<
FormatJsonbSerializedRelation<(BrandedObjectWithRelation | null)[]>,
({
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null)[]
>
>,
];
type MultipleRelationsObject = JsonbProperty<{
name: string;
sourceFieldId: SerializedRelation;
targetFieldId: SerializedRelation;
regularId: string;
}>;
// eslint-disable-next-line unused-imports/no-unused-vars
type MultipleRelationsAssertions = [
// Multiple SerializedRelation properties: all get renamed
Expect<
Equal<
FormatJsonbSerializedRelation<MultipleRelationsObject>,
{
name: string;
sourceFieldUniversalIdentifier: SerializedRelation;
targetFieldUniversalIdentifier: SerializedRelation;
regularId: string;
}
>
>,
];
// Verify brand is removed
type BrandRemovedCheck =
FormatJsonbSerializedRelation<BrandedObjectWithRelation>;
// eslint-disable-next-line unused-imports/no-unused-vars
type BrandRemovedAssertion = Expect<
Equal<
typeof JSONB_PROPERTY_BRAND extends keyof BrandRemovedCheck ? true : false,
false
>
>;
@@ -0,0 +1,254 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type SerializedRelation } from 'twenty-shared/types';
import { type FormatRecordSerializedRelationProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-record-serialized-relation-properties.type';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
type ObjectWithRelation = {
name: string;
targetFieldMetadataId: SerializedRelation;
};
type ObjectWithoutRelation = {
name: string;
count: number;
};
type BrandedObjectWithRelation = JsonbProperty<ObjectWithRelation>;
// eslint-disable-next-line unused-imports/no-unused-vars
type ObjectAssertions = [
// Object with SerializedRelation: Id suffix renamed to UniversalIdentifier
Expect<
Equal<
FormatRecordSerializedRelationProperties<ObjectWithRelation>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
>
>,
// Branded object with SerializedRelation: renames property, preserves brand key
Expect<
Equal<
FormatRecordSerializedRelationProperties<BrandedObjectWithRelation>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
__JsonbPropertyBrand__?: undefined;
}
>
>,
// Object without SerializedRelation: no changes
Expect<
Equal<
FormatRecordSerializedRelationProperties<ObjectWithoutRelation>,
ObjectWithoutRelation
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type PrimitiveAssertions = [
// Primitives pass through unchanged
Expect<Equal<FormatRecordSerializedRelationProperties<string>, string>>,
Expect<Equal<FormatRecordSerializedRelationProperties<number>, number>>,
Expect<Equal<FormatRecordSerializedRelationProperties<null>, null>>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ArrayAssertions = [
// Array of objects with relation: transforms each element
Expect<
Equal<
FormatRecordSerializedRelationProperties<ObjectWithRelation[]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[]
>
>,
// Array of objects without relation: passes through unchanged
Expect<
Equal<
FormatRecordSerializedRelationProperties<ObjectWithoutRelation[]>,
ObjectWithoutRelation[]
>
>,
// Array of primitives: passes through unchanged
Expect<Equal<FormatRecordSerializedRelationProperties<string[]>, string[]>>,
// Nested array of objects: transforms innermost elements
Expect<
Equal<
FormatRecordSerializedRelationProperties<ObjectWithRelation[][]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[][]
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnionAssertions = [
// Union with null: transforms object, keeps null
Expect<
Equal<
FormatRecordSerializedRelationProperties<ObjectWithRelation | null>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null
>
>,
// Array of union: transforms elements appropriately
Expect<
Equal<
FormatRecordSerializedRelationProperties<(ObjectWithRelation | null)[]>,
({
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null)[]
>
>,
];
type MultipleRelationsObject = {
name: string;
sourceFieldId: SerializedRelation;
targetFieldId: SerializedRelation;
regularId: string;
};
// eslint-disable-next-line unused-imports/no-unused-vars
type MultipleRelationsAssertions = [
// Multiple SerializedRelation properties: all get renamed
Expect<
Equal<
FormatRecordSerializedRelationProperties<MultipleRelationsObject>,
{
name: string;
sourceFieldUniversalIdentifier: SerializedRelation;
targetFieldUniversalIdentifier: SerializedRelation;
regularId: string;
}
>
>,
];
type NestedObjectWithRelation = {
name: string;
nested: {
fieldMetadataId: SerializedRelation;
};
};
type DeeplyNestedObjectWithRelation = {
name: string;
level1: {
level2: {
targetId: SerializedRelation;
};
};
};
type MixedNestedObject = {
name: string;
directRelationId: SerializedRelation;
nested: {
nestedRelationId: SerializedRelation;
plainField: string;
};
};
type NullableNestedObject = {
name: string;
nested: {
relationId: SerializedRelation;
} | null;
};
type NestedWithArrayOfObjects = {
name: string;
items: {
itemRelationId: SerializedRelation;
}[];
};
// eslint-disable-next-line unused-imports/no-unused-vars
type NestedObjectAssertions = [
// Simple nested object: transforms relation inside nested object
Expect<
Equal<
FormatRecordSerializedRelationProperties<NestedObjectWithRelation>,
{
name: string;
nested: {
fieldMetadataUniversalIdentifier: SerializedRelation;
};
}
>
>,
// Deeply nested object: transforms relation at any depth
Expect<
Equal<
FormatRecordSerializedRelationProperties<DeeplyNestedObjectWithRelation>,
{
name: string;
level1: {
level2: {
targetUniversalIdentifier: SerializedRelation;
};
};
}
>
>,
// Mixed: transforms both direct and nested relations
Expect<
Equal<
FormatRecordSerializedRelationProperties<MixedNestedObject>,
{
name: string;
directRelationUniversalIdentifier: SerializedRelation;
nested: {
nestedRelationUniversalIdentifier: SerializedRelation;
plainField: string;
};
}
>
>,
// Nullable nested object: transforms relation inside, preserves union with null
Expect<
Equal<
FormatRecordSerializedRelationProperties<NullableNestedObject>,
{
name: string;
nested: {
relationUniversalIdentifier: SerializedRelation;
} | null;
}
>
>,
// Nested with array of objects: transforms relations inside array elements
Expect<
Equal<
FormatRecordSerializedRelationProperties<NestedWithArrayOfObjects>,
{
name: string;
items: {
itemRelationUniversalIdentifier: SerializedRelation;
}[];
}
>
>,
];
@@ -99,6 +99,7 @@ type NarrowedExpectedResult = {
onDelete?: RelationOnDeleteAction | undefined;
joinColumnName?: string | null | undefined;
junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
__JsonbPropertyBrand__?: undefined;
};
type SettingsTestCase = UniversalFlatFieldMetadata<
@@ -111,14 +112,17 @@ type SettingsExpectedResult =
onDelete?: RelationOnDeleteAction | undefined;
joinColumnName?: string | null | undefined;
junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
__JsonbPropertyBrand__?: undefined;
}
| {
dataType?: NumberDataType | undefined;
decimals?: number | undefined;
type?: FieldNumberVariant | undefined;
__JsonbPropertyBrand__?: undefined;
}
| {
displayedMaxRows?: number | undefined;
__JsonbPropertyBrand__?: undefined;
}
| null;
@@ -137,11 +141,13 @@ type DefaultValueExpectedResult =
| {
amountMicros: string | null;
currencyCode: string | null;
__JsonbPropertyBrand__?: undefined;
}
| {
primaryLinkLabel: string | null;
primaryLinkUrl: string | null;
secondaryLinks: LinkMetadata[] | null;
__JsonbPropertyBrand__?: undefined;
};
type OptionsTestCase =
@@ -1,21 +0,0 @@
import { type ExtractSerializedRelationProperties } from 'twenty-shared/types';
import { type HasJsonbPropertyBrand } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type JSONB_PROPERTY_BRAND } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
export type FormatJsonbSerializedRelation<T> = T extends unknown
? T extends (infer U)[]
? FormatJsonbSerializedRelation<U>[]
: HasJsonbPropertyBrand<T> extends true
? Omit<
{
[P in keyof T as P extends ExtractSerializedRelationProperties<T> &
string
? `${RemoveSuffix<P, 'Id'>}UniversalIdentifier`
: P]: T[P];
},
typeof JSONB_PROPERTY_BRAND
>
: T
: never;
@@ -0,0 +1,18 @@
import { type ExtractSerializedRelationProperties } from 'twenty-shared/types';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
export type FormatRecordSerializedRelationProperties<T> = T extends unknown
? T extends (infer U)[]
? FormatRecordSerializedRelationProperties<U>[]
: T extends string
? T
: T extends object
? {
[P in keyof T as P extends ExtractSerializedRelationProperties<T> &
string
? `${RemoveSuffix<P, 'Id'>}UniversalIdentifier`
: P]: FormatRecordSerializedRelationProperties<T[P]>;
}
: T
: never;
@@ -7,7 +7,7 @@ import { type FromMetadataEntityToMetadataName } from 'src/engine/metadata-modul
import { type MetadataManyToOneJoinColumn } from 'src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-join-column.type';
import { type SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type ExtractJsonbProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type FormatJsonbSerializedRelation } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type';
import { type FormatRecordSerializedRelationProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-record-serialized-relation-properties.type';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
// TODO Handle universal settings
@@ -38,7 +38,7 @@ export type UniversalFlatEntityFrom<
} & {
applicationUniversalIdentifier: string;
} & {
[P in ExtractJsonbProperties<TEntity>]: FormatJsonbSerializedRelation<
[P in ExtractJsonbProperties<TEntity>]: FormatRecordSerializedRelationProperties<
TEntity[P]
>;
};