fix(server): normalize legacy index names (command) (#22053)
## TL;DR Adds a workspace upgrade command that normalizes index names to the current v2 deterministic naming convention (IDX_ prefix). This will close https://github.com/twentyhq/twenty/issues/21383 : uniqueness constraint cannot be disabled (for users who set it before v2 determinist naming is enforced). ### Background The deterministic index name embeds the table name, columns, uniqueness and where clause. The naming convention changed on **2025-09-23** (#14567 - added the `IDX_`/`IDX_UNIQUE_` prefix and folded the table name into the hash). Index rows created before that kept their old name in `core."indexMetadata"`, and nothing rewrites it (only targeted phone/relation rebuilds got new names). Code paths that locate an index by recomputing its expected name then miss these legacy-named rows. The most visible symptom is #21383: toggling a field's `isUnique` from `true` → `false` recomputes the expected unique-index name, fails to find the legacy-named index, and silently no-ops — so uniqueness can't be disabled. ### What the command does Per workspace, for each index: - recomputes the expected name with the same generator the app uses (`generateFlatIndexMetadataWithNameOrThrow`); - if the stored name differs → **rename** it (metadata `UPDATE` + a new metadata-only `ALTER INDEX … RENAME`, which preserves the unique constraint with no rebuild/lock); - if a correctly-named twin already exists (the legacy + freshly-generated duplicate case) → **drop the redundant** one (physical + metadata, field rows cascade) and keep the canonical; - invalidates the metadata cache so the running app + `isUnique` derivation reflect the new names. Honors `--dry-run`, wraps writes in a transaction (rolls back on error), and skips (with a warning) any single index whose name can't be recomputed so it can't abort the whole workspace. ### Notable changes - New `renameIndex` on `WorkspaceSchemaIndexManagerService` (`ALTER INDEX IF EXISTS … RENAME`). - Planning logic extracted into a pure, unit-tested util (`planIndexNameNormalization`).
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceIteratorModule,
|
||||
WorkspaceSchemaManagerModule,
|
||||
],
|
||||
providers: [NormalizeLegacyIndexNamesCommand],
|
||||
})
|
||||
export class V2_18_UpgradeVersionCommandModule {}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
type FlatIndexNameStatus,
|
||||
planIndexNameNormalization,
|
||||
} from 'src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-universal-identifier-in-universal-flat-entity-maps-or-throw.util';
|
||||
import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util';
|
||||
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
import {
|
||||
deleteIndexMetadata,
|
||||
dropIndexFromWorkspaceSchema,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.18.0', 1799200000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-18:normalize-legacy-index-names',
|
||||
description:
|
||||
'Rename indexes whose stored name predates the v2 deterministic naming convention to their recomputed name, and drop redundant duplicates.',
|
||||
})
|
||||
export class NormalizeLegacyIndexNamesCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceSchemaManagerService: WorkspaceSchemaManagerService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
dataSource,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (!isDefined(dataSource)) {
|
||||
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatIndexMaps, flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatIndexMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
|
||||
const indexStatuses: FlatIndexNameStatus[] = [];
|
||||
|
||||
for (const flatObjectMetadata of Object.values(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
)) {
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectIndexes =
|
||||
findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow(
|
||||
{
|
||||
flatEntityMaps: flatIndexMaps,
|
||||
universalIdentifiers:
|
||||
flatObjectMetadata.indexMetadataUniversalIdentifiers,
|
||||
},
|
||||
);
|
||||
|
||||
if (objectIndexes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectFlatFieldMetadatas =
|
||||
findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow(
|
||||
{
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
universalIdentifiers: flatObjectMetadata.fieldUniversalIdentifiers,
|
||||
},
|
||||
);
|
||||
|
||||
for (const flatIndex of objectIndexes) {
|
||||
try {
|
||||
const expectedName = generateFlatIndexMetadataWithNameOrThrow({
|
||||
flatObjectMetadata,
|
||||
objectFlatFieldMetadatas,
|
||||
flatIndex,
|
||||
}).name;
|
||||
|
||||
indexStatuses.push({
|
||||
indexMetadataId: flatIndex.id,
|
||||
objectMetadataId: flatIndex.objectMetadataId,
|
||||
currentName: flatIndex.name,
|
||||
expectedName,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Could not recompute expected name for index ${flatIndex.name} (${flatIndex.id}) in workspace ${workspaceId}, skipping: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const operations = planIndexNameNormalization(indexStatuses);
|
||||
|
||||
if (operations.length === 0) {
|
||||
this.logger.log(
|
||||
`No legacy index names to normalize for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type === 'rename') {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would rename index ${operation.fromName} -> ${operation.toName} (workspace ${workspaceId})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would drop redundant duplicate index ${operation.redundantName} (kept ${operation.keptName}) (workspace ${workspaceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = dataSource.createQueryRunner();
|
||||
let isQueryRunnerConnected = false;
|
||||
let isTransactionStarted = false;
|
||||
|
||||
try {
|
||||
await queryRunner.connect();
|
||||
isQueryRunnerConnected = true;
|
||||
|
||||
await queryRunner.startTransaction();
|
||||
isTransactionStarted = true;
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === 'rename') {
|
||||
await this.workspaceSchemaManagerService.indexManager.renameIndexWithoutRebuild(
|
||||
{
|
||||
queryRunner,
|
||||
schemaName,
|
||||
fromIndexName: operation.fromName,
|
||||
toIndexName: operation.toName,
|
||||
},
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."indexMetadata"
|
||||
SET "name" = $1
|
||||
WHERE "id" = $2
|
||||
AND "workspaceId" = $3`,
|
||||
[operation.toName, operation.indexMetadataId, workspaceId],
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Renamed index ${operation.fromName} -> ${operation.toName} (workspace ${workspaceId})`,
|
||||
);
|
||||
} else {
|
||||
await dropIndexFromWorkspaceSchema({
|
||||
indexName: operation.redundantName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
});
|
||||
|
||||
await deleteIndexMetadata({
|
||||
entityId: operation.indexMetadataId,
|
||||
queryRunner,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Dropped redundant duplicate index ${operation.redundantName} (kept ${operation.keptName}) (workspace ${workspaceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
if (isTransactionStarted) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (isQueryRunnerConnected) {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'flatIndexMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
import { type DataSource, type QueryRunner } from 'typeorm';
|
||||
|
||||
import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command';
|
||||
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { type WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util';
|
||||
import {
|
||||
deleteIndexMetadata,
|
||||
dropIndexFromWorkspaceSchema,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util',
|
||||
);
|
||||
jest.mock(
|
||||
'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils',
|
||||
);
|
||||
|
||||
const generateFlatIndexMetadataWithNameOrThrowMock =
|
||||
generateFlatIndexMetadataWithNameOrThrow as jest.Mock;
|
||||
const deleteIndexMetadataMock = deleteIndexMetadata as jest.Mock;
|
||||
const dropIndexFromWorkspaceSchemaMock = dropIndexFromWorkspaceSchema as jest.Mock;
|
||||
|
||||
const WORKSPACE_ID = '20202020-0000-0000-0000-000000000001';
|
||||
|
||||
type IndexFixture = {
|
||||
universalIdentifier: string;
|
||||
id: string;
|
||||
name: string;
|
||||
expectedName: string;
|
||||
};
|
||||
|
||||
const buildFlatEntityMaps = (indexes: IndexFixture[]) => {
|
||||
const flatIndexMaps = {
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
indexes.map((index) => [
|
||||
index.universalIdentifier,
|
||||
{
|
||||
universalIdentifier: index.universalIdentifier,
|
||||
id: index.id,
|
||||
name: index.name,
|
||||
objectMetadataId: 'object-1',
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
const flatObjectMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
'object-uid': {
|
||||
universalIdentifier: 'object-uid',
|
||||
nameSingular: 'myObject',
|
||||
indexMetadataUniversalIdentifiers: indexes.map(
|
||||
(index) => index.universalIdentifier,
|
||||
),
|
||||
fieldUniversalIdentifiers: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const flatFieldMetadataMaps = { byUniversalIdentifier: {} };
|
||||
|
||||
// Drive expected names per index via the mocked generator.
|
||||
generateFlatIndexMetadataWithNameOrThrowMock.mockImplementation(
|
||||
({ flatIndex }: { flatIndex: { id: string } }) => {
|
||||
const match = indexes.find((index) => index.id === flatIndex.id);
|
||||
|
||||
return { name: match?.expectedName ?? flatIndex.id };
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { flatIndexMaps, flatObjectMetadataMaps, flatFieldMetadataMaps } as any;
|
||||
};
|
||||
|
||||
const buildQueryRunner = (): {
|
||||
queryRunner: QueryRunner;
|
||||
query: jest.Mock;
|
||||
commit: jest.Mock;
|
||||
rollback: jest.Mock;
|
||||
} => {
|
||||
const query = jest.fn();
|
||||
const commit = jest.fn();
|
||||
const rollback = jest.fn();
|
||||
|
||||
const queryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: commit,
|
||||
rollbackTransaction: rollback,
|
||||
release: jest.fn(),
|
||||
query,
|
||||
} as unknown as QueryRunner;
|
||||
|
||||
return { queryRunner, query, commit, rollback };
|
||||
};
|
||||
|
||||
describe('NormalizeLegacyIndexNamesCommand', () => {
|
||||
let command: NormalizeLegacyIndexNamesCommand;
|
||||
let renameIndexMock: jest.Mock;
|
||||
let dropIndexMock: jest.Mock;
|
||||
let getOrRecomputeMock: jest.Mock;
|
||||
let invalidateAndRecomputeMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
renameIndexMock = jest.fn();
|
||||
dropIndexMock = jest.fn();
|
||||
getOrRecomputeMock = jest.fn();
|
||||
invalidateAndRecomputeMock = jest.fn();
|
||||
|
||||
const workspaceIteratorService = {} as WorkspaceIteratorService;
|
||||
const workspaceCacheService = {
|
||||
getOrRecompute: getOrRecomputeMock,
|
||||
invalidateAndRecompute: invalidateAndRecomputeMock,
|
||||
} as unknown as WorkspaceCacheService;
|
||||
const workspaceSchemaManagerService = {
|
||||
indexManager: {
|
||||
renameIndexWithoutRebuild: renameIndexMock,
|
||||
dropIndex: dropIndexMock,
|
||||
},
|
||||
} as unknown as WorkspaceSchemaManagerService;
|
||||
|
||||
command = new NormalizeLegacyIndexNamesCommand(
|
||||
workspaceIteratorService,
|
||||
workspaceCacheService,
|
||||
workspaceSchemaManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
const runOnWorkspace = (dataSource: DataSource, dryRun = false) =>
|
||||
command.runOnWorkspace({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
dataSource: dataSource as never,
|
||||
options: { dryRun },
|
||||
index: 0,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
it('renames a legacy-named index and updates its metadata row, then invalidates the cache', async () => {
|
||||
getOrRecomputeMock.mockResolvedValue(
|
||||
buildFlatEntityMaps([
|
||||
{
|
||||
universalIdentifier: 'idx-uid',
|
||||
id: 'index-1',
|
||||
name: 'legacyhash',
|
||||
expectedName: 'IDX_UNIQUE_new',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const { queryRunner, query, commit } = buildQueryRunner();
|
||||
const dataSource = {
|
||||
createQueryRunner: () => queryRunner,
|
||||
} as unknown as DataSource;
|
||||
|
||||
await runOnWorkspace(dataSource);
|
||||
|
||||
expect(renameIndexMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fromIndexName: 'legacyhash',
|
||||
toIndexName: 'IDX_UNIQUE_new',
|
||||
}),
|
||||
);
|
||||
expect(query).toHaveBeenCalledWith(expect.stringContaining('UPDATE'), [
|
||||
'IDX_UNIQUE_new',
|
||||
'index-1',
|
||||
WORKSPACE_ID,
|
||||
]);
|
||||
expect(dropIndexFromWorkspaceSchemaMock).not.toHaveBeenCalled();
|
||||
expect(commit).toHaveBeenCalled();
|
||||
expect(invalidateAndRecomputeMock).toHaveBeenCalledWith(WORKSPACE_ID, [
|
||||
'flatIndexMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the redundant duplicate and renames the survivor when two indexes collide', async () => {
|
||||
getOrRecomputeMock.mockResolvedValue(
|
||||
buildFlatEntityMaps([
|
||||
{
|
||||
universalIdentifier: 'idx-a',
|
||||
id: 'index-a',
|
||||
name: 'legacyA',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'idx-b',
|
||||
id: 'index-b',
|
||||
name: 'legacyB',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const { queryRunner } = buildQueryRunner();
|
||||
const dataSource = {
|
||||
createQueryRunner: () => queryRunner,
|
||||
} as unknown as DataSource;
|
||||
|
||||
await runOnWorkspace(dataSource);
|
||||
|
||||
expect(renameIndexMock).toHaveBeenCalledTimes(1);
|
||||
expect(renameIndexMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fromIndexName: 'legacyA',
|
||||
toIndexName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
);
|
||||
expect(dropIndexFromWorkspaceSchemaMock).toHaveBeenCalledTimes(1);
|
||||
expect(dropIndexFromWorkspaceSchemaMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ indexName: 'legacyB' }),
|
||||
);
|
||||
expect(deleteIndexMetadataMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityId: 'index-b', workspaceId: WORKSPACE_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when all index names already match', async () => {
|
||||
getOrRecomputeMock.mockResolvedValue(
|
||||
buildFlatEntityMaps([
|
||||
{
|
||||
universalIdentifier: 'idx-uid',
|
||||
id: 'index-1',
|
||||
name: 'IDX_ok',
|
||||
expectedName: 'IDX_ok',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const createQueryRunner = jest.fn();
|
||||
const dataSource = { createQueryRunner } as unknown as DataSource;
|
||||
|
||||
await runOnWorkspace(dataSource);
|
||||
|
||||
expect(createQueryRunner).not.toHaveBeenCalled();
|
||||
expect(renameIndexMock).not.toHaveBeenCalled();
|
||||
expect(invalidateAndRecomputeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write anything in dry-run mode', async () => {
|
||||
getOrRecomputeMock.mockResolvedValue(
|
||||
buildFlatEntityMaps([
|
||||
{
|
||||
universalIdentifier: 'idx-uid',
|
||||
id: 'index-1',
|
||||
name: 'legacyhash',
|
||||
expectedName: 'IDX_UNIQUE_new',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const createQueryRunner = jest.fn();
|
||||
const dataSource = { createQueryRunner } as unknown as DataSource;
|
||||
|
||||
await runOnWorkspace(dataSource, true);
|
||||
|
||||
expect(createQueryRunner).not.toHaveBeenCalled();
|
||||
expect(renameIndexMock).not.toHaveBeenCalled();
|
||||
expect(dropIndexFromWorkspaceSchemaMock).not.toHaveBeenCalled();
|
||||
expect(invalidateAndRecomputeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the transaction when an operation fails', async () => {
|
||||
getOrRecomputeMock.mockResolvedValue(
|
||||
buildFlatEntityMaps([
|
||||
{
|
||||
universalIdentifier: 'idx-uid',
|
||||
id: 'index-1',
|
||||
name: 'legacyhash',
|
||||
expectedName: 'IDX_UNIQUE_new',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renameIndexMock.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const { queryRunner, commit, rollback } = buildQueryRunner();
|
||||
const dataSource = {
|
||||
createQueryRunner: () => queryRunner,
|
||||
} as unknown as DataSource;
|
||||
|
||||
await expect(runOnWorkspace(dataSource)).rejects.toThrow('boom');
|
||||
|
||||
expect(rollback).toHaveBeenCalled();
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
expect(invalidateAndRecomputeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
type FlatIndexNameStatus,
|
||||
planIndexNameNormalization,
|
||||
} from 'src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util';
|
||||
|
||||
const buildStatus = (
|
||||
overrides: Partial<FlatIndexNameStatus> &
|
||||
Pick<FlatIndexNameStatus, 'indexMetadataId' | 'currentName' | 'expectedName'>,
|
||||
): FlatIndexNameStatus => ({
|
||||
objectMetadataId: 'object-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('planIndexNameNormalization', () => {
|
||||
it('returns no operations for an empty input', () => {
|
||||
expect(planIndexNameNormalization([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no operations when every index name already matches', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-1',
|
||||
currentName: 'IDX_UNIQUE_aaa',
|
||||
expectedName: 'IDX_UNIQUE_aaa',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-2',
|
||||
currentName: 'IDX_bbb',
|
||||
expectedName: 'IDX_bbb',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([]);
|
||||
});
|
||||
|
||||
it('renames a single legacy-named index to its expected name', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-1',
|
||||
currentName: 'legacyhash000000000000000000',
|
||||
expectedName: 'IDX_UNIQUE_newhash',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([
|
||||
{
|
||||
type: 'rename',
|
||||
indexMetadataId: 'index-1',
|
||||
objectMetadataId: 'object-1',
|
||||
fromName: 'legacyhash000000000000000000',
|
||||
toName: 'IDX_UNIQUE_newhash',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renames the survivor and drops the duplicate when two legacy indexes resolve to the same name', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-legacy-a',
|
||||
currentName: 'legacyhashA',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-legacy-b',
|
||||
currentName: 'legacyhashB',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([
|
||||
{
|
||||
type: 'rename',
|
||||
indexMetadataId: 'index-legacy-a',
|
||||
objectMetadataId: 'object-1',
|
||||
fromName: 'legacyhashA',
|
||||
toName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
{
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: 'index-legacy-b',
|
||||
objectMetadataId: 'object-1',
|
||||
redundantName: 'legacyhashB',
|
||||
keptName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the already-correct twin and only drops the legacy duplicate (no rename)', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-legacy',
|
||||
currentName: 'legacyhash',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-correct',
|
||||
currentName: 'IDX_UNIQUE_shared',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([
|
||||
{
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: 'index-legacy',
|
||||
objectMetadataId: 'object-1',
|
||||
redundantName: 'legacyhash',
|
||||
keptName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles a mix of correct, legacy, and duplicate indexes across objects', () => {
|
||||
const statuses = [
|
||||
// already correct -> ignored
|
||||
buildStatus({
|
||||
indexMetadataId: 'ok',
|
||||
objectMetadataId: 'object-a',
|
||||
currentName: 'IDX_ok',
|
||||
expectedName: 'IDX_ok',
|
||||
}),
|
||||
// legacy single -> rename
|
||||
buildStatus({
|
||||
indexMetadataId: 'legacy-single',
|
||||
objectMetadataId: 'object-a',
|
||||
currentName: 'oldname',
|
||||
expectedName: 'IDX_renamed',
|
||||
}),
|
||||
// duplicate pair on another object -> rename + drop
|
||||
buildStatus({
|
||||
indexMetadataId: 'dup-1',
|
||||
objectMetadataId: 'object-b',
|
||||
currentName: 'oldDup1',
|
||||
expectedName: 'IDX_UNIQUE_dup',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'dup-2',
|
||||
objectMetadataId: 'object-b',
|
||||
currentName: 'oldDup2',
|
||||
expectedName: 'IDX_UNIQUE_dup',
|
||||
}),
|
||||
];
|
||||
|
||||
const operations = planIndexNameNormalization(statuses);
|
||||
|
||||
expect(operations).toHaveLength(3);
|
||||
expect(operations).toContainEqual({
|
||||
type: 'rename',
|
||||
indexMetadataId: 'legacy-single',
|
||||
objectMetadataId: 'object-a',
|
||||
fromName: 'oldname',
|
||||
toName: 'IDX_renamed',
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
type: 'rename',
|
||||
indexMetadataId: 'dup-1',
|
||||
objectMetadataId: 'object-b',
|
||||
fromName: 'oldDup1',
|
||||
toName: 'IDX_UNIQUE_dup',
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: 'dup-2',
|
||||
objectMetadataId: 'object-b',
|
||||
redundantName: 'oldDup2',
|
||||
keptName: 'IDX_UNIQUE_dup',
|
||||
});
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
export type FlatIndexNameStatus = {
|
||||
indexMetadataId: string;
|
||||
objectMetadataId: string;
|
||||
currentName: string;
|
||||
expectedName: string;
|
||||
};
|
||||
|
||||
export type RenameIndexNameOperation = {
|
||||
type: 'rename';
|
||||
indexMetadataId: string;
|
||||
objectMetadataId: string;
|
||||
fromName: string;
|
||||
toName: string;
|
||||
};
|
||||
|
||||
export type DropRedundantIndexOperation = {
|
||||
type: 'dropRedundant';
|
||||
indexMetadataId: string;
|
||||
objectMetadataId: string;
|
||||
redundantName: string;
|
||||
keptName: string;
|
||||
};
|
||||
|
||||
export type IndexNameNormalizationOperation =
|
||||
| RenameIndexNameOperation
|
||||
| DropRedundantIndexOperation;
|
||||
|
||||
export const planIndexNameNormalization = (
|
||||
indexStatuses: FlatIndexNameStatus[],
|
||||
): IndexNameNormalizationOperation[] => {
|
||||
const operations: IndexNameNormalizationOperation[] = [];
|
||||
|
||||
const statusesByExpectedName = new Map<string, FlatIndexNameStatus[]>();
|
||||
|
||||
for (const status of indexStatuses) {
|
||||
const group = statusesByExpectedName.get(status.expectedName) ?? [];
|
||||
|
||||
group.push(status);
|
||||
statusesByExpectedName.set(status.expectedName, group);
|
||||
}
|
||||
|
||||
for (const [expectedName, group] of statusesByExpectedName) {
|
||||
const survivor =
|
||||
group.find((status) => status.currentName === expectedName) ?? group[0];
|
||||
|
||||
if (survivor.currentName !== expectedName) {
|
||||
operations.push({
|
||||
type: 'rename',
|
||||
indexMetadataId: survivor.indexMetadataId,
|
||||
objectMetadataId: survivor.objectMetadataId,
|
||||
fromName: survivor.currentName,
|
||||
toName: expectedName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of group) {
|
||||
if (status.indexMetadataId === survivor.indexMetadataId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
operations.push({
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: status.indexMetadataId,
|
||||
objectMetadataId: status.objectMetadataId,
|
||||
redundantName: status.currentName,
|
||||
keptName: expectedName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
};
|
||||
+2
@@ -18,6 +18,7 @@ import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
import { V2_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-15/2-15-upgrade-version-command.module';
|
||||
import { V2_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module';
|
||||
import { V2_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module';
|
||||
import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -39,6 +40,7 @@ import { V2_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
V2_15_UpgradeVersionCommandModule,
|
||||
V2_16_UpgradeVersionCommandModule,
|
||||
V2_17_UpgradeVersionCommandModule,
|
||||
V2_18_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
+16
@@ -90,4 +90,20 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async renameIndexWithoutRebuild({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
fromIndexName,
|
||||
toIndexName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
fromIndexName: string;
|
||||
toIndexName: string;
|
||||
}): Promise<void> {
|
||||
const sql = `ALTER INDEX ${escapeIdentifier(schemaName)}.${escapeIdentifier(fromIndexName)} RENAME TO ${escapeIdentifier(toIndexName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user