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:
Charles Bochet
2026-05-15 16:39:34 +02:00
committed by GitHub
parent 0c20b8bc88
commit eca92ca559
@@ -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();