From e1f92bd95142481f51e559e1d21f322fa0f5f472 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:11:57 +0100 Subject: [PATCH] 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 --- ...mat-jsonb-serialized-relation.test-type.ts | 173 ------------ ...erialized-relation-properties.test-type.ts | 254 ++++++++++++++++++ ...universal-flat-field-metadata.test-type.ts | 6 + .../format-jsonb-serialized-relation.type.ts | 21 -- ...ord-serialized-relation-properties.type.ts | 18 ++ .../types/universal-flat-entity-from.type.ts | 4 +- 6 files changed, 280 insertions(+), 196 deletions(-) delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-jsonb-serialized-relation.test-type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-record-serialized-relation-properties.test-type.ts delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-record-serialized-relation-properties.type.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-jsonb-serialized-relation.test-type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-jsonb-serialized-relation.test-type.ts deleted file mode 100644 index d1ede3975d..0000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-jsonb-serialized-relation.test-type.ts +++ /dev/null @@ -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, - { - name: string; - targetFieldMetadataUniversalIdentifier: SerializedRelation; - } - > - >, - - // Branded object without SerializedRelation: no renaming, just removes brand - Expect< - Equal< - FormatJsonbSerializedRelation, - { - name: string; - count: number; - } - > - >, -]; - -// eslint-disable-next-line unused-imports/no-unused-vars -type UnbrandedObjectAssertions = [ - // Unbranded objects pass through unchanged - Expect< - Equal, UnbrandedObject> - >, -]; - -// eslint-disable-next-line unused-imports/no-unused-vars -type PrimitiveAssertions = [ - // Primitives pass through unchanged - Expect, string>>, - Expect, number>>, - Expect, null>>, -]; - -// eslint-disable-next-line unused-imports/no-unused-vars -type ArrayAssertions = [ - // Array of branded objects: transforms each element - Expect< - Equal< - FormatJsonbSerializedRelation, - { - name: string; - targetFieldMetadataUniversalIdentifier: SerializedRelation; - }[] - > - >, - - // Array of unbranded objects: passes through unchanged - Expect< - Equal, UnbrandedObject[]> - >, - - // Array of primitives: passes through unchanged - Expect, string[]>>, - - // Nested array of branded objects: transforms innermost elements - Expect< - Equal< - FormatJsonbSerializedRelation, - { - 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, - { - 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, - { - name: string; - sourceFieldUniversalIdentifier: SerializedRelation; - targetFieldUniversalIdentifier: SerializedRelation; - regularId: string; - } - > - >, -]; - -// Verify brand is removed -type BrandRemovedCheck = - FormatJsonbSerializedRelation; - -// eslint-disable-next-line unused-imports/no-unused-vars -type BrandRemovedAssertion = Expect< - Equal< - typeof JSONB_PROPERTY_BRAND extends keyof BrandRemovedCheck ? true : false, - false - > ->; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-record-serialized-relation-properties.test-type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-record-serialized-relation-properties.test-type.ts new file mode 100644 index 0000000000..b876d7f906 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/format-record-serialized-relation-properties.test-type.ts @@ -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; + +// eslint-disable-next-line unused-imports/no-unused-vars +type ObjectAssertions = [ + // Object with SerializedRelation: Id suffix renamed to UniversalIdentifier + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + targetFieldMetadataUniversalIdentifier: SerializedRelation; + } + > + >, + + // Branded object with SerializedRelation: renames property, preserves brand key + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + targetFieldMetadataUniversalIdentifier: SerializedRelation; + __JsonbPropertyBrand__?: undefined; + } + > + >, + + // Object without SerializedRelation: no changes + Expect< + Equal< + FormatRecordSerializedRelationProperties, + ObjectWithoutRelation + > + >, +]; + +// eslint-disable-next-line unused-imports/no-unused-vars +type PrimitiveAssertions = [ + // Primitives pass through unchanged + Expect, string>>, + Expect, number>>, + Expect, null>>, +]; + +// eslint-disable-next-line unused-imports/no-unused-vars +type ArrayAssertions = [ + // Array of objects with relation: transforms each element + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + targetFieldMetadataUniversalIdentifier: SerializedRelation; + }[] + > + >, + + // Array of objects without relation: passes through unchanged + Expect< + Equal< + FormatRecordSerializedRelationProperties, + ObjectWithoutRelation[] + > + >, + + // Array of primitives: passes through unchanged + Expect, string[]>>, + + // Nested array of objects: transforms innermost elements + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + 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, + { + 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, + { + 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, + { + name: string; + nested: { + fieldMetadataUniversalIdentifier: SerializedRelation; + }; + } + > + >, + + // Deeply nested object: transforms relation at any depth + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + level1: { + level2: { + targetUniversalIdentifier: SerializedRelation; + }; + }; + } + > + >, + + // Mixed: transforms both direct and nested relations + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + directRelationUniversalIdentifier: SerializedRelation; + nested: { + nestedRelationUniversalIdentifier: SerializedRelation; + plainField: string; + }; + } + > + >, + + // Nullable nested object: transforms relation inside, preserves union with null + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + nested: { + relationUniversalIdentifier: SerializedRelation; + } | null; + } + > + >, + + // Nested with array of objects: transforms relations inside array elements + Expect< + Equal< + FormatRecordSerializedRelationProperties, + { + name: string; + items: { + itemRelationUniversalIdentifier: SerializedRelation; + }[]; + } + > + >, +]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-field-metadata.test-type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-field-metadata.test-type.ts index 722886afd9..09bb2ead56 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-field-metadata.test-type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-field-metadata.test-type.ts @@ -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 = diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type.ts deleted file mode 100644 index 251c1aea5c..0000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type.ts +++ /dev/null @@ -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 extends unknown - ? T extends (infer U)[] - ? FormatJsonbSerializedRelation[] - : HasJsonbPropertyBrand extends true - ? Omit< - { - [P in keyof T as P extends ExtractSerializedRelationProperties & - string - ? `${RemoveSuffix}UniversalIdentifier` - : P]: T[P]; - }, - typeof JSONB_PROPERTY_BRAND - > - : T - : never; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-record-serialized-relation-properties.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-record-serialized-relation-properties.type.ts new file mode 100644 index 0000000000..62bddcefce --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-record-serialized-relation-properties.type.ts @@ -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 extends unknown + ? T extends (infer U)[] + ? FormatRecordSerializedRelationProperties[] + : T extends string + ? T + : T extends object + ? { + [P in keyof T as P extends ExtractSerializedRelationProperties & + string + ? `${RemoveSuffix}UniversalIdentifier` + : P]: FormatRecordSerializedRelationProperties; + } + : T + : never; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type.ts index 1708741bb0..b7a3b835a9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type.ts @@ -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]: FormatJsonbSerializedRelation< + [P in ExtractJsonbProperties]: FormatRecordSerializedRelationProperties< TEntity[P] >; };