Factorize from entity to flat entity utils (#21972)
## What Factorizes the two responsibilities that were copy‑pasted across every `from-<entity>-entity-to-flat-<entity>` util into two reusable tools. ### `fromEntityToScalarEntity` Projects a TypeORM entity into its scalar flat shape using an **allow‑list** driven by `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME` (plus the base columns `id`/`workspaceId`/`applicationId`/`universalIdentifier`). Only registered scalar columns are forwarded, `Date`s are serialized to ISO strings, and absent values are normalized to `null`. Replaces the previous deny‑list (`removePropertiesFromRecord`) approach, so unregistered/deprecated columns can no longer silently leak into the flat entity. ### `resolveManyToOneRelationIdsToUniversalIdentifiers` Resolves an entity's many‑to‑one foreign keys to their universal identifiers, driven by `ALL_MANY_TO_ONE_METADATA_RELATIONS`. Handles the always‑present `application`, nullable relations, and throws a `FlatEntityMapsException` when a referenced id is missing from its identifier map. Mirrors `resolveUniversalRelationIdentifiersToIds` in the opposite direction. Each `from-<entity>` util now reduces to: scalar spread + relation spread (+ explicit one‑to‑many id/universalIdentifier arrays where applicable). ### Note The allow‑list drops `isUIReadOnly` (a `WasRemovedInUpgrade` column not in the config) from `fieldMetadata`, which is the only integration‑snapshot change. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21972?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
import { type AllMetadataName } from 'twenty-shared/metadata';
|
||||
|
||||
import { FlatEntityMapsExceptionCode } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { type EntityManyToOneIdByUniversalIdentifierMaps } from 'src/engine/workspace-cache/types/entity-many-to-one-id-by-universal-identifier-maps.type';
|
||||
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
|
||||
import { resolveManyToOneRelationIdsToUniversalIdentifiers } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-many-to-one-relation-ids-to-universal-identifiers.util';
|
||||
|
||||
const buildArgs = <T extends AllMetadataName>(
|
||||
args: {
|
||||
metadataName: T;
|
||||
entity: Record<string, string | null | undefined>;
|
||||
} & EntityManyToOneIdByUniversalIdentifierMaps<T>,
|
||||
): { metadataName: T } & FromEntityToFlatEntityArgs<T> => ({
|
||||
...args,
|
||||
entity: args.entity as unknown as FromEntityToFlatEntityArgs<T>['entity'],
|
||||
});
|
||||
|
||||
describe('resolveManyToOneRelationIdsToUniversalIdentifiers', () => {
|
||||
describe('application resolution', () => {
|
||||
it('should resolve only the application universal identifier when the entity has no other many-to-one relations', () => {
|
||||
const result = resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'permissionFlag',
|
||||
entity: {
|
||||
id: 'permission-flag-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
applicationUniversalIdentifier: 'app-ui-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when the applicationId is not present in the map', () => {
|
||||
expect(() =>
|
||||
resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'permissionFlag',
|
||||
entity: {
|
||||
id: 'permission-flag-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map(),
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when the entity has no applicationId', () => {
|
||||
expect(() =>
|
||||
resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'permissionFlag',
|
||||
entity: {
|
||||
id: 'permission-flag-id-1',
|
||||
applicationId: null,
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-nullable relations', () => {
|
||||
it('should resolve universal identifiers for application and non-nullable foreign keys', () => {
|
||||
const result = resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'viewField',
|
||||
entity: {
|
||||
id: 'view-field-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
fieldMetadataId: 'field-id-1',
|
||||
viewId: 'view-id-1',
|
||||
viewFieldGroupId: 'view-field-group-id-1',
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
fieldMetadataIdToUniversalIdentifierMap: new Map([
|
||||
['field-id-1', 'field-ui-1'],
|
||||
]),
|
||||
viewIdToUniversalIdentifierMap: new Map([['view-id-1', 'view-ui-1']]),
|
||||
viewFieldGroupIdToUniversalIdentifierMap: new Map([
|
||||
['view-field-group-id-1', 'view-field-group-ui-1'],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
applicationUniversalIdentifier: 'app-ui-1',
|
||||
fieldMetadataUniversalIdentifier: 'field-ui-1',
|
||||
viewUniversalIdentifier: 'view-ui-1',
|
||||
viewFieldGroupUniversalIdentifier: 'view-field-group-ui-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when a non-nullable foreign key id is missing from the map', () => {
|
||||
expect(() =>
|
||||
resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'viewField',
|
||||
entity: {
|
||||
id: 'view-field-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
fieldMetadataId: 'non-existent-field-id',
|
||||
viewId: 'view-id-1',
|
||||
viewFieldGroupId: null,
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
fieldMetadataIdToUniversalIdentifierMap: new Map(),
|
||||
viewIdToUniversalIdentifierMap: new Map([
|
||||
['view-id-1', 'view-ui-1'],
|
||||
]),
|
||||
viewFieldGroupIdToUniversalIdentifierMap: new Map(),
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a malformed-entity error when a non-nullable foreign key is itself missing', () => {
|
||||
expect(() =>
|
||||
resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'viewField',
|
||||
entity: {
|
||||
id: 'view-field-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
fieldMetadataId: null,
|
||||
viewId: 'view-id-1',
|
||||
viewFieldGroupId: null,
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
fieldMetadataIdToUniversalIdentifierMap: new Map([
|
||||
['field-id-1', 'field-ui-1'],
|
||||
]),
|
||||
viewIdToUniversalIdentifierMap: new Map([
|
||||
['view-id-1', 'view-ui-1'],
|
||||
]),
|
||||
viewFieldGroupIdToUniversalIdentifierMap: new Map(),
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nullable relations', () => {
|
||||
it('should resolve a nullable foreign key to null when its id is not defined', () => {
|
||||
const result = resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'viewField',
|
||||
entity: {
|
||||
id: 'view-field-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
fieldMetadataId: 'field-id-1',
|
||||
viewId: 'view-id-1',
|
||||
viewFieldGroupId: null,
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
fieldMetadataIdToUniversalIdentifierMap: new Map([
|
||||
['field-id-1', 'field-ui-1'],
|
||||
]),
|
||||
viewIdToUniversalIdentifierMap: new Map([['view-id-1', 'view-ui-1']]),
|
||||
viewFieldGroupIdToUniversalIdentifierMap: new Map(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
applicationUniversalIdentifier: 'app-ui-1',
|
||||
fieldMetadataUniversalIdentifier: 'field-ui-1',
|
||||
viewUniversalIdentifier: 'view-ui-1',
|
||||
viewFieldGroupUniversalIdentifier: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when a nullable foreign key has an id that is missing from the map', () => {
|
||||
expect(() =>
|
||||
resolveManyToOneRelationIdsToUniversalIdentifiers(
|
||||
buildArgs({
|
||||
metadataName: 'viewField',
|
||||
entity: {
|
||||
id: 'view-field-id-1',
|
||||
applicationId: 'app-id-1',
|
||||
fieldMetadataId: 'field-id-1',
|
||||
viewId: 'view-id-1',
|
||||
viewFieldGroupId: 'non-existent-view-field-group-id',
|
||||
},
|
||||
applicationIdToUniversalIdentifierMap: new Map([
|
||||
['app-id-1', 'app-ui-1'],
|
||||
]),
|
||||
fieldMetadataIdToUniversalIdentifierMap: new Map([
|
||||
['field-id-1', 'field-ui-1'],
|
||||
]),
|
||||
viewIdToUniversalIdentifierMap: new Map([
|
||||
['view-id-1', 'view-ui-1'],
|
||||
]),
|
||||
viewFieldGroupIdToUniversalIdentifierMap: new Map(),
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { type AllMetadataName } from 'twenty-shared/metadata';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
FlatEntityMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
|
||||
|
||||
type ManyToOneRelationsConfig<T extends AllMetadataName> =
|
||||
(typeof ALL_MANY_TO_ONE_METADATA_RELATIONS)[T];
|
||||
|
||||
// Mirrors ResolvedForeignKeyIds from resolveUniversalRelationIdentifiersToIds but
|
||||
// keyed on universalForeignKey, so the resolved record is the many-to-one part of a
|
||||
// flat entity (plus the always-present applicationUniversalIdentifier).
|
||||
export type ResolvedManyToOneRelationUniversalIdentifiers<
|
||||
T extends AllMetadataName,
|
||||
> = {
|
||||
[K in keyof ManyToOneRelationsConfig<T> as ManyToOneRelationsConfig<T>[K] extends {
|
||||
universalForeignKey: infer UniversalForeignKey extends string;
|
||||
}
|
||||
? UniversalForeignKey
|
||||
: never]: ManyToOneRelationsConfig<T>[K] extends { isNullable: true }
|
||||
? string | null
|
||||
: string;
|
||||
} & {
|
||||
applicationUniversalIdentifier: string;
|
||||
};
|
||||
|
||||
export const resolveManyToOneRelationIdsToUniversalIdentifiers = <
|
||||
T extends AllMetadataName,
|
||||
>({
|
||||
metadataName,
|
||||
entity,
|
||||
...idToUniversalIdentifierMaps
|
||||
}: {
|
||||
metadataName: T;
|
||||
} & FromEntityToFlatEntityArgs<T>): ResolvedManyToOneRelationUniversalIdentifiers<T> => {
|
||||
const readEntityForeignKey = (
|
||||
propertyName: string,
|
||||
): string | null | undefined =>
|
||||
entity[propertyName as keyof typeof entity] as string | null | undefined;
|
||||
|
||||
const entityId = readEntityForeignKey('id');
|
||||
const resolvedUniversalIdentifierByForeignKey: Record<string, string | null> =
|
||||
{};
|
||||
|
||||
const applicationId = readEntityForeignKey('applicationId');
|
||||
const applicationUniversalIdentifier = isDefined(applicationId)
|
||||
? idToUniversalIdentifierMaps.applicationIdToUniversalIdentifierMap.get(
|
||||
applicationId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(applicationUniversalIdentifier)) {
|
||||
throw new FlatEntityMapsException(
|
||||
`Application with id ${applicationId} not found for ${metadataName} ${entityId}`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
resolvedUniversalIdentifierByForeignKey.applicationUniversalIdentifier =
|
||||
applicationUniversalIdentifier;
|
||||
|
||||
const relationEntries = ALL_MANY_TO_ONE_METADATA_RELATIONS[metadataName];
|
||||
|
||||
for (const relationPropertyName of Object.keys(relationEntries)) {
|
||||
const relationEntry = relationEntries[
|
||||
relationPropertyName as keyof typeof relationEntries
|
||||
] as {
|
||||
foreignKey: string;
|
||||
metadataName: AllMetadataName;
|
||||
isNullable: boolean;
|
||||
universalForeignKey: string;
|
||||
} | null;
|
||||
|
||||
if (!isDefined(relationEntry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const {
|
||||
foreignKey,
|
||||
metadataName: targetMetadataName,
|
||||
universalForeignKey,
|
||||
isNullable,
|
||||
} = relationEntry;
|
||||
const foreignKeyId = readEntityForeignKey(foreignKey);
|
||||
|
||||
if (!isDefined(foreignKeyId)) {
|
||||
if (isNullable) {
|
||||
resolvedUniversalIdentifierByForeignKey[universalForeignKey] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new FlatEntityMapsException(
|
||||
`Missing non-nullable foreign key ${foreignKey} on ${metadataName} ${entityId} (relation to ${targetMetadataName})`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
|
||||
);
|
||||
}
|
||||
|
||||
const mapKey = `${targetMetadataName}IdToUniversalIdentifierMap`;
|
||||
const targetIdToUniversalIdentifierMap = idToUniversalIdentifierMaps[
|
||||
mapKey as keyof typeof idToUniversalIdentifierMaps
|
||||
] as unknown as Map<string, string> | undefined;
|
||||
|
||||
if (!isDefined(targetIdToUniversalIdentifierMap)) {
|
||||
throw new FlatEntityMapsException(
|
||||
`Missing ${mapKey} when resolving ${metadataName} ${entityId}`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const universalIdentifier =
|
||||
targetIdToUniversalIdentifierMap.get(foreignKeyId);
|
||||
|
||||
if (!isDefined(universalIdentifier)) {
|
||||
throw new FlatEntityMapsException(
|
||||
`${capitalize(targetMetadataName)} with id ${foreignKeyId} not found for ${metadataName} ${entityId}`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
resolvedUniversalIdentifierByForeignKey[universalForeignKey] =
|
||||
universalIdentifier;
|
||||
}
|
||||
|
||||
return resolvedUniversalIdentifierByForeignKey as ResolvedManyToOneRelationUniversalIdentifiers<T>;
|
||||
};
|
||||
Reference in New Issue
Block a user