Object metadata API create one using workspace migration v2 (#13420)

# Introduction
In this PR we create basic transpilation methods and utils to handle
input to flat, entity to flat, object maps to flat. In order to
transpile everything into a common validation that will be implemented
in another PR

## FieldMetadataEntity typing
Added `never | null` to fields that should never be in order to ease
general abstracted method to pass null, as anw it's what is in the
database

## Todo
- ~~Create a feature flag~~
- Integration test for object creation through metadata api + pg col
introspection and snapshoting
This commit is contained in:
Paul Rastoin
2025-07-29 17:47:28 +02:00
committed by GitHub
parent 600df4fd90
commit c1bf0a1fbf
66 changed files with 1224 additions and 164 deletions
@@ -0,0 +1,56 @@
import { faker } from '@faker-js/faker';
import { FieldMetadataType } from 'twenty-shared/types';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
type FlatFieldMetadataOverrides<
T extends FieldMetadataType = FieldMetadataType,
> = Required<
Pick<FlatFieldMetadata<T>, 'uniqueIdentifier' | 'objectMetadataId' | 'type'>
> &
Partial<FlatFieldMetadata<T>>;
export const getFlatFieldMetadataMock = <T extends FieldMetadataType>(
overrides: FlatFieldMetadataOverrides<T>,
): FlatFieldMetadata => {
const createdAt = faker.date.anytime();
return {
defaultValue: null,
options: null,
settings: null,
createdAt,
description: 'default flat field metadata description',
icon: 'icon',
id: faker.string.uuid(),
isActive: true,
isCustom: true,
name: 'flatFieldMetadataName',
label: 'flat field metadata label',
isNullable: true,
isUnique: false,
isLabelSyncedWithName: false,
isSystem: false,
standardId: null,
standardOverrides: null,
updatedAt: createdAt,
workspaceId: faker.string.uuid(),
flatRelationTargetFieldMetadata: null,
relationTargetFieldMetadataId: null,
flatRelationTargetObjectMetadata: null,
relationTargetObjectMetadataId: null,
...overrides,
};
};
export const getStandardFlatFieldMetadataMock = (
overrides: Omit<FlatFieldMetadataOverrides, 'isCustom' | 'isSystem'>,
) => {
return getFlatFieldMetadataMock({
standardId: faker.string.uuid(),
standardOverrides: {},
isCustom: false,
isSystem: true,
...overrides,
});
};
@@ -0,0 +1,32 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { AssignTypeIfIsRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-type-if-is-relation-field-metadata-type.type';
import { FlatObjectMetadataWithoutFields } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const fieldMetadataRelationProperties = [
'relationTargetFieldMetadata',
'relationTargetObjectMetadata',
'fieldPermissions',
'indexFieldMetadatas',
'object',
] as const satisfies (keyof FieldMetadataEntity)[];
export type FieldMetadataEntityRelationProperties =
(typeof fieldMetadataRelationProperties)[number];
export type FlatFieldMetadata<T extends FieldMetadataType = FieldMetadataType> =
Omit<FieldMetadataEntity<T>, FieldMetadataEntityRelationProperties> & {
uniqueIdentifier: string;
flatRelationTargetFieldMetadata: AssignTypeIfIsRelationFieldMetadataType<
Omit<
FlatFieldMetadata,
'flatRelationTargetFieldMetadata' | 'flatRelationTargetObjectMetadata'
>,
T
>;
flatRelationTargetObjectMetadata: AssignTypeIfIsRelationFieldMetadataType<
FlatObjectMetadataWithoutFields,
T
>;
};
@@ -0,0 +1,118 @@
import diff from 'microdiff';
import { FieldMetadataType, FromTo } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-field-action-v2';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
const flatFieldMetadataPropertiesToCompare = [
'defaultValue',
'description',
'icon',
'isActive',
'isLabelSyncedWithName',
'isUnique',
'label',
'name',
'options',
'standardOverrides',
'settings',
] as const satisfies (keyof FlatFieldMetadata)[];
export type FlatFieldMetadataPropertiesToCompare =
(typeof flatFieldMetadataPropertiesToCompare)[number];
const fieldMetadataPropertiesToStringify = [
'defaultValue',
'standardOverrides',
'settings',
] as const satisfies FlatFieldMetadataPropertiesToCompare[];
const shouldNotOverrideDefaultValue = (type: FieldMetadataType) => {
return [
FieldMetadataType.BOOLEAN,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.CURRENCY,
FieldMetadataType.PHONES,
FieldMetadataType.ADDRESS,
].includes(type);
};
type GetWorkspaceMigrationUpdateFieldActionArgs = FromTo<FlatFieldMetadata>;
/**
* This comparator handles update on colliding uniqueIdentifier flatFieldMetadata
*/
export const compareTwoFlatFieldMetadata = ({
from,
to,
}: GetWorkspaceMigrationUpdateFieldActionArgs) => {
const compareFieldMetadataOptions = {
shouldIgnoreProperty: (
property: string,
fieldMetadata: FlatFieldMetadata,
) => {
if (
!flatFieldMetadataPropertiesToCompare.includes(
property as FlatFieldMetadataPropertiesToCompare,
)
) {
return true;
}
if (
property === 'defaultValue' &&
isDefined(fieldMetadata.type) &&
shouldNotOverrideDefaultValue(fieldMetadata.type)
) {
return true;
}
// Remove below assertion when we authorize relation edition, see https://github.com/twentyhq/twenty/commit/39f6f3c4bb101272a9014e142a842d0801a3c33b
if (
isDefined(fieldMetadata.type) &&
isRelationFieldMetadataType(fieldMetadata.type) &&
!['label', 'description', 'isActive'].includes(property)
) {
return true;
}
return false;
},
propertiesToStringify: fieldMetadataPropertiesToStringify,
};
const fromCompare = transformMetadataForComparison(
from,
compareFieldMetadataOptions,
);
const toCompare = transformMetadataForComparison(
to,
compareFieldMetadataOptions,
);
const flatFieldMetadataDifferences = diff(fromCompare, toCompare);
return flatFieldMetadataDifferences.flatMap<
UpdateFieldAction['updates'][number]
>((difference) => {
switch (difference.type) {
case 'CHANGE': {
const { oldValue, path, value } = difference;
return {
from: oldValue,
to: value,
property: path[0] as FlatFieldMetadataPropertiesToCompare,
};
}
case 'CREATE':
case 'REMOVE':
default: {
// Should never occurs, we should only provide null never undefined and so on
return [];
}
}
});
};
@@ -0,0 +1,77 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FlatFieldMetadata,
fieldMetadataRelationProperties,
} from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { fromObjectMetadataEntityToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { fromFlatObjectMetadataToFlatObjectMetadataWithoutFields } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/from-flat-object-metadata-to-flat-object-metadata-without-fields.util';
export const fromFieldMetadataEntityToFlatFieldMetadata = <
T extends FieldMetadataType,
>(
fieldMetadataEntity: FieldMetadataEntity<T>,
/**
* private depth bottleneck
*/
_depth?: number,
// This is intended to be abstract
): FlatFieldMetadata => {
if (isDefined(_depth) && _depth > 1) {
throw new Error(
'fromFieldMetadataEntityToFlatFieldMetadata entering a possible infinite loop',
);
}
const fieldMetadataWithoutRelations = removePropertiesFromRecord(
fieldMetadataEntity,
fieldMetadataRelationProperties,
);
if (
isFieldMetadataEntityOfType(
fieldMetadataEntity,
FieldMetadataType.RELATION,
) ||
isFieldMetadataEntityOfType(
fieldMetadataEntity,
FieldMetadataType.MORPH_RELATION,
)
) {
const newDepth = isDefined(_depth) ? _depth + 1 : 1;
const flatRelationTargetFieldMetadata =
fromFieldMetadataEntityToFlatFieldMetadata(
fieldMetadataEntity.relationTargetFieldMetadata,
newDepth,
);
const flatObjectTargetFieldMetadata =
fromObjectMetadataEntityToFlatObjectMetadata(
fieldMetadataEntity.relationTargetObjectMetadata,
);
const flatRelationTargetObjectMetadata =
fromFlatObjectMetadataToFlatObjectMetadataWithoutFields(
flatObjectTargetFieldMetadata,
);
return {
...fieldMetadataWithoutRelations,
uniqueIdentifier:
fieldMetadataWithoutRelations.standardId ??
fieldMetadataWithoutRelations.id,
flatRelationTargetFieldMetadata,
flatRelationTargetObjectMetadata,
} as FlatFieldMetadata<FieldMetadataType.RELATION>;
}
return {
...fieldMetadataWithoutRelations,
uniqueIdentifier:
fieldMetadataWithoutRelations.standardId ??
fieldMetadataWithoutRelations.id,
flatRelationTargetFieldMetadata: null,
flatRelationTargetObjectMetadata: null,
};
};
@@ -0,0 +1,13 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export function isFlatFieldMetadataEntityOfType<
Field extends FlatFieldMetadata<FieldMetadataType>,
Type extends FieldMetadataType,
>(
fieldMetadata: Pick<Field, 'type'>,
type: Type,
): fieldMetadata is Field & FlatFieldMetadata<Type> {
return fieldMetadata.type === type;
}
@@ -0,0 +1,34 @@
import {
deepMerge,
fromArrayToUniqueKeyRecord,
isDefined,
} from 'twenty-shared/utils';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { ToMerge } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/to-merge.type';
export const mergeTwoFlatFieldMetadatas = ({
destFlatFieldMetadatas,
toMergeFlatFieldMetadatas,
}: ToMerge<FlatFieldMetadata[], 'field'>): FlatFieldMetadata[] => {
const initialRecordAccumulator = fromArrayToUniqueKeyRecord({
array: destFlatFieldMetadatas,
uniqueKey: 'uniqueIdentifier',
});
const mergedUniqueIdentifierFlatFieldMetadataRecord =
toMergeFlatFieldMetadatas.reduce((acc, flatFieldToMerge) => {
const fieldUniqueIdentifier = flatFieldToMerge.uniqueIdentifier;
const destFieldMetadata: FlatFieldMetadata | undefined =
acc[fieldUniqueIdentifier];
return {
...acc,
[fieldUniqueIdentifier]: isDefined(destFieldMetadata)
? deepMerge(destFieldMetadata, flatFieldToMerge)
: flatFieldToMerge,
};
}, initialRecordAccumulator);
return Object.values(mergedUniqueIdentifierFlatFieldMetadataRecord);
};