Move search vector command from 1-7 to 1-10 and make it less verbose. (#14892)

Made the command more explicit for idempotency.

- Drop index explicitly (even though PostgreSQL does it automatically
when a column is dropped, but it might be good practice to account for
any unforeseen failures).
- Drop column.
- Create column.
- Create index (if we reach this point, column has already been created
and no error was thrown).
 
Figured we do not need extra queries to check ObjectMetadata for the
existence of person table on a workspace - we can use the IF EXISTS
syntax instead.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdullah.
2025-10-12 21:25:45 +05:00
committed by GitHub
parent 7b84db4708
commit d6526af302
3 changed files with 116 additions and 61 deletions
@@ -1,7 +1,7 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, Repository } from 'typeorm';
import { DataSource, Repository, type QueryRunner } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
@@ -9,6 +9,7 @@ import {
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_PERSON } from 'src/modules/person/standard-objects/person.workspace-entity';
@@ -25,94 +26,149 @@ export class RegeneratePersonSearchVectorWithPhonesCommand extends ActiveOrSuspe
@InjectDataSource()
private readonly coreDataSource: DataSource,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceSchemaManager: WorkspaceSchemaManagerService,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const schemaName = getWorkspaceSchemaName(workspaceId);
await this.ensureUnaccentFunction();
this.logger.log(
`Regenerating person search vector for workspace ${workspaceId} in schema ${schemaName}`,
`Regenerating person search vector for workspace ${workspaceId}`,
);
const personTableExists = await this.coreDataSource.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = '${schemaName}'
AND table_name = 'person'
);
`);
const queryRunner = this.coreDataSource.createQueryRunner();
if (!personTableExists[0]?.exists) {
this.logger.log(
`Person table does not exist in workspace ${workspaceId}, skipping`,
);
return;
}
const searchVectorColumnExists = await this.coreDataSource.query(`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = '${schemaName}'
AND table_name = 'person'
AND column_name = 'searchVector'
);
`);
if (!searchVectorColumnExists[0]?.exists) {
this.logger.log(
`searchVector column does not exist in workspace ${workspaceId}, skipping`,
);
return;
}
await queryRunner.connect();
try {
const schemaName = getWorkspaceSchemaName(workspaceId);
const newSearchVectorExpression = getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_PERSON,
);
this.logger.log(
`Dropping existing searchVector column for workspace ${workspaceId}`,
const isDryRun = Boolean(options.dryRun);
await queryRunner.startTransaction();
await this.applySearchVectorChanges(
schemaName,
workspaceId,
newSearchVectorExpression,
queryRunner,
);
await this.coreDataSource.query(`
ALTER TABLE "${schemaName}"."person"
DROP COLUMN "searchVector"
`);
if (isDryRun) {
await queryRunner.rollbackTransaction();
this.logger.log(
`Creating new searchVector column with phone indexing for workspace ${workspaceId}`,
);
this.logger.log(
`DRY RUN: Would regenerate person search vector for workspace ${workspaceId}`,
);
await this.coreDataSource.query(`
ALTER TABLE "${schemaName}"."person"
ADD COLUMN "searchVector" tsvector
GENERATED ALWAYS AS (${newSearchVectorExpression}) STORED
`);
return;
}
this.logger.log(
`Recreating GIN index on searchVector for workspace ${workspaceId}`,
);
await this.coreDataSource.query(`
CREATE INDEX "IDX_person_searchVector"
ON "${schemaName}"."person"
USING GIN ("searchVector")
`);
await queryRunner.commitTransaction();
this.logger.log(
`Successfully regenerated person search vector for workspace ${workspaceId}`,
);
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
this.logger.error(
`Failed to regenerate person search vector for workspace ${workspaceId}: ${error.message}`,
);
throw error;
} finally {
await queryRunner.release();
}
}
private async applySearchVectorChanges(
schemaName: string,
workspaceId: string,
newSearchVectorExpression: string,
queryRunner: QueryRunner,
): Promise<void> {
this.logger.log(
`Dropping existing searchVector index for workspace ${workspaceId}`,
);
await this.workspaceSchemaManager.indexManager.dropIndex({
queryRunner,
schemaName,
indexName: 'IDX_person_searchVector',
});
this.logger.log(
`Dropping existing searchVector column for workspace ${workspaceId}`,
);
await this.workspaceSchemaManager.columnManager.dropColumns({
queryRunner,
schemaName,
tableName: 'person',
columnNames: ['searchVector'],
});
this.logger.log(
`Creating new searchVector column with phone indexing for workspace ${workspaceId}`,
);
await this.workspaceSchemaManager.columnManager.addColumns({
queryRunner,
schemaName,
tableName: 'person',
columnDefinitions: [
{
name: 'searchVector',
type: 'tsvector',
isNullable: true,
asExpression: newSearchVectorExpression,
generatedType: 'STORED',
},
],
});
this.logger.log(
`Recreating GIN index on searchVector for workspace ${workspaceId}`,
);
await this.workspaceSchemaManager.indexManager.createIndex({
queryRunner,
schemaName,
tableName: 'person',
index: {
name: 'IDX_person_searchVector',
columns: ['searchVector'],
type: 'GIN',
},
});
}
private async ensureUnaccentFunction(): Promise<void> {
const result = await this.coreDataSource.query(`
SELECT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public'
AND p.proname = 'unaccent_immutable'
) as function_exists
`);
if (!result[0]?.function_exists) {
throw new Error(
'The public.unaccent_immutable() function is required but not found. Please run database migrations first.',
);
}
}
}
@@ -11,7 +11,7 @@ import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
@Module({
@@ -22,10 +22,10 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor
ObjectMetadataEntity,
IndexMetadataEntity,
]),
WorkspaceDataSourceModule,
IndexMetadataModule,
WorkspaceMigrationRunnerModule,
WorkspaceMigrationModule,
WorkspaceSchemaManagerModule,
],
providers: [
MigrateWorkflowStepFilterOperandValueCommand,
@@ -3,10 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@Module({
imports: [TypeOrmModule.forFeature([Workspace]), WorkspaceDataSourceModule],
imports: [TypeOrmModule.forFeature([Workspace])],
providers: [BackfillWorkflowManualTriggerAvailabilityCommand],
exports: [BackfillWorkflowManualTriggerAvailabilityCommand],
})