fix(server): rebuild unique phone indexes drops legacy non-empty partial WHERE clause (#20606)
## Summary `RebuildUniquePhoneIndexesCommand` reuses each index's existing `indexWhereClause` when recreating the physical index. For workspaces whose unique phone indexes have a legacy clause like `"primaryPhoneNumber" != ''` (created before PR #18024 hardened the validator allowlist), the recreate path fails at `validateAndReturnIndexWhereClause` because the clause isn't in `ALLOWED_INDEX_WHERE_CLAUSES`. Two workspaces are hitting this on the 2.5 upgrade: - `3a797122-…` — `"companyPhonePrimaryPhoneNumber" != ''` - `ea74716f-…` — `"phonesPrimaryPhoneNumber" != ''` ## Fix Detect the legacy `"<col>" != ''` shape via a strict regex. When it's there, before the existing drop+create, do three things inside the workspace transaction: 1. **Normalize the data** that the legacy partial clause was masking — `UPDATE "<schema>"."<table>" SET "<col>" = NULL WHERE "<col>" = ''` for every column the index covers. Without this the next step would fail because the new plain-unique index would see duplicate `''` values across the rows the old partial clause was excluding. 2. **Null out `core."indexMetadata".indexWhereClause`** so the metadata row matches what the UI would have created (`indexWhereClause: null`) and doesn't carry the validator-rejected clause forward to any future re-emit. Uses the same workspace `queryRunner` (Postgres lets one connection write across schemas). 3. **Recreate** with an overridden flat index where `indexWhereClause: null`. `createIndexInWorkspaceSchema` → `indexManager.createIndex` → `validateAndReturnIndexWhereClause` short-circuits on null, no allowlist check. End state matches the shape a fresh "toggle unique in Settings UI" creates: plain unique index, no `WHERE`, NULL semantics doing the "exclude empty phones" work via PG's default NULL-distinct behaviour. For indexes whose clause is already allowlisted (`"deletedAt" IS NULL`) or null, behaviour is unchanged — just the column-list widening this command already does.
This commit is contained in:
+41
-2
@@ -1,6 +1,7 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
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';
|
||||
@@ -10,13 +11,17 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import {
|
||||
computeFlatIndexFieldColumnNames,
|
||||
createIndexInWorkspaceSchema,
|
||||
dropIndexFromWorkspaceSchema,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
|
||||
const LEGACY_NON_EMPTY_PARTIAL_INDEX_PATTERN = /^"[a-zA-Z][a-zA-Z0-9]*" != ''$/;
|
||||
|
||||
@RegisteredWorkspaceCommand('2.5.0', 1778000000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-5:rebuild-unique-phone-indexes',
|
||||
@@ -103,6 +108,35 @@ export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspace
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
const hasLegacyNonEmptyPartialClause =
|
||||
isNonEmptyString(uniquePhoneIndex.indexWhereClause) &&
|
||||
LEGACY_NON_EMPTY_PARTIAL_INDEX_PATTERN.test(
|
||||
uniquePhoneIndex.indexWhereClause,
|
||||
);
|
||||
|
||||
if (hasLegacyNonEmptyPartialClause) {
|
||||
const tableName = computeObjectTargetTable(flatObjectMetadata);
|
||||
const columns = computeFlatIndexFieldColumnNames({
|
||||
flatIndexFieldMetadatas: uniquePhoneIndex.flatIndexFieldMetadatas,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
for (const column of columns) {
|
||||
await queryRunner.query(
|
||||
`UPDATE "${schemaName}"."${tableName}"
|
||||
SET "${column}" = NULL
|
||||
WHERE "${column}" = ''`,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."indexMetadata"
|
||||
SET "indexWhereClause" = NULL
|
||||
WHERE id = $1`,
|
||||
[uniquePhoneIndex.id],
|
||||
);
|
||||
}
|
||||
|
||||
await dropIndexFromWorkspaceSchema({
|
||||
indexName: uniquePhoneIndex.name,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
@@ -110,8 +144,13 @@ export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspace
|
||||
schemaName,
|
||||
});
|
||||
|
||||
const flatIndexMetadataForRebuild: FlatIndexMetadata =
|
||||
hasLegacyNonEmptyPartialClause
|
||||
? { ...uniquePhoneIndex, indexWhereClause: null }
|
||||
: uniquePhoneIndex;
|
||||
|
||||
await createIndexInWorkspaceSchema({
|
||||
flatIndexMetadata: uniquePhoneIndex,
|
||||
flatIndexMetadata: flatIndexMetadataForRebuild,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
@@ -120,7 +159,7 @@ export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspace
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Rebuilt unique phone index ${uniquePhoneIndex.name} for workspace ${workspaceId}`,
|
||||
`Rebuilt unique phone index ${uniquePhoneIndex.name} for workspace ${workspaceId}${hasLegacyNonEmptyPartialClause ? ' (dropped legacy non-empty partial WHERE clause)' : ''}`,
|
||||
);
|
||||
}
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
Reference in New Issue
Block a user