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:
+56
@@ -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,
|
||||
});
|
||||
};
|
||||
+32
@@ -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
|
||||
>;
|
||||
};
|
||||
+118
@@ -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 [];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
+77
@@ -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,
|
||||
};
|
||||
};
|
||||
+13
@@ -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;
|
||||
}
|
||||
+34
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user