Fix 2.16 search field metadata cross version upgrade (#22039)
# Introduction
Allow decorating at class scope the properties introduced in specific
upgrade command
```
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME,
properties: ['universalIdentifier', 'applicationId', 'position'],
})
```
Here the search field metadata has been created as it without extending
the syncableEntity a previous PR I've created now extends it, but
nothing has been protected the fact they're not decorated. Also having
to re-declare the properties would be redundant to me
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22039?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:
+2
@@ -0,0 +1,2 @@
|
||||
export const ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME =
|
||||
'2.16.0_AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand_1782200000000';
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
WasIntroducedInUpgrade,
|
||||
getWasIntroducedInUpgradeClassMetadata,
|
||||
getWasIntroducedInUpgradePropertyMetadata,
|
||||
} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
|
||||
describe('WasIntroducedInUpgrade', () => {
|
||||
it('records class-level entity introduction metadata', () => {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-class' })
|
||||
class Example {}
|
||||
|
||||
expect(getWasIntroducedInUpgradeClassMetadata(Example)).toEqual({
|
||||
upgradeCommandName: 'upgrade-step-class',
|
||||
});
|
||||
expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({});
|
||||
});
|
||||
|
||||
it('records property-level metadata keyed by property name', () => {
|
||||
class Example {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-foo' })
|
||||
foo!: string;
|
||||
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-bar' })
|
||||
bar!: string;
|
||||
|
||||
untouched!: string;
|
||||
}
|
||||
|
||||
expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({
|
||||
foo: { upgradeCommandName: 'upgrade-step-foo' },
|
||||
bar: { upgradeCommandName: 'upgrade-step-bar' },
|
||||
});
|
||||
expect(getWasIntroducedInUpgradeClassMetadata(Example)).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('class-level properties form (for inherited columns)', () => {
|
||||
it('registers the listed properties in the property-introduction map', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: 'upgrade-step-columns',
|
||||
properties: ['inheritedA', 'inheritedB'],
|
||||
})
|
||||
class Example {}
|
||||
|
||||
expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({
|
||||
inheritedA: { upgradeCommandName: 'upgrade-step-columns' },
|
||||
inheritedB: { upgradeCommandName: 'upgrade-step-columns' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark the entity itself as introduced', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: 'upgrade-step-columns',
|
||||
properties: ['inheritedA'],
|
||||
})
|
||||
class Example {}
|
||||
|
||||
expect(getWasIntroducedInUpgradeClassMetadata(Example)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges with property-level decorators on the same entity', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: 'upgrade-step-inherited',
|
||||
properties: ['inheritedA'],
|
||||
})
|
||||
class Example {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-local' })
|
||||
localColumn!: string;
|
||||
}
|
||||
|
||||
expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({
|
||||
localColumn: { upgradeCommandName: 'upgrade-step-local' },
|
||||
inheritedA: { upgradeCommandName: 'upgrade-step-inherited' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty map for classes with no decorated properties', () => {
|
||||
class Example {}
|
||||
|
||||
expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({});
|
||||
expect(getWasIntroducedInUpgradeClassMetadata(Example)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+53
-3
@@ -1,9 +1,19 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util';
|
||||
|
||||
export type WasIntroducedInUpgradeOptions = {
|
||||
upgradeCommandName: string;
|
||||
// Class-level escape hatch for columns inherited from a base entity (e.g.
|
||||
// SyncableEntity.universalIdentifier) that cannot carry a property decorator
|
||||
// in place
|
||||
properties?: readonly string[];
|
||||
};
|
||||
|
||||
export type WasIntroducedInUpgradeClassMetadata = {
|
||||
upgradeCommandName: string;
|
||||
};
|
||||
|
||||
export const WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY =
|
||||
@@ -14,24 +24,64 @@ export const WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY =
|
||||
|
||||
export type WasIntroducedInUpgradePropertyMap = Record<
|
||||
string,
|
||||
WasIntroducedInUpgradeOptions
|
||||
WasIntroducedInUpgradeClassMetadata
|
||||
>;
|
||||
|
||||
export const WasIntroducedInUpgrade =
|
||||
(options: WasIntroducedInUpgradeOptions) =>
|
||||
(target: object, propertyKey?: string | symbol): void => {
|
||||
const value: WasIntroducedInUpgradeClassMetadata = {
|
||||
upgradeCommandName: options.upgradeCommandName,
|
||||
};
|
||||
|
||||
if (!isDefined(propertyKey) && isDefined(options.properties)) {
|
||||
defineIntroducedPropertiesOnClass({
|
||||
entityClass: target as Function,
|
||||
propertyNames: options.properties,
|
||||
value,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
defineUpgradeMetadataOnClassOrProperty({
|
||||
classMetadataKey: WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY,
|
||||
propertyMetadataKey: WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
|
||||
value: options,
|
||||
value,
|
||||
target,
|
||||
propertyKey,
|
||||
});
|
||||
};
|
||||
|
||||
const defineIntroducedPropertiesOnClass = ({
|
||||
entityClass,
|
||||
propertyNames,
|
||||
value,
|
||||
}: {
|
||||
entityClass: Function;
|
||||
propertyNames: readonly string[];
|
||||
value: WasIntroducedInUpgradeClassMetadata;
|
||||
}): void => {
|
||||
const existing: WasIntroducedInUpgradePropertyMap =
|
||||
Reflect.getMetadata(
|
||||
WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
|
||||
entityClass,
|
||||
) ?? {};
|
||||
|
||||
const additions: WasIntroducedInUpgradePropertyMap = Object.fromEntries(
|
||||
propertyNames.map((propertyName) => [propertyName, value]),
|
||||
);
|
||||
|
||||
Reflect.defineMetadata(
|
||||
WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
|
||||
{ ...existing, ...additions },
|
||||
entityClass,
|
||||
);
|
||||
};
|
||||
|
||||
export const getWasIntroducedInUpgradeClassMetadata = (
|
||||
target: Function,
|
||||
): WasIntroducedInUpgradeOptions | undefined =>
|
||||
): WasIntroducedInUpgradeClassMetadata | undefined =>
|
||||
Reflect.getMetadata(WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY, target);
|
||||
|
||||
export const getWasIntroducedInUpgradePropertyMetadata = (
|
||||
|
||||
+52
@@ -169,6 +169,58 @@ describe('resolveEntityShapeAtUpgradeCursor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('class-level properties form (inherited columns)', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: PROP_INTRODUCE_CMD,
|
||||
properties: ['inheritedColumn', 'localColumn'],
|
||||
})
|
||||
class EntityWithInheritedColumns {}
|
||||
|
||||
const currentColumns = [
|
||||
{ propertyName: 'inheritedColumn', databaseName: 'inheritedColumn' },
|
||||
{ propertyName: 'localColumn', databaseName: 'localColumn' },
|
||||
{ propertyName: 'baselineColumn', databaseName: 'baselineColumn' },
|
||||
];
|
||||
|
||||
it('hides the listed inherited columns before introduction applied', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithInheritedColumns,
|
||||
currentTableName: 'entityWithInheritedColumns',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([]),
|
||||
});
|
||||
|
||||
expect(result.isAvailable).toBe(true);
|
||||
expect(result.hiddenPropertyNames).toEqual(
|
||||
new Set(['inheritedColumn', 'localColumn']),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the listed columns once introduction applied', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithInheritedColumns,
|
||||
currentTableName: 'entityWithInheritedColumns',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([PROP_INTRODUCE_CMD]),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('never hides undecorated baseline columns', () => {
|
||||
for (const applied of [[], [PROP_INTRODUCE_CMD]] as string[][]) {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithInheritedColumns,
|
||||
currentTableName: 'entityWithInheritedColumns',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate(applied),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames.has('baselineColumn')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('property-level @WasRemovedInUpgrade', () => {
|
||||
class EntityWithRemovedColumn {
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: PROP_REMOVE_CMD })
|
||||
|
||||
+86
@@ -115,6 +115,92 @@ describe('validateUpgradeAwareEntityDecorators', () => {
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
describe('column property name validation', () => {
|
||||
it('reports an introduced property name that does not exist on the entity', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: KNOWN_CMD,
|
||||
properties: ['universalIdentifier', 'typoColumn'],
|
||||
})
|
||||
class EntityWithTypo {}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [EntityWithTypo],
|
||||
stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
|
||||
columnPropertyNamesByEntityClass: new Map([
|
||||
[EntityWithTypo, new Set(['universalIdentifier', 'position'])],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(problems).toEqual([
|
||||
{
|
||||
kind: 'unknown-property-name',
|
||||
entityName: 'EntityWithTypo',
|
||||
decorator: '@WasIntroducedInUpgrade',
|
||||
propertyName: 'typoColumn',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a relation property name (resolution only acts on columns)', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: KNOWN_CMD,
|
||||
properties: ['application'],
|
||||
})
|
||||
class EntityTargetingRelation {}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [EntityTargetingRelation],
|
||||
stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
|
||||
// 'application' is a relation, not a column, so it is not in the set.
|
||||
columnPropertyNamesByEntityClass: new Map([
|
||||
[EntityTargetingRelation, new Set(['applicationId'])],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(problems).toEqual([
|
||||
{
|
||||
kind: 'unknown-property-name',
|
||||
entityName: 'EntityTargetingRelation',
|
||||
decorator: '@WasIntroducedInUpgrade',
|
||||
propertyName: 'application',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports no problem when all introduced property names are columns', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: KNOWN_CMD,
|
||||
properties: ['universalIdentifier', 'position'],
|
||||
})
|
||||
class ValidEntity {}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [ValidEntity],
|
||||
stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
|
||||
columnPropertyNamesByEntityClass: new Map([
|
||||
[ValidEntity, new Set(['universalIdentifier', 'position'])],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips property name validation when no column property names are provided', () => {
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName: KNOWN_CMD,
|
||||
properties: ['anythingGoes'],
|
||||
})
|
||||
class UnvalidatedEntity {}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [UnvalidatedEntity],
|
||||
stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
|
||||
});
|
||||
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a rename history that is out of order versus the sequence', () => {
|
||||
@WasRenamedInUpgrade([
|
||||
{ previousName: 'first', upgradeCommandName: KNOWN_LATER_RENAME_CMD },
|
||||
|
||||
+26
@@ -38,19 +38,29 @@ export type UpgradeAwareDecoratorReferenceProblem =
|
||||
scope: 'class' | `property:${string}`;
|
||||
introductionUpgradeCommandName: string;
|
||||
removalUpgradeCommandName: string;
|
||||
}
|
||||
| {
|
||||
kind: 'unknown-property-name';
|
||||
entityName: string;
|
||||
decorator: '@WasIntroducedInUpgrade';
|
||||
propertyName: string;
|
||||
};
|
||||
|
||||
export const validateUpgradeAwareEntityDecorators = ({
|
||||
entityClasses,
|
||||
stepNameToIndex,
|
||||
columnPropertyNamesByEntityClass,
|
||||
}: {
|
||||
entityClasses: Function[];
|
||||
stepNameToIndex: ReadonlyMap<string, number>;
|
||||
columnPropertyNamesByEntityClass?: ReadonlyMap<Function, ReadonlySet<string>>;
|
||||
}): UpgradeAwareDecoratorReferenceProblem[] => {
|
||||
const problems: UpgradeAwareDecoratorReferenceProblem[] = [];
|
||||
|
||||
for (const entityClass of entityClasses) {
|
||||
const entityName = entityClass.name;
|
||||
const columnPropertyNames =
|
||||
columnPropertyNamesByEntityClass?.get(entityClass);
|
||||
|
||||
const classIntroduced = getWasIntroducedInUpgradeClassMetadata(entityClass);
|
||||
|
||||
@@ -115,6 +125,18 @@ export const validateUpgradeAwareEntityDecorators = ({
|
||||
upgradeCommandName: options.upgradeCommandName,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(columnPropertyNames) &&
|
||||
!columnPropertyNames.has(propertyName)
|
||||
) {
|
||||
problems.push({
|
||||
kind: 'unknown-property-name',
|
||||
entityName,
|
||||
decorator: '@WasIntroducedInUpgrade',
|
||||
propertyName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const propRemovedMap = getWasRemovedInUpgradePropertyMetadata(entityClass);
|
||||
@@ -251,6 +273,10 @@ export const formatUpgradeAwareDecoratorReferenceProblems = (
|
||||
return ` - ${problem.entityName} @WasRenamedInUpgrade (${problem.scope}): "${problem.offendingUpgradeCommandName}" must come after "${problem.precedingUpgradeCommandName}" in the upgrade sequence`;
|
||||
}
|
||||
|
||||
if (problem.kind === 'unknown-property-name') {
|
||||
return ` - ${problem.entityName} ${problem.decorator} (property:${problem.propertyName}): "${problem.propertyName}" is not a known property on the entity (check the spelling in the properties array)`;
|
||||
}
|
||||
|
||||
return ` - ${problem.entityName} @WasRemovedInUpgrade (${problem.scope}): removal step "${problem.removalUpgradeCommandName}" must come after introduction step "${problem.introductionUpgradeCommandName}" in the upgrade sequence`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
+7
@@ -11,11 +11,18 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant';
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
||||
|
||||
@Entity({ name: 'searchFieldMetadata', schema: 'core' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME,
|
||||
properties: ['universalIdentifier', 'applicationId', 'position'],
|
||||
})
|
||||
@Unique('IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE', [
|
||||
'objectMetadataId',
|
||||
'fieldMetadataId',
|
||||
|
||||
+19
-4
@@ -4,8 +4,8 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { type ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
|
||||
import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata';
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
@@ -346,13 +346,28 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
|
||||
}
|
||||
|
||||
private validateDecoratorsAgainstSequence(): void {
|
||||
const entityClasses = this.coreDataSource.entityMetadatas
|
||||
.map((metadata) => metadata.target)
|
||||
.filter((target): target is Function => typeof target === 'function');
|
||||
const entityClasses: Function[] = [];
|
||||
const columnPropertyNamesByEntityClass = new Map<
|
||||
Function,
|
||||
ReadonlySet<string>
|
||||
>();
|
||||
|
||||
for (const metadata of this.coreDataSource.entityMetadatas) {
|
||||
if (typeof metadata.target !== 'function') {
|
||||
continue;
|
||||
}
|
||||
|
||||
entityClasses.push(metadata.target);
|
||||
columnPropertyNamesByEntityClass.set(
|
||||
metadata.target,
|
||||
new Set(metadata.columns.map((column) => column.propertyName)),
|
||||
);
|
||||
}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses,
|
||||
stepNameToIndex: this.stepNameToIndex,
|
||||
columnPropertyNamesByEntityClass,
|
||||
});
|
||||
|
||||
if (problems.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user