Add @WasRemovedInUpgrade decorator (#20729)
## Summary
Adds the symmetric counterpart to `@WasIntroducedInUpgrade` for the
upgrade-aware ORM. Today the framework can describe "this column will
exist once upgrade X applies" but not "this
column will stop existing once upgrade X applies". Plain field deletion
only works when nothing writes to the table during the mid-state window
between the binary booting and the drop
migration completing for a given workspace — fine for sparse tables
(`DropWorkspaceVersionColumn`, `DropPostgresCredentialsTable`), risky
for hot-write tables.
This PR ships the primitive on its own so the upcoming
`rolePermissionFlag.flag` drop has the framework support it needs. No
in-tree consumer yet — coverage is via unit tests against
synthetic entities.
### What's in it
- **New `@WasRemovedInUpgrade({ upgradeCommandName })` decorator**
(class- or property-scope) — mirrors `@WasIntroducedInUpgrade`, uses the
shared
`defineUpgradeMetadataOnClassOrProperty` helper, exposes class +
property getters.
- **`resolveEntityShapeAtUpgradeCursor`** now folds applied-removals
into the existing `hiddenPropertyNames` set. Intro-pending and
removal-applied share one hide bucket — both ask
TypeORM for the same thing.
- **`UpgradeAwareEntityMetadataAdapter`** now disables `isSelect`,
`isInsert`, **and** `isUpdate` for any hidden column, restoring
canonical values when the column comes back.
Previously only `isSelect` was flipped, which left an
INSERT-into-nonexistent-column hole the intro path was tacitly relying
on application code to avoid; this PR closes that hole for
both directions.
- **`validateUpgradeAwareEntityDecorators`** validates
`@WasRemovedInUpgrade` `upgradeCommandName` references, and surfaces a
new `removal-before-introduction` problem when a property
has both decorators with the removal step preceding the introduction
step.
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
WasRemovedInUpgrade,
|
||||
getWasRemovedInUpgradeClassMetadata,
|
||||
getWasRemovedInUpgradePropertyMetadata,
|
||||
} from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
|
||||
|
||||
describe('WasRemovedInUpgrade', () => {
|
||||
it('records class-level metadata', () => {
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: 'upgrade-step-class' })
|
||||
class Example {}
|
||||
|
||||
expect(getWasRemovedInUpgradeClassMetadata(Example)).toEqual({
|
||||
upgradeCommandName: 'upgrade-step-class',
|
||||
});
|
||||
expect(getWasRemovedInUpgradePropertyMetadata(Example)).toEqual({});
|
||||
});
|
||||
|
||||
it('records property-level metadata keyed by property name', () => {
|
||||
class Example {
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: 'upgrade-step-foo' })
|
||||
foo!: string;
|
||||
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: 'upgrade-step-bar' })
|
||||
bar!: string;
|
||||
|
||||
untouched!: string;
|
||||
}
|
||||
|
||||
expect(getWasRemovedInUpgradePropertyMetadata(Example)).toEqual({
|
||||
foo: { upgradeCommandName: 'upgrade-step-foo' },
|
||||
bar: { upgradeCommandName: 'upgrade-step-bar' },
|
||||
});
|
||||
expect(getWasRemovedInUpgradeClassMetadata(Example)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns an empty map for classes with no decorated properties', () => {
|
||||
class Example {}
|
||||
|
||||
expect(getWasRemovedInUpgradePropertyMetadata(Example)).toEqual({});
|
||||
expect(getWasRemovedInUpgradeClassMetadata(Example)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util';
|
||||
|
||||
export type WasRemovedInUpgradeOptions = {
|
||||
upgradeCommandName: string;
|
||||
};
|
||||
|
||||
export const WAS_REMOVED_IN_UPGRADE_CLASS_METADATA_KEY =
|
||||
'WAS_REMOVED_IN_UPGRADE_CLASS';
|
||||
|
||||
export const WAS_REMOVED_IN_UPGRADE_PROPERTIES_METADATA_KEY =
|
||||
'WAS_REMOVED_IN_UPGRADE_PROPERTIES';
|
||||
|
||||
export type WasRemovedInUpgradePropertyMap = Record<
|
||||
string,
|
||||
WasRemovedInUpgradeOptions
|
||||
>;
|
||||
|
||||
export const WasRemovedInUpgrade =
|
||||
(options: WasRemovedInUpgradeOptions) =>
|
||||
(target: object, propertyKey?: string | symbol): void => {
|
||||
defineUpgradeMetadataOnClassOrProperty({
|
||||
classMetadataKey: WAS_REMOVED_IN_UPGRADE_CLASS_METADATA_KEY,
|
||||
propertyMetadataKey: WAS_REMOVED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
|
||||
value: options,
|
||||
target,
|
||||
propertyKey,
|
||||
});
|
||||
};
|
||||
|
||||
export const getWasRemovedInUpgradeClassMetadata = (
|
||||
target: Function,
|
||||
): WasRemovedInUpgradeOptions | undefined =>
|
||||
Reflect.getMetadata(WAS_REMOVED_IN_UPGRADE_CLASS_METADATA_KEY, target);
|
||||
|
||||
export const getWasRemovedInUpgradePropertyMetadata = (
|
||||
target: Function,
|
||||
): WasRemovedInUpgradePropertyMap =>
|
||||
Reflect.getMetadata(WAS_REMOVED_IN_UPGRADE_PROPERTIES_METADATA_KEY, target) ??
|
||||
{};
|
||||
+96
@@ -1,4 +1,5 @@
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
|
||||
import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator';
|
||||
import { resolveEntityShapeAtUpgradeCursor } from 'src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util';
|
||||
|
||||
@@ -6,6 +7,7 @@ const INTRODUCE_CMD = '2.7.0_IntroduceCommand_1800000000000';
|
||||
const RENAME_CMD = '2.6.0_RenameCommand_1700000000000';
|
||||
const PROP_INTRODUCE_CMD = '2.7.0_AddColumnCommand_1800000000001';
|
||||
const PROP_RENAME_CMD = '2.6.0_RenameColumnCommand_1700000000001';
|
||||
const PROP_REMOVE_CMD = '2.7.0_DropColumnCommand_1800000000002';
|
||||
|
||||
const buildPredicate = (applied: string[]) => {
|
||||
const set = new Set(applied);
|
||||
@@ -167,6 +169,100 @@ describe('resolveEntityShapeAtUpgradeCursor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('property-level @WasRemovedInUpgrade', () => {
|
||||
class EntityWithRemovedColumn {
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: PROP_REMOVE_CMD })
|
||||
removedColumn!: string;
|
||||
|
||||
untouchedColumn!: string;
|
||||
}
|
||||
|
||||
const currentColumns = [
|
||||
{ propertyName: 'removedColumn', databaseName: 'removedColumn' },
|
||||
{ propertyName: 'untouchedColumn', databaseName: 'untouchedColumn' },
|
||||
];
|
||||
|
||||
it('should not hide the column before its removal step applied', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithRemovedColumn,
|
||||
currentTableName: 'entityWithRemovedColumn',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([]),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should hide the column once its removal step applied', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithRemovedColumn,
|
||||
currentTableName: 'entityWithRemovedColumn',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([PROP_REMOVE_CMD]),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames).toEqual(new Set(['removedColumn']));
|
||||
});
|
||||
|
||||
it('should leave undecorated siblings untouched at every cursor', () => {
|
||||
for (const applied of [[], [PROP_REMOVE_CMD]] as string[][]) {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithRemovedColumn,
|
||||
currentTableName: 'entityWithRemovedColumn',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate(applied),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames.has('untouchedColumn')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('property-level intro + remove combined', () => {
|
||||
class EntityWithIntroAndRemove {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: PROP_INTRODUCE_CMD })
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: PROP_REMOVE_CMD })
|
||||
transientColumn!: string;
|
||||
}
|
||||
|
||||
const currentColumns = [
|
||||
{ propertyName: 'transientColumn', databaseName: 'transientColumn' },
|
||||
];
|
||||
|
||||
it('hides the column before intro applied', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithIntroAndRemove,
|
||||
currentTableName: 'entityWithIntroAndRemove',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([]),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames).toEqual(new Set(['transientColumn']));
|
||||
});
|
||||
|
||||
it('exposes the column between intro and removal', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithIntroAndRemove,
|
||||
currentTableName: 'entityWithIntroAndRemove',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([PROP_INTRODUCE_CMD]),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('hides the column once removal applied', () => {
|
||||
const result = resolveEntityShapeAtUpgradeCursor({
|
||||
entityClass: EntityWithIntroAndRemove,
|
||||
currentTableName: 'entityWithIntroAndRemove',
|
||||
currentColumns,
|
||||
isStepApplied: buildPredicate([PROP_INTRODUCE_CMD, PROP_REMOVE_CMD]),
|
||||
});
|
||||
|
||||
expect(result.hiddenPropertyNames).toEqual(new Set(['transientColumn']));
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat an entity with no decorators as available and unchanged', () => {
|
||||
class Plain {}
|
||||
|
||||
|
||||
+67
@@ -1,4 +1,5 @@
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
|
||||
import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator';
|
||||
import { validateUpgradeAwareEntityDecorators } from 'src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util';
|
||||
|
||||
@@ -48,6 +49,72 @@ describe('validateUpgradeAwareEntityDecorators', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report an unknown step name on @WasRemovedInUpgrade', () => {
|
||||
class BrokenRemoved {
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: UNKNOWN_CMD })
|
||||
doomedColumn!: string;
|
||||
}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [BrokenRemoved],
|
||||
stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
|
||||
});
|
||||
|
||||
expect(problems).toEqual([
|
||||
{
|
||||
kind: 'unknown-step-name',
|
||||
entityName: 'BrokenRemoved',
|
||||
decorator: '@WasRemovedInUpgrade',
|
||||
scope: 'property:doomedColumn',
|
||||
upgradeCommandName: UNKNOWN_CMD,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report when a property is removed before it is introduced', () => {
|
||||
class BackwardsLifecycle {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: KNOWN_LATER_RENAME_CMD })
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: KNOWN_CMD })
|
||||
transientColumn!: string;
|
||||
}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [BackwardsLifecycle],
|
||||
stepNameToIndex: buildStepNameToIndex([
|
||||
KNOWN_CMD,
|
||||
KNOWN_LATER_RENAME_CMD,
|
||||
]),
|
||||
});
|
||||
|
||||
expect(problems).toEqual([
|
||||
{
|
||||
kind: 'removal-before-introduction',
|
||||
entityName: 'BackwardsLifecycle',
|
||||
scope: 'property:transientColumn',
|
||||
introductionUpgradeCommandName: KNOWN_LATER_RENAME_CMD,
|
||||
removalUpgradeCommandName: KNOWN_CMD,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts a property that is introduced before being removed', () => {
|
||||
class ProperLifecycle {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: KNOWN_CMD })
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: KNOWN_LATER_RENAME_CMD })
|
||||
transientColumn!: string;
|
||||
}
|
||||
|
||||
const problems = validateUpgradeAwareEntityDecorators({
|
||||
entityClasses: [ProperLifecycle],
|
||||
stepNameToIndex: buildStepNameToIndex([
|
||||
KNOWN_CMD,
|
||||
KNOWN_LATER_RENAME_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 },
|
||||
|
||||
+10
@@ -4,6 +4,7 @@ import {
|
||||
getWasIntroducedInUpgradeClassMetadata,
|
||||
getWasIntroducedInUpgradePropertyMetadata,
|
||||
} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { getWasRemovedInUpgradePropertyMetadata } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
|
||||
import {
|
||||
getWasRenamedInUpgradeClassMetadata,
|
||||
getWasRenamedInUpgradePropertyMetadata,
|
||||
@@ -43,6 +44,8 @@ export const resolveEntityShapeAtUpgradeCursor = ({
|
||||
|
||||
const propertyIntroductionMap =
|
||||
getWasIntroducedInUpgradePropertyMetadata(entityClass);
|
||||
const propertyRemovalMap =
|
||||
getWasRemovedInUpgradePropertyMetadata(entityClass);
|
||||
const propertyRenameMap = getWasRenamedInUpgradePropertyMetadata(entityClass);
|
||||
|
||||
const hiddenPropertyNames = new Set<string>();
|
||||
@@ -59,6 +62,13 @@ export const resolveEntityShapeAtUpgradeCursor = ({
|
||||
continue;
|
||||
}
|
||||
|
||||
const removed = propertyRemovalMap[column.propertyName];
|
||||
|
||||
if (isDefined(removed) && isStepApplied(removed.upgradeCommandName)) {
|
||||
hiddenPropertyNames.add(column.propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const renameHistory = propertyRenameMap[column.propertyName] ?? [];
|
||||
|
||||
if (renameHistory.length === 0) {
|
||||
|
||||
+104
-2
@@ -4,6 +4,10 @@ import {
|
||||
getWasIntroducedInUpgradeClassMetadata,
|
||||
getWasIntroducedInUpgradePropertyMetadata,
|
||||
} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import {
|
||||
getWasRemovedInUpgradeClassMetadata,
|
||||
getWasRemovedInUpgradePropertyMetadata,
|
||||
} from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
|
||||
import {
|
||||
getWasRenamedInUpgradeClassMetadata,
|
||||
getWasRenamedInUpgradePropertyMetadata,
|
||||
@@ -14,7 +18,10 @@ export type UpgradeAwareDecoratorReferenceProblem =
|
||||
| {
|
||||
kind: 'unknown-step-name';
|
||||
entityName: string;
|
||||
decorator: '@WasIntroducedInUpgrade' | '@WasRenamedInUpgrade';
|
||||
decorator:
|
||||
| '@WasIntroducedInUpgrade'
|
||||
| '@WasRemovedInUpgrade'
|
||||
| '@WasRenamedInUpgrade';
|
||||
scope: 'class' | `property:${string}`;
|
||||
upgradeCommandName: string;
|
||||
}
|
||||
@@ -24,6 +31,13 @@ export type UpgradeAwareDecoratorReferenceProblem =
|
||||
scope: 'class' | `property:${string}`;
|
||||
offendingUpgradeCommandName: string;
|
||||
precedingUpgradeCommandName: string;
|
||||
}
|
||||
| {
|
||||
kind: 'removal-before-introduction';
|
||||
entityName: string;
|
||||
scope: 'class' | `property:${string}`;
|
||||
introductionUpgradeCommandName: string;
|
||||
removalUpgradeCommandName: string;
|
||||
};
|
||||
|
||||
export const validateUpgradeAwareEntityDecorators = ({
|
||||
@@ -53,6 +67,30 @@ export const validateUpgradeAwareEntityDecorators = ({
|
||||
});
|
||||
}
|
||||
|
||||
const classRemoved = getWasRemovedInUpgradeClassMetadata(entityClass);
|
||||
|
||||
if (
|
||||
isDefined(classRemoved) &&
|
||||
!stepNameToIndex.has(classRemoved.upgradeCommandName)
|
||||
) {
|
||||
problems.push({
|
||||
kind: 'unknown-step-name',
|
||||
entityName,
|
||||
decorator: '@WasRemovedInUpgrade',
|
||||
scope: 'class',
|
||||
upgradeCommandName: classRemoved.upgradeCommandName,
|
||||
});
|
||||
}
|
||||
|
||||
checkRemovalAfterIntroduction({
|
||||
entityName,
|
||||
scope: 'class',
|
||||
introduced: classIntroduced,
|
||||
removed: classRemoved,
|
||||
stepNameToIndex,
|
||||
problems,
|
||||
});
|
||||
|
||||
const classRenameHistory =
|
||||
getWasRenamedInUpgradeClassMetadata(entityClass) ?? [];
|
||||
|
||||
@@ -79,6 +117,29 @@ export const validateUpgradeAwareEntityDecorators = ({
|
||||
}
|
||||
}
|
||||
|
||||
const propRemovedMap = getWasRemovedInUpgradePropertyMetadata(entityClass);
|
||||
|
||||
for (const [propertyName, options] of Object.entries(propRemovedMap)) {
|
||||
if (!stepNameToIndex.has(options.upgradeCommandName)) {
|
||||
problems.push({
|
||||
kind: 'unknown-step-name',
|
||||
entityName,
|
||||
decorator: '@WasRemovedInUpgrade',
|
||||
scope: `property:${propertyName}`,
|
||||
upgradeCommandName: options.upgradeCommandName,
|
||||
});
|
||||
}
|
||||
|
||||
checkRemovalAfterIntroduction({
|
||||
entityName,
|
||||
scope: `property:${propertyName}`,
|
||||
introduced: propIntroducedMap[propertyName],
|
||||
removed: options,
|
||||
stepNameToIndex,
|
||||
problems,
|
||||
});
|
||||
}
|
||||
|
||||
const propRenameMap = getWasRenamedInUpgradePropertyMetadata(entityClass);
|
||||
|
||||
for (const [propertyName, history] of Object.entries(propRenameMap)) {
|
||||
@@ -140,6 +201,43 @@ const checkHistoryForReferenceAndOrder = ({
|
||||
}
|
||||
};
|
||||
|
||||
const checkRemovalAfterIntroduction = ({
|
||||
entityName,
|
||||
scope,
|
||||
introduced,
|
||||
removed,
|
||||
stepNameToIndex,
|
||||
problems,
|
||||
}: {
|
||||
entityName: string;
|
||||
scope: 'class' | `property:${string}`;
|
||||
introduced: { upgradeCommandName: string } | undefined;
|
||||
removed: { upgradeCommandName: string } | undefined;
|
||||
stepNameToIndex: ReadonlyMap<string, number>;
|
||||
problems: UpgradeAwareDecoratorReferenceProblem[];
|
||||
}): void => {
|
||||
if (!isDefined(introduced) || !isDefined(removed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const introducedIndex = stepNameToIndex.get(introduced.upgradeCommandName);
|
||||
const removedIndex = stepNameToIndex.get(removed.upgradeCommandName);
|
||||
|
||||
if (!isDefined(introducedIndex) || !isDefined(removedIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (removedIndex <= introducedIndex) {
|
||||
problems.push({
|
||||
kind: 'removal-before-introduction',
|
||||
entityName,
|
||||
scope,
|
||||
introductionUpgradeCommandName: introduced.upgradeCommandName,
|
||||
removalUpgradeCommandName: removed.upgradeCommandName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const formatUpgradeAwareDecoratorReferenceProblems = (
|
||||
problems: UpgradeAwareDecoratorReferenceProblem[],
|
||||
): string =>
|
||||
@@ -149,6 +247,10 @@ export const formatUpgradeAwareDecoratorReferenceProblems = (
|
||||
return ` - ${problem.entityName} ${problem.decorator} (${problem.scope}): unknown upgradeCommandName "${problem.upgradeCommandName}"`;
|
||||
}
|
||||
|
||||
return ` - ${problem.entityName} @WasRenamedInUpgrade (${problem.scope}): "${problem.offendingUpgradeCommandName}" must come after "${problem.precedingUpgradeCommandName}" in the upgrade sequence`;
|
||||
if (problem.kind === 'rename-history-out-of-order') {
|
||||
return ` - ${problem.entityName} @WasRenamedInUpgrade (${problem.scope}): "${problem.offendingUpgradeCommandName}" must come after "${problem.precedingUpgradeCommandName}" in the upgrade sequence`;
|
||||
}
|
||||
|
||||
return ` - ${problem.entityName} @WasRemovedInUpgrade (${problem.scope}): removal step "${problem.removalUpgradeCommandName}" must come after introduction step "${problem.introductionUpgradeCommandName}" in the upgrade sequence`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
+88
@@ -4,20 +4,44 @@ import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type DataSource } from 'typeorm';
|
||||
import { type ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
|
||||
import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata';
|
||||
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
|
||||
import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator';
|
||||
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';
|
||||
import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
|
||||
|
||||
const RENAME_STEP = '2.6.0_Rename_1700000000000';
|
||||
const INTRODUCE_STEP = '2.7.0_AddColumn_1800000000000';
|
||||
const REMOVE_STEP = '2.7.0_DropColumn_1800000000001';
|
||||
|
||||
@WasRenamedInUpgrade([
|
||||
{ previousName: 'oldEntity', upgradeCommandName: RENAME_STEP },
|
||||
])
|
||||
class RenamedEntity {}
|
||||
|
||||
class EntityWithHideableColumns {
|
||||
@WasIntroducedInUpgrade({ upgradeCommandName: INTRODUCE_STEP })
|
||||
introducedColumn!: string;
|
||||
|
||||
@WasRemovedInUpgrade({ upgradeCommandName: REMOVE_STEP })
|
||||
removedColumn!: string;
|
||||
|
||||
visibleColumn!: string;
|
||||
}
|
||||
|
||||
const buildColumn = (propertyName: string): ColumnMetadata =>
|
||||
({
|
||||
propertyName,
|
||||
databaseName: propertyName,
|
||||
isSelect: true,
|
||||
isInsert: true,
|
||||
isUpdate: true,
|
||||
}) as unknown as ColumnMetadata;
|
||||
|
||||
describe('UpgradeAwareEntityMetadataAdapter', () => {
|
||||
it('rewrites tableName / tablePath / givenTableName when the rename step is not yet applied', async () => {
|
||||
const metadata = {
|
||||
@@ -64,4 +88,68 @@ describe('UpgradeAwareEntityMetadataAdapter', () => {
|
||||
expect(metadata.tablePath).toBe('core.oldEntity');
|
||||
expect(metadata.givenTableName).toBe('oldEntity');
|
||||
});
|
||||
|
||||
it('disables isSelect, isInsert and isUpdate for hidden columns (intro pending + removal applied) while leaving the visible sibling untouched', async () => {
|
||||
const introducedColumn = buildColumn('introducedColumn');
|
||||
const removedColumn = buildColumn('removedColumn');
|
||||
const visibleColumn = buildColumn('visibleColumn');
|
||||
|
||||
const metadata = {
|
||||
target: EntityWithHideableColumns,
|
||||
tableName: 'entityWithHideableColumns',
|
||||
tablePath: 'core.entityWithHideableColumns',
|
||||
givenTableName: 'entityWithHideableColumns',
|
||||
schema: 'core',
|
||||
columns: [introducedColumn, removedColumn, visibleColumn],
|
||||
} as unknown as EntityMetadata;
|
||||
|
||||
const dataSource = {
|
||||
entityMetadatas: [metadata],
|
||||
} as unknown as DataSource;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
UpgradeAwareEntityMetadataAdapter,
|
||||
{
|
||||
provide: UpgradeMigrationService,
|
||||
useValue: {
|
||||
getLastAttemptedInstanceCommand: jest.fn().mockResolvedValue({
|
||||
name: REMOVE_STEP,
|
||||
status: 'completed',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UpgradeSequenceReaderService,
|
||||
useValue: {
|
||||
getUpgradeSequence: jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: REMOVE_STEP },
|
||||
{ name: INTRODUCE_STEP },
|
||||
]),
|
||||
},
|
||||
},
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const adapter = moduleRef.get(UpgradeAwareEntityMetadataAdapter);
|
||||
|
||||
await adapter.onModuleInit();
|
||||
|
||||
await adapter.refresh();
|
||||
|
||||
expect(introducedColumn.isSelect).toBe(false);
|
||||
expect(introducedColumn.isInsert).toBe(false);
|
||||
expect(introducedColumn.isUpdate).toBe(false);
|
||||
|
||||
expect(removedColumn.isSelect).toBe(false);
|
||||
expect(removedColumn.isInsert).toBe(false);
|
||||
expect(removedColumn.isUpdate).toBe(false);
|
||||
|
||||
expect(visibleColumn.isSelect).toBe(true);
|
||||
expect(visibleColumn.isInsert).toBe(true);
|
||||
expect(visibleColumn.isUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-3
@@ -25,6 +25,8 @@ type EntityMetadataSnapshot = {
|
||||
givenTableName: string | undefined;
|
||||
columnDatabaseNamesByPropertyName: ReadonlyMap<string, string>;
|
||||
columnSelectByPropertyName: ReadonlyMap<string, boolean>;
|
||||
columnInsertByPropertyName: ReadonlyMap<string, boolean>;
|
||||
columnUpdateByPropertyName: ReadonlyMap<string, boolean>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -116,6 +118,8 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
|
||||
for (const metadata of this.coreDataSource.entityMetadatas) {
|
||||
const columnDatabaseNamesByPropertyName = new Map<string, string>();
|
||||
const columnSelectByPropertyName = new Map<string, boolean>();
|
||||
const columnInsertByPropertyName = new Map<string, boolean>();
|
||||
const columnUpdateByPropertyName = new Map<string, boolean>();
|
||||
|
||||
for (const column of metadata.columns) {
|
||||
columnDatabaseNamesByPropertyName.set(
|
||||
@@ -123,6 +127,8 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
|
||||
column.databaseName,
|
||||
);
|
||||
columnSelectByPropertyName.set(column.propertyName, column.isSelect);
|
||||
columnInsertByPropertyName.set(column.propertyName, column.isInsert);
|
||||
columnUpdateByPropertyName.set(column.propertyName, column.isUpdate);
|
||||
}
|
||||
|
||||
this.snapshotByMetadata.set(metadata, {
|
||||
@@ -131,6 +137,8 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
|
||||
givenTableName: metadata.givenTableName,
|
||||
columnDatabaseNamesByPropertyName,
|
||||
columnSelectByPropertyName,
|
||||
columnInsertByPropertyName,
|
||||
columnUpdateByPropertyName,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -297,12 +305,18 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
|
||||
|
||||
column.databaseName = remappedName ?? canonicalName;
|
||||
|
||||
const isHidden = resolved.hiddenPropertyNames.has(column.propertyName);
|
||||
|
||||
const canonicalIsSelect =
|
||||
snapshot.columnSelectByPropertyName.get(column.propertyName) ?? true;
|
||||
const canonicalIsInsert =
|
||||
snapshot.columnInsertByPropertyName.get(column.propertyName) ?? true;
|
||||
const canonicalIsUpdate =
|
||||
snapshot.columnUpdateByPropertyName.get(column.propertyName) ?? true;
|
||||
|
||||
column.isSelect = resolved.hiddenPropertyNames.has(column.propertyName)
|
||||
? false
|
||||
: canonicalIsSelect;
|
||||
column.isSelect = isHidden ? false : canonicalIsSelect;
|
||||
column.isInsert = isHidden ? false : canonicalIsInsert;
|
||||
column.isUpdate = isHidden ? false : canonicalIsUpdate;
|
||||
}
|
||||
|
||||
private computeTablePath({
|
||||
|
||||
Reference in New Issue
Block a user