diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index cd1c872519..84425f8d22 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2991,6 +2991,7 @@ enum AllMetadataName { webhook applicationVariable connectionProvider + searchFieldMetadata } type MinimalObjectMetadata { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 13856b8747..0f609b2996 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2612,7 +2612,7 @@ export interface CollectionHash { __typename: 'CollectionHash' } -export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'rolePermissionFlag' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'applicationVariable' | 'connectionProvider' +export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'rolePermissionFlag' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'applicationVariable' | 'connectionProvider' | 'searchFieldMetadata' export interface MinimalObjectMetadata { id: Scalars['UUID'] @@ -9259,7 +9259,8 @@ export const enumAllMetadataName = { frontComponent: 'frontComponent' as const, webhook: 'webhook' as const, applicationVariable: 'applicationVariable' as const, - connectionProvider: 'connectionProvider' as const + connectionProvider: 'connectionProvider' as const, + searchFieldMetadata: 'searchFieldMetadata' as const } export const enumEventLogTable = { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 999b9fa19d..0d542cf7dc 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -208,6 +208,7 @@ export enum AllMetadataName { roleTarget = 'roleTarget', rowLevelPermissionPredicate = 'rowLevelPermissionPredicate', rowLevelPermissionPredicateGroup = 'rowLevelPermissionPredicateGroup', + searchFieldMetadata = 'searchFieldMetadata', skill = 'skill', view = 'view', viewField = 'viewField', diff --git a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts index b54566a012..3164106801 100644 --- a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts +++ b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts @@ -51,6 +51,7 @@ export const useMetadataErrorHandler = () => { viewSort: t`view sort`, applicationVariable: t`application variable`, connectionProvider: t`connection provider`, + searchFieldMetadata: t`search field metadata`, } as const satisfies Record; const handleMetadataError = ( diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782200000000-add-universal-identifier-and-application-id-to-search-field-metadata.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782200000000-add-universal-identifier-and-application-id-to-search-field-metadata.ts new file mode 100644 index 0000000000..e637b4e346 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782200000000-add-universal-identifier-and-application-id-to-search-field-metadata.ts @@ -0,0 +1,25 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.16.0', 1782200000000) +export class AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" ADD "universalIdentifier" uuid NOT NULL'); + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" ADD "applicationId" uuid NOT NULL'); + // The column is NOT NULL but added without a default: searchFieldMetadata is dormant/empty + // at instance-command time and the 2-16 backfill workspace command populates positions. + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" ADD "position" double precision NOT NULL'); + await queryRunner.query('CREATE UNIQUE INDEX "IDX_c2e441c901b45221a70d325349" ON "core"."searchFieldMetadata" ("workspaceId", "universalIdentifier") '); + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_927b6101a5d9562a558a18ed412" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_927b6101a5d9562a558a18ed412"'); + await queryRunner.query('DROP INDEX "core"."IDX_c2e441c901b45221a70d325349"'); + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" DROP COLUMN "position"'); + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" DROP COLUMN "applicationId"'); + await queryRunner.query('ALTER TABLE "core"."searchFieldMetadata" DROP COLUMN "universalIdentifier"'); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts new file mode 100644 index 0000000000..45c2797944 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { BackfillSearchFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; + +@Module({ + imports: [ + ApplicationModule, + WorkspaceCacheModule, + WorkspaceIteratorModule, + WorkspaceMigrationModule, + ], + providers: [BackfillSearchFieldMetadataCommand], +}) +export class V2_16_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts new file mode 100644 index 0000000000..cd95e6e4e6 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts @@ -0,0 +1,151 @@ +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 { buildSearchFieldMetadataBackfillOperations } from 'src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +@RegisteredWorkspaceCommand('2.16.0', 1799100000000) +@Command({ + name: 'upgrade:2-16:backfill-search-field-metadata', + description: + 'Backfill searchFieldMetadata rows for each searchable object. Standard objects mirror their SEARCH_FIELDS_FOR_* set; custom objects get their label-identifier field. Idempotent: existing rows are skipped.', +}) +export class BackfillSearchFieldMetadataCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatSearchFieldMetadataMaps', + ]); + + const { twentyStandardFlatApplication, workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + // The standard-application sync does not run during upgrades, so standard objects' + // rows are backfilled from the same definition provisioning uses + // (SEARCH_FIELDS_FOR_*), not by parsing the searchVector asExpression. + const { allFlatEntityMaps: standardAllFlatEntityMaps } = + computeTwentyStandardApplicationAllFlatEntityMaps({ + now: new Date().toISOString(), + workspaceId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + }); + + const { + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + } = buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + standardFlatSearchFieldMetadataMaps: + standardAllFlatEntityMaps.flatSearchFieldMetadataMaps, + customApplicationId: workspaceCustomFlatApplication.id, + }); + + const applicationUniversalIdentifiers = Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ); + + const totalRowsToCreate = applicationUniversalIdentifiers.reduce( + (total, applicationUniversalIdentifier) => + total + + (flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + applicationUniversalIdentifier + ]?.length ?? 0), + 0, + ); + + if (totalRowsToCreate === 0) { + this.logger.log( + `No missing searchFieldMetadata rows for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Found ${totalRowsToCreate} missing searchFieldMetadata row(s) for workspace ${workspaceId} across ${applicationUniversalIdentifiers.length} application(s)`, + ); + + if (isDryRun) { + return; + } + + // One migration per application: the runner assigns applicationId from the single + // application passed here, keeping custom-object rows tied to the custom application. + for (const applicationUniversalIdentifier of applicationUniversalIdentifiers) { + const flatSearchFieldMetadataToCreate = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + applicationUniversalIdentifier + ]; + + if ( + !isDefined(flatSearchFieldMetadataToCreate) || + flatSearchFieldMetadataToCreate.length === 0 + ) { + continue; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + isSystemBuild: true, + allFlatEntityOperationByMetadataName: { + searchFieldMetadata: { + flatEntityToCreate: flatSearchFieldMetadataToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to persist searchFieldMetadata rows for application ${applicationUniversalIdentifier}:\n${JSON.stringify( + validateAndBuildResult, + null, + 2, + )}`, + ); + + throw new Error( + `Failed to persist searchFieldMetadata rows for workspace ${workspaceId}`, + ); + } + } + + this.logger.log( + `Successfully backfilled ${totalRowsToCreate} searchFieldMetadata row(s) for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts new file mode 100644 index 0000000000..465e6ebde9 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts @@ -0,0 +1,646 @@ +import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { buildSearchFieldMetadataBackfillOperations } from 'src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + getFlatObjectMetadataMock, + getStandardFlatObjectMetadataMock, +} from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; + +const CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER = 'custom-application-uid'; +const CUSTOM_APPLICATION_ID = 'custom-application-id'; + +const buildUniversalIdentifiersByApplicationId = ( + flatObjectMetadatas: FlatObjectMetadata[], +): Record => + flatObjectMetadatas.reduce>( + (universalIdentifiersByApplicationId, flatObjectMetadata) => { + const applicationUniversalIdentifiers = + universalIdentifiersByApplicationId[flatObjectMetadata.applicationId] ?? + []; + + applicationUniversalIdentifiers.push( + flatObjectMetadata.universalIdentifier, + ); + + universalIdentifiersByApplicationId[flatObjectMetadata.applicationId] = + applicationUniversalIdentifiers; + + return universalIdentifiersByApplicationId; + }, + {}, + ); + +const buildFlatObjectMetadataMaps = ( + flatObjectMetadatas: FlatObjectMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.universalIdentifier, + flatObjectMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.id, + flatObjectMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: + buildUniversalIdentifiersByApplicationId(flatObjectMetadatas), +}); + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatSearchFieldMetadataMaps = ( + flatSearchFieldMetadatas: FlatSearchFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatSearchFieldMetadatas.map((flatSearchFieldMetadata) => [ + flatSearchFieldMetadata.universalIdentifier, + flatSearchFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatSearchFieldMetadatas.map((flatSearchFieldMetadata) => [ + flatSearchFieldMetadata.id, + flatSearchFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildSearchFieldMetadata = ({ + id, + universalIdentifier, + objectMetadataId, + fieldMetadataId, + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + applicationUniversalIdentifier, + position = 0, +}: { + id: string; + universalIdentifier: string; + objectMetadataId: string; + fieldMetadataId: string; + objectMetadataUniversalIdentifier: string; + fieldMetadataUniversalIdentifier: string; + applicationUniversalIdentifier: string; + position?: number; +}): FlatSearchFieldMetadata => { + const createdAt = '2024-01-01T00:00:00.000Z'; + + return { + id, + universalIdentifier, + objectMetadataId, + fieldMetadataId, + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + applicationId: 'unused-application-id', + applicationUniversalIdentifier, + position, + workspaceId: 'workspace-id', + createdAt, + updatedAt: createdAt, + }; +}; + +// Custom object with a searchable `name` field plus a TEXT field whose name +// overlaps it by prefix (name / nameDescription). Only the `name` field should +// produce a row; the exact-name match makes the prefix overlap irrelevant. This +// mirrors pre-2.15 provisioning, which indexes the custom object's `name` field only. +const buildCustomObjectFixture = () => { + const nameFieldId = 'name-field-id'; + const nameDescriptionFieldId = 'name-description-field-id'; + const customObjectUniversalIdentifier = 'custom-object-uid'; + const customObjectId = 'custom-object-id'; + + const nameField = getFlatFieldMetadataMock({ + id: nameFieldId, + universalIdentifier: 'name-field-uid', + objectMetadataId: customObjectId, + objectMetadataUniversalIdentifier: customObjectUniversalIdentifier, + type: FieldMetadataType.TEXT, + name: 'name', + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const nameDescriptionField = getFlatFieldMetadataMock({ + id: nameDescriptionFieldId, + universalIdentifier: 'name-description-field-uid', + objectMetadataId: customObjectId, + objectMetadataUniversalIdentifier: customObjectUniversalIdentifier, + type: FieldMetadataType.TEXT, + name: 'nameDescription', + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const customObject = getFlatObjectMetadataMock({ + id: customObjectId, + universalIdentifier: customObjectUniversalIdentifier, + isSearchable: true, + applicationId: CUSTOM_APPLICATION_ID, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataId: nameFieldId, + fieldUniversalIdentifiers: [ + nameField.universalIdentifier, + nameDescriptionField.universalIdentifier, + ], + }); + + return { customObject, nameField, nameDescriptionField }; +}; + +describe('buildSearchFieldMetadataBackfillOperations', () => { + it('selects only the name field for a custom object, ignoring a prefix-overlapping field name', () => { + const { customObject, nameField, nameDescriptionField } = + buildCustomObjectFixture(); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([customObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: + buildFlatSearchFieldMetadataMaps([]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + const customApplicationRows = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER + ]; + + expect(customApplicationRows).toHaveLength(1); + expect(customApplicationRows?.[0].fieldMetadataUniversalIdentifier).toBe( + nameField.universalIdentifier, + ); + expect(customApplicationRows?.[0].objectMetadataUniversalIdentifier).toBe( + customObject.universalIdentifier, + ); + // Custom object name field is seeded at position 0. + expect(customApplicationRows?.[0].position).toBe(0); + // No spurious row for the prefix-overlapping `nameDescription` field. + expect( + customApplicationRows?.some( + (row) => + row.fieldMetadataUniversalIdentifier === + nameDescriptionField.universalIdentifier, + ), + ).toBe(false); + }); + + it('creates no row for a searchable custom object that has no name field (junction object whose label identifier is a UUID id field)', () => { + const junctionObjectId = 'junction-object-id'; + const junctionObjectUniversalIdentifier = 'junction-object-uid'; + const idFieldId = 'junction-id-field-id'; + + // No name field: the label identifier falls back to the UUID `id` field. + // UUID is a searchable type, so a label-identifier-based derivation would + // wrongly emit a row — but pre-2.15 indexes nothing here, so backfill must + // skip it. Regression guard for the junction-object over-creation bug. + const idField = getFlatFieldMetadataMock({ + id: idFieldId, + universalIdentifier: 'junction-id-field-uid', + objectMetadataId: junctionObjectId, + objectMetadataUniversalIdentifier: junctionObjectUniversalIdentifier, + type: FieldMetadataType.UUID, + name: 'id', + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const junctionObject = getFlatObjectMetadataMock({ + id: junctionObjectId, + universalIdentifier: junctionObjectUniversalIdentifier, + isSearchable: true, + applicationId: CUSTOM_APPLICATION_ID, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataId: idFieldId, + fieldUniversalIdentifiers: [idField.universalIdentifier], + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([junctionObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([idField]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: + buildFlatSearchFieldMetadataMaps([]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('groups rows per application: standard-object rows under the standard application, custom-object rows under the custom application', () => { + const { customObject, nameField, nameDescriptionField } = + buildCustomObjectFixture(); + + const standardObjectId = 'standard-object-id'; + const standardObjectUniversalIdentifier = 'standard-object-uid'; + const standardFieldId = 'standard-field-id'; + const standardFieldUniversalIdentifier = 'standard-field-uid'; + + const standardField = getFlatFieldMetadataMock({ + id: standardFieldId, + universalIdentifier: standardFieldUniversalIdentifier, + objectMetadataId: standardObjectId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + type: FieldMetadataType.TEXT, + name: 'name', + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const standardObject = getStandardFlatObjectMetadataMock({ + id: standardObjectId, + universalIdentifier: standardObjectUniversalIdentifier, + isSearchable: true, + labelIdentifierFieldMetadataId: standardFieldId, + fieldIds: [standardFieldId], + }); + + const standardSearchFieldMetadata = buildSearchFieldMetadata({ + id: 'standard-search-field-id', + universalIdentifier: 'standard-search-field-uid', + objectMetadataId: standardObjectId, + fieldMetadataId: standardFieldId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + fieldMetadataUniversalIdentifier: standardFieldUniversalIdentifier, + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + customObject, + standardObject, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + standardField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + standardSearchFieldMetadata, + ]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ).sort(), + ).toEqual( + [ + CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + ].sort(), + ); + + const standardApplicationRows = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER + ]; + + expect(standardApplicationRows).toHaveLength(1); + expect(standardApplicationRows?.[0].fieldMetadataUniversalIdentifier).toBe( + standardFieldUniversalIdentifier, + ); + + const customApplicationRows = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER + ]; + + expect(customApplicationRows).toHaveLength(1); + expect(customApplicationRows?.[0].fieldMetadataUniversalIdentifier).toBe( + nameField.universalIdentifier, + ); + }); + + it('selects only the standard-set field on a standard object, never a prefix-overlapping sibling (phone vs phoneNumber)', () => { + const standardObjectId = 'standard-object-id'; + const standardObjectUniversalIdentifier = 'standard-object-uid'; + + // Two fields whose names overlap by prefix. Only `phone` is part of the + // standard search set; `phoneNumber` must never be pulled in. The old + // asExpression-token heuristic could mis-bind these by name; the id-based + // derivation cannot. + const phoneFieldId = 'phone-field-id'; + const phoneFieldUniversalIdentifier = 'phone-field-uid'; + const phoneNumberFieldId = 'phone-number-field-id'; + const phoneNumberFieldUniversalIdentifier = 'phone-number-field-uid'; + + const phoneField = getFlatFieldMetadataMock({ + id: phoneFieldId, + universalIdentifier: phoneFieldUniversalIdentifier, + objectMetadataId: standardObjectId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + type: FieldMetadataType.TEXT, + name: 'phone', + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const phoneNumberField = getFlatFieldMetadataMock({ + id: phoneNumberFieldId, + universalIdentifier: phoneNumberFieldUniversalIdentifier, + objectMetadataId: standardObjectId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + type: FieldMetadataType.TEXT, + name: 'phoneNumber', + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const standardObject = getStandardFlatObjectMetadataMock({ + id: standardObjectId, + universalIdentifier: standardObjectUniversalIdentifier, + isSearchable: true, + labelIdentifierFieldMetadataId: phoneFieldId, + fieldIds: [phoneFieldId, phoneNumberFieldId], + }); + + const phoneSearchFieldMetadata = buildSearchFieldMetadata({ + id: 'phone-search-field-id', + universalIdentifier: 'phone-search-field-uid', + objectMetadataId: standardObjectId, + fieldMetadataId: phoneFieldId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + fieldMetadataUniversalIdentifier: phoneFieldUniversalIdentifier, + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([standardObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + phoneField, + phoneNumberField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + phoneSearchFieldMetadata, + ]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + const standardApplicationRows = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER + ]; + + expect(standardApplicationRows).toHaveLength(1); + expect(standardApplicationRows?.[0].fieldMetadataUniversalIdentifier).toBe( + phoneFieldUniversalIdentifier, + ); + // The prefix-overlapping `phoneNumber` field is never selected. + expect( + standardApplicationRows?.some( + (row) => + row.fieldMetadataUniversalIdentifier === + phoneNumberFieldUniversalIdentifier, + ), + ).toBe(false); + }); + + it('carries the position from the standard maps row onto the backfilled standard row', () => { + const standardObjectId = 'standard-object-id'; + const standardObjectUniversalIdentifier = 'standard-object-uid'; + const emailsFieldId = 'emails-field-id'; + const emailsFieldUniversalIdentifier = 'emails-field-uid'; + + const emailsField = getFlatFieldMetadataMock({ + id: emailsFieldId, + universalIdentifier: emailsFieldUniversalIdentifier, + objectMetadataId: standardObjectId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + type: FieldMetadataType.TEXT, + name: 'emails', + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const standardObject = getStandardFlatObjectMetadataMock({ + id: standardObjectId, + universalIdentifier: standardObjectUniversalIdentifier, + isSearchable: true, + labelIdentifierFieldMetadataId: emailsFieldId, + fieldIds: [emailsFieldId], + }); + + // The standard maps row sits at position 3 (e.g. SEARCH_FIELDS_FOR_PERSON order); + // backfill must replicate that ordinal, not reset it to 0. + const standardSearchFieldMetadata = buildSearchFieldMetadata({ + id: 'emails-search-field-id', + universalIdentifier: 'emails-search-field-uid', + objectMetadataId: standardObjectId, + fieldMetadataId: emailsFieldId, + objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier, + fieldMetadataUniversalIdentifier: emailsFieldUniversalIdentifier, + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + position: 3, + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([standardObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([emailsField]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + standardSearchFieldMetadata, + ]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + const standardApplicationRows = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER + ]; + + expect(standardApplicationRows).toHaveLength(1); + expect(standardApplicationRows?.[0].position).toBe(3); + }); + + it('is a no-op when the searchFieldMetadata rows already exist (idempotent)', () => { + const { customObject, nameField, nameDescriptionField } = + buildCustomObjectFixture(); + + const existingSearchFieldMetadata = buildSearchFieldMetadata({ + id: 'existing-search-field-id', + universalIdentifier: 'existing-search-field-uid', + objectMetadataId: customObject.id, + fieldMetadataId: nameField.id, + objectMetadataUniversalIdentifier: customObject.universalIdentifier, + fieldMetadataUniversalIdentifier: nameField.universalIdentifier, + applicationUniversalIdentifier: + CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([customObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + existingSearchFieldMetadata, + ]), + standardFlatSearchFieldMetadataMaps: + buildFlatSearchFieldMetadataMaps([]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('skips a custom object whose name field is not a searchable type', () => { + const customObjectId = 'relation-name-object-id'; + const customObjectUniversalIdentifier = 'relation-name-object-uid'; + const relationNameFieldId = 'relation-name-field-id'; + + // A `name` field that is a RELATION (non-searchable) type: pre-2.15 would not + // include it in the tsvector and the recompute filters it out, so no row. + const relationNameField = getFlatFieldMetadataMock({ + id: relationNameFieldId, + universalIdentifier: 'relation-name-field-uid', + objectMetadataId: customObjectId, + objectMetadataUniversalIdentifier: customObjectUniversalIdentifier, + type: FieldMetadataType.RELATION, + name: 'name', + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const customObject = getFlatObjectMetadataMock({ + id: customObjectId, + universalIdentifier: customObjectUniversalIdentifier, + isSearchable: true, + applicationId: CUSTOM_APPLICATION_ID, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataId: relationNameFieldId, + fieldUniversalIdentifiers: [relationNameField.universalIdentifier], + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([customObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([relationNameField]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: + buildFlatSearchFieldMetadataMaps([]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('skips a searchable object owned by a third-party application (only the workspace custom application is backfilled)', () => { + const thirdPartyApplicationId = 'third-party-application-id'; + const thirdPartyApplicationUniversalIdentifier = + 'third-party-application-uid'; + const { customObject, nameField, nameDescriptionField } = + buildCustomObjectFixture(); + + // Same fixture but owned by a different (third-party) application: each application + // owns its own searchFieldMetadata, so this backfill must not fabricate a row for it. + const thirdPartyObject: FlatObjectMetadata = { + ...customObject, + applicationId: thirdPartyApplicationId, + applicationUniversalIdentifier: thirdPartyApplicationUniversalIdentifier, + }; + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([thirdPartyObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: + buildFlatSearchFieldMetadataMaps([]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('skips non-searchable objects', () => { + const { customObject, nameField, nameDescriptionField } = + buildCustomObjectFixture(); + + const nonSearchableObject: FlatObjectMetadata = { + ...customObject, + isSearchable: false, + }; + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + nonSearchableObject, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: + buildFlatSearchFieldMetadataMaps([]), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts new file mode 100644 index 0000000000..e8259a249a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts @@ -0,0 +1,159 @@ +import { + fromArrayToValuesByKeyRecord, + isDefined, + isSearchableFieldType, +} from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntitiesByApplicationId } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entities-by-application-id.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util'; +import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata.constants'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; + +type SearchFieldMetadataBackfillOperationsArgs = { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + flatSearchFieldMetadataMaps: FlatEntityMaps; + standardFlatSearchFieldMetadataMaps: FlatEntityMaps; + customApplicationId: string; +}; + +type BuildSearchFieldMetadataBackfillOperationsReturnType = { + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier: Record< + string, + UniversalFlatSearchFieldMetadata[] + >; +}; + +// Groups rows by application so each group runs through the migration runner under +// the matching application (the runner assigns applicationId from that single app). +export const buildSearchFieldMetadataBackfillOperations = ({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + standardFlatSearchFieldMetadataMaps, + customApplicationId, +}: SearchFieldMetadataBackfillOperationsArgs): BuildSearchFieldMetadataBackfillOperationsReturnType => { + const existingSearchFieldMetadataKeys = new Set( + Object.values(flatSearchFieldMetadataMaps.byUniversalIdentifier) + .filter(isDefined) + .map( + (searchFieldMetadata) => + `${searchFieldMetadata.objectMetadataId}:${searchFieldMetadata.fieldMetadataId}`, + ), + ); + + const flatSearchFieldMetadatasToCreate: UniversalFlatSearchFieldMetadata[] = + []; + // Dedupe (object, field) within a run: standard and custom derivations can overlap. + const candidateSearchFieldMetadataKeys = new Set(); + + const pushCandidateIfMissing = ({ + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + position, + }: { + objectMetadataUniversalIdentifier: string; + fieldMetadataUniversalIdentifier: string; + position: number; + }): void => { + const flatObjectMetadata = + flatObjectMetadataMaps.byUniversalIdentifier[ + objectMetadataUniversalIdentifier + ]; + const flatFieldMetadata = + flatFieldMetadataMaps.byUniversalIdentifier[ + fieldMetadataUniversalIdentifier + ]; + + // Skip rows whose object/field isn't provisioned in this workspace yet (keeps + // the backfill idempotent and lets each sync own its own rows). + if (!isDefined(flatObjectMetadata) || !isDefined(flatFieldMetadata)) { + return; + } + + const searchFieldMetadataKey = `${flatObjectMetadata.id}:${flatFieldMetadata.id}`; + + if ( + existingSearchFieldMetadataKeys.has(searchFieldMetadataKey) || + candidateSearchFieldMetadataKeys.has(searchFieldMetadataKey) + ) { + return; + } + + candidateSearchFieldMetadataKeys.add(searchFieldMetadataKey); + + flatSearchFieldMetadatasToCreate.push( + buildFlatSearchFieldMetadataForField({ + flatObjectMetadata, + flatFieldMetadata, + position, + }), + ); + }; + + // Standard objects: mirror exactly what provisioning/standard-sync creates. + for (const standardSearchFieldMetadata of Object.values( + standardFlatSearchFieldMetadataMaps.byUniversalIdentifier, + ).filter(isDefined)) { + pushCandidateIfMissing({ + objectMetadataUniversalIdentifier: + standardSearchFieldMetadata.objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier: + standardSearchFieldMetadata.fieldMetadataUniversalIdentifier, + position: standardSearchFieldMetadata.position, + }); + } + + const customApplicationFlatObjectMetadatas = findFlatEntitiesByApplicationId({ + flatEntityMaps: flatObjectMetadataMaps, + applicationId: customApplicationId, + }); + + // Custom objects index only the field named 'name' (SEARCH_FIELDS_FOR_CUSTOM_OBJECT). + // Resolve it by exact name, not the label identifier: junction objects (skipNameField) + // have no name field and must stay unsearchable — their label identifier is the UUID id. + for (const flatObjectMetadata of customApplicationFlatObjectMetadatas) { + if (!flatObjectMetadata.isSearchable) { + continue; + } + + const nameFieldMetadata = flatObjectMetadata.fieldUniversalIdentifiers + .map( + (fieldUniversalIdentifier) => + flatFieldMetadataMaps.byUniversalIdentifier[fieldUniversalIdentifier], + ) + .find( + (flatFieldMetadata) => + isDefined(flatFieldMetadata) && + flatFieldMetadata.name === DEFAULT_LABEL_IDENTIFIER_FIELD_NAME, + ); + + // Aligns with the recompute, which drops non-searchable-type fields. + if ( + !isDefined(nameFieldMetadata) || + !isSearchableFieldType(nameFieldMetadata.type) + ) { + continue; + } + + pushCandidateIfMissing({ + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + fieldMetadataUniversalIdentifier: nameFieldMetadata.universalIdentifier, + position: 0, + }); + } + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + fromArrayToValuesByKeyRecord({ + array: flatSearchFieldMetadatasToCreate, + key: 'applicationUniversalIdentifier', + }); + + return { + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + }; +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index b947fce001..b9217f0835 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -29,8 +29,11 @@ import { CreateUnsubscribeTopicCoreTableFastInstanceCommand } from 'src/database import { RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781277453604-rename-is-ui-read-only-to-is-ui-editable'; import { BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects'; import { SetTableWidgetViewsVisibilityToWorkspaceSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-instance-command-slow-1781515653781-set-table-widget-views-visibility-to-workspace'; +import { AddHasPaymentMethodToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-fast-1781280240009-add-has-payment-method-to-billing-customer'; import { AddIsSystemSideEffectFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-fast-1781600000000-add-is-system-side-effect'; import { BackfillConnectionSecuritySlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-slow-1781461753981-backfill-connection-security'; +import { AddChannelWebhookSubscriptionFieldsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782152096938-add-channel-webhook-subscription-fields'; +import { AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782200000000-add-universal-identifier-and-application-id-to-search-field-metadata'; import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread'; import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application'; import { AddSubFieldNameToViewSortEarlyFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1747234200000-add-sub-field-name-to-view-sort'; @@ -72,10 +75,8 @@ import { EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand } from import { AddLogicFunctionExecutionModeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000030000-add-logic-function-execution-mode'; import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable'; import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences'; -import { AddHasPaymentMethodToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-fast-1781280240009-add-has-payment-method-to-billing-customer'; import { AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781714499016-add-folder-import-to-message-folder-pending-sync-action'; import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781900000000-add-view-kanban-column-width'; -import { AddChannelWebhookSubscriptionFieldsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782152096938-add-channel-webhook-subscription-fields'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -154,4 +155,5 @@ export const INSTANCE_COMMANDS = [ AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand, AddViewKanbanColumnWidthFastInstanceCommand, AddChannelWebhookSubscriptionFieldsFastInstanceCommand, + AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts index 0d253e02df..a2ad31f971 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts @@ -16,6 +16,7 @@ import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { V2_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-13/2-13-upgrade-version-command.module'; import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-14/2-14-upgrade-version-command.module'; 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'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { V2_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade V2_13_UpgradeVersionCommandModule, V2_14_UpgradeVersionCommandModule, V2_15_UpgradeVersionCommandModule, + V2_16_UpgradeVersionCommandModule, ], }) export class WorkspaceCommandProviderModule {} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts index bf6b82f5c5..95677493b6 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts @@ -152,6 +152,7 @@ export const mockPersonFlatObjectMetadata = ( workspaceId, universalIdentifier: objectMetadataId, indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], fieldIds: mockFieldMetadatas.map((field) => field.id), @@ -173,6 +174,7 @@ export const mockPersonFlatObjectMetadata = ( objectPermissionUniversalIdentifiers: [], fieldPermissionUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, }); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts index 074a673bb8..e0ee8d9ba6 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts @@ -35,6 +35,7 @@ export const fromObjectManifestToUniversalFlatObjectMetadata = ({ isLabelSyncedWithName: false, fieldUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts index cca50921cb..ad0947b827 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts @@ -25,6 +25,7 @@ const mockObjectMetadata: FlatObjectMetadata = { isAuditLogged: true, isSearchable: true, indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], fieldIds: [], @@ -47,6 +48,7 @@ const mockObjectMetadata: FlatObjectMetadata = { fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index fd923c6c8d..60454fdce7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -95,6 +95,7 @@ export class FieldMetadataService extends TypeOrmQueryService 0 ? { pageLayoutWidget: { @@ -218,6 +228,7 @@ export class FieldMetadataService extends TypeOrmQueryService; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts index 8ab905a195..041b22c260 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts @@ -303,6 +303,16 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = { workspace: null, application: null, }, + searchFieldMetadata: { + workspace: null, + application: null, + objectMetadata: { + foreignKey: 'objectMetadataId', + }, + fieldMetadata: { + foreignKey: 'fieldMetadataId', + }, + }, } as const satisfies ManyToOneMetadataRelationsProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts index 1548c668a4..485e118dab 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts @@ -531,6 +531,24 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = { workspace: null, application: null, }, + searchFieldMetadata: { + workspace: null, + application: null, + objectMetadata: { + metadataName: 'objectMetadata', + foreignKey: 'objectMetadataId', + inverseOneToManyProperty: 'searchFieldMetadatas', + isNullable: false, + universalForeignKey: 'objectMetadataUniversalIdentifier', + }, + fieldMetadata: { + metadataName: 'fieldMetadata', + foreignKey: 'fieldMetadataId', + inverseOneToManyProperty: null, + isNullable: false, + universalForeignKey: 'fieldMetadataUniversalIdentifier', + }, + }, } as const satisfies ManyToOneMetadataRelationsProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts index c2d923418e..0eb4420345 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts @@ -31,6 +31,7 @@ import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entiti import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; export const ALL_METADATA_ENTITY_BY_METADATA_NAME = { viewField: ViewFieldEntity, @@ -63,4 +64,5 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = { applicationVariable: ApplicationVariableEntity, viewSort: ViewSortEntity, connectionProvider: ConnectionProviderEntity, + searchFieldMetadata: SearchFieldMetadataEntity, } as const satisfies Record>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index 91b157d034..0ef1d6b69a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -118,4 +118,8 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { webhook: {}, applicationVariable: {}, connectionProvider: {}, + searchFieldMetadata: { + objectMetadata: true, + fieldMetadata: true, + }, } as const satisfies MetadataRequiredForValidation; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts index d8abb9406f..4d94020012 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts @@ -53,6 +53,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = { webhook: {}, applicationVariable: {}, connectionProvider: {}, + searchFieldMetadata: {}, } as const satisfies MetadataSerializedRelationProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts index feae37154c..539038ab95 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts @@ -107,6 +107,12 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { flatEntityForeignKeyAggregator: 'viewIds', universalFlatEntityForeignKeyAggregator: 'viewUniversalIdentifiers', }, + searchFieldMetadatas: { + metadataName: 'searchFieldMetadata', + flatEntityForeignKeyAggregator: 'searchFieldMetadataIds', + universalFlatEntityForeignKeyAggregator: + 'searchFieldMetadataUniversalIdentifiers', + }, }, view: { viewFields: { @@ -254,6 +260,7 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { applicationVariable: {}, viewSort: {}, connectionProvider: {}, + searchFieldMetadata: {}, } as const satisfies OneToManyMetadataRelationsProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts index 8147bb3045..f714c65dfe 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts @@ -21,6 +21,7 @@ import { WorkspaceFlatViewFieldMapCacheService } from 'src/engine/metadata-modul import { WorkspaceFlatViewFilterGroupMapCacheService } from 'src/engine/metadata-modules/flat-view-filter-group/services/workspace-flat-view-filter-group-map-cache.service'; import { WorkspaceFlatViewFilterMapCacheService } from 'src/engine/metadata-modules/flat-view-filter/services/workspace-flat-view-filter-map-cache.service'; import { WorkspaceFlatViewGroupMapCacheService } from 'src/engine/metadata-modules/flat-view-group/services/workspace-flat-view-group-map-cache.service'; +import { WorkspaceFlatSearchFieldMetadataMapCacheService } from 'src/engine/metadata-modules/flat-search-field-metadata/services/workspace-flat-search-field-metadata-map-cache.service'; import { WorkspaceFlatViewSortMapCacheService } from 'src/engine/metadata-modules/flat-view-sort/services/workspace-flat-view-sort-map-cache.service'; import { WorkspaceFlatViewMapCacheService } from 'src/engine/metadata-modules/flat-view/services/workspace-flat-view-map-cache.service'; import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; @@ -37,6 +38,7 @@ import { RolePermissionFlagEntity } from 'src/engine/metadata-modules/role-permi import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity'; import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity'; import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity'; import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity'; @@ -74,6 +76,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache ApplicationEntity, RoleEntity, FrontComponentEntity, + SearchFieldMetadataEntity, ]), ], providers: [ @@ -92,12 +95,14 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceFlatRolePermissionFlagMapCacheService, WorkspaceFlatPermissionFlagMapCacheService, WorkspaceFlatViewSortMapCacheService, + WorkspaceFlatSearchFieldMetadataMapCacheService, WorkspaceFlatPageLayoutMapCacheService, WorkspaceFlatPageLayoutTabMapCacheService, WorkspaceFlatPageLayoutWidgetMapCacheService, WorkspaceFlatRowLevelPermissionPredicateMapCacheService, WorkspaceFlatRowLevelPermissionPredicateGroupMapCacheService, provideWorkspaceScopedRepository(PermissionFlagEntity), + provideWorkspaceScopedRepository(SearchFieldMetadataEntity), provideWorkspaceScopedRepository(IndexMetadataEntity), provideWorkspaceScopedRepository(ViewEntity), provideWorkspaceScopedRepository(ViewFieldEntity), @@ -131,6 +136,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceFlatRolePermissionFlagMapCacheService, WorkspaceFlatPermissionFlagMapCacheService, WorkspaceFlatViewSortMapCacheService, + WorkspaceFlatSearchFieldMetadataMapCacheService, WorkspaceFlatPageLayoutMapCacheService, WorkspaceFlatPageLayoutTabMapCacheService, WorkspaceFlatPageLayoutWidgetMapCacheService, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts index 215f6bebf2..d26dba740d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts @@ -45,6 +45,8 @@ import { type FlatViewGroupMaps } from 'src/engine/metadata-modules/flat-view-gr import { type FlatViewGroup } from 'src/engine/metadata-modules/flat-view-group/types/flat-view-group.type'; import { type FlatViewSortMaps } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort-maps.type'; import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type'; +import { type FlatSearchFieldMetadataMaps } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata-maps.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type'; import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; import { type FlatApplicationVariableMaps } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable-maps.type'; @@ -84,6 +86,7 @@ import { type UniversalFlatViewFilterGroup } from 'src/engine/workspace-manager/ import { type UniversalFlatViewFilter } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-filter.type'; import { type UniversalFlatViewGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-group.type'; import { type UniversalFlatViewSort } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-sort.type'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type'; import { type UniversalFlatApplicationVariable } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-application-variable.type'; import { type UniversalFlatWebhook } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-webhook.type'; @@ -327,6 +330,14 @@ import { type UniversalDeleteWebhookAction, type UniversalUpdateWebhookAction, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type'; +import { + type FlatCreateSearchFieldMetadataAction, + type FlatDeleteSearchFieldMetadataAction, + type FlatUpdateSearchFieldMetadataAction, + type UniversalCreateSearchFieldMetadataAction, + type UniversalDeleteSearchFieldMetadataAction, + type UniversalUpdateSearchFieldMetadataAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type'; export type AllFlatEntityTypesByMetadataName = { fieldMetadata: { @@ -812,4 +823,20 @@ export type AllFlatEntityTypesByMetadataName = { universalFlatEntity: UniversalFlatConnectionProvider; entity: MetadataEntity<'connectionProvider'>; }; + searchFieldMetadata: { + flatEntityMaps: FlatSearchFieldMetadataMaps; + universalActions: { + create: UniversalCreateSearchFieldMetadataAction; + update: UniversalUpdateSearchFieldMetadataAction; + delete: UniversalDeleteSearchFieldMetadataAction; + }; + flatActions: { + create: FlatCreateSearchFieldMetadataAction; + update: FlatUpdateSearchFieldMetadataAction; + delete: FlatDeleteSearchFieldMetadataAction; + }; + flatEntity: FlatSearchFieldMetadata; + universalFlatEntity: UniversalFlatSearchFieldMetadata; + entity: MetadataEntity<'searchFieldMetadata'>; + }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 5e23a00291..8da7ded374 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -60,6 +60,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo "objectPermission", "fieldPermission", "view", + "searchFieldMetadata", ] `; @@ -139,6 +140,13 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo ] `; +exports[`getMetadataRelatedMetadataNames should return related metadata names for searchFieldMetadata 1`] = ` +[ + "objectMetadata", + "fieldMetadata", +] +`; + exports[`getMetadataRelatedMetadataNames should return related metadata names for skill 1`] = `[]`; exports[`getMetadataRelatedMetadataNames should return related metadata names for view 1`] = ` diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index b767c8a3f3..c9a96a1c58 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -11,6 +11,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with "objectPermission", "pageLayoutWidget", "rolePermissionFlag", + "searchFieldMetadata", "viewSort", "index", "pageLayout", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts index 50ba10436c..b1b577cdbb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts @@ -46,6 +46,7 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps('obj-1'), flatIndexMaps: createEmptyFlatEntityMaps(), + flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow( expect.objectContaining({ @@ -69,6 +70,7 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), + flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow( expect.objectContaining({ @@ -92,6 +94,7 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), + flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow(new RegExp('Cannot delete standard field "city"')); }); @@ -110,6 +113,7 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([customField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), + flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }); expect(result.flatFieldMetadatasToDelete).toContainEqual( @@ -132,6 +136,7 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), + flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow(FieldMetadataException); }); @@ -155,6 +160,7 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardAppField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), + flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow( expect.objectContaining({ diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util.ts new file mode 100644 index 0000000000..eba5d0722d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util.ts @@ -0,0 +1,89 @@ +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { recomputeSearchVectorFieldFromSearchFieldMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-from-search-field-metadatas.util'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; + +export const computeSearchFieldMetadataDeletionForDeletedFields = ({ + flatFieldMetadatasToDelete, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, +}: { + flatFieldMetadatasToDelete: FlatFieldMetadata[]; +} & Pick< + AllFlatEntityMaps, + | 'flatObjectMetadataMaps' + | 'flatFieldMetadataMaps' + | 'flatSearchFieldMetadataMaps' +>): { + searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[]; + flatSearchVectorFieldsToUpdate: FlatFieldMetadata[]; +} => { + const deletedFieldIds = new Set( + flatFieldMetadatasToDelete.map((flatFieldMetadata) => flatFieldMetadata.id), + ); + const affectedObjectMetadataIds = new Set( + flatFieldMetadatasToDelete.map( + (flatFieldMetadata) => flatFieldMetadata.objectMetadataId, + ), + ); + + const searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[] = []; + const flatSearchVectorFieldsToUpdate: FlatFieldMetadata[] = + []; + + for (const objectMetadataId of affectedObjectMetadataIds) { + const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: objectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(flatObjectMetadata) || !flatObjectMetadata.isSearchable) { + continue; + } + + const objectSearchFieldMetadatas = + findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityMaps: flatSearchFieldMetadataMaps, + flatEntityIds: flatObjectMetadata.searchFieldMetadataIds, + }); + + const rowsToDelete = objectSearchFieldMetadatas.filter( + (searchFieldMetadata) => + deletedFieldIds.has(searchFieldMetadata.fieldMetadataId), + ); + + if (rowsToDelete.length === 0) { + continue; + } + + searchFieldMetadatasToDelete.push(...rowsToDelete); + + const remainingSearchFieldMetadatas = objectSearchFieldMetadatas.filter( + (searchFieldMetadata) => + !deletedFieldIds.has(searchFieldMetadata.fieldMetadataId), + ); + + const flatSearchVectorFieldToUpdate = + recomputeSearchVectorFieldFromSearchFieldMetadatas({ + flatObjectMetadata, + flatFieldMetadataMaps, + searchFieldMetadatas: remainingSearchFieldMetadatas, + }); + + if (isDefined(flatSearchVectorFieldToUpdate)) { + flatSearchVectorFieldsToUpdate.push(flatSearchVectorFieldToUpdate); + } + } + + return { + searchFieldMetadatasToDelete, + flatSearchVectorFieldsToUpdate, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts index 63a8d4de53..6777c01e6b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts @@ -1,3 +1,4 @@ +import { type FieldMetadataType } from 'twenty-shared/types'; import { isDefined, trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties, @@ -12,18 +13,24 @@ import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; 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 { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { computeFlatFieldMetadataRelatedFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-metadata-related-flat-field-metadata.util'; +import { computeSearchFieldMetadataDeletionForDeletedFields } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util'; import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/belongs-to-twenty-standard-app.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; type FromDeleteFieldInputToFlatFieldMetadatasToDeleteArgs = { deleteOneFieldInput: DeleteOneFieldInput; } & Pick< AllFlatEntityMaps, - 'flatFieldMetadataMaps' | 'flatIndexMaps' | 'flatObjectMetadataMaps' + | 'flatFieldMetadataMaps' + | 'flatIndexMaps' + | 'flatObjectMetadataMaps' + | 'flatSearchFieldMetadataMaps' >; // TODO refactor as a side effect service export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({ @@ -31,10 +38,13 @@ export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({ deleteOneFieldInput: rawDeleteOneInput, flatIndexMaps: existingFlatIndexMaps, flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps, }: FromDeleteFieldInputToFlatFieldMetadatasToDeleteArgs): { flatFieldMetadatasToDelete: UniversalFlatFieldMetadata[]; flatIndexesToUpdate: UniversalFlatIndexMetadata[]; flatIndexesToDelete: UniversalFlatIndexMetadata[]; + searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[]; + flatSearchVectorFieldsToUpdate: FlatFieldMetadata[]; } => { const { id: fieldMetadataToDeleteId } = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( @@ -177,9 +187,19 @@ export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({ }, ); + const { searchFieldMetadatasToDelete, flatSearchVectorFieldsToUpdate } = + computeSearchFieldMetadataDeletionForDeletedFields({ + flatFieldMetadatasToDelete, + flatObjectMetadataMaps: existingFlatObjectMetadataMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps, + }); + return { flatFieldMetadatasToDelete, flatIndexesToDelete, flatIndexesToUpdate, + searchFieldMetadatasToDelete, + flatSearchVectorFieldsToUpdate, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts index 4e09d208ce..73aa746b87 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts @@ -35,6 +35,7 @@ type FromUpdateFieldInputToFlatFieldMetadataArgs = { | 'flatViewGroupMaps' | 'flatViewMaps' | 'flatViewFieldMaps' + | 'flatSearchFieldMetadataMaps' >; type FlatFieldMetadataAndIndexToUpdate = { @@ -51,6 +52,7 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ flatViewGroupMaps, flatViewMaps, flatViewFieldMaps, + flatSearchFieldMetadataMaps, isSystemBuild, }: FromUpdateFieldInputToFlatFieldMetadataArgs): FieldInputTranspilationResult => { const updateFieldInputInformalProperties = @@ -150,6 +152,7 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ toFlatFieldMetadata, flatViewMaps, flatViewFieldMaps, + flatSearchFieldMetadataMaps, flatApplication, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts index 6fde0d0618..2c84d6603e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts @@ -15,8 +15,8 @@ import { type FieldMetadataUpdateIndexSideEffect, handleIndexChangesDuringFieldUpdate, } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-index-changes-during-field-update.util'; -import { handleLabelIdentifierChangesDuringFieldUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-label-identifier-changes-during-field-update.util'; import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util'; +import { recomputeSearchVectorOnFieldRename } from 'src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util'; import { type FlatViewFiltersToDeleteAndUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util'; import { type FlatViewGroupsToDeleteUpdateAndCreate } from 'src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-groups-on-flat-field-metadata-options-update.util'; @@ -41,6 +41,7 @@ type HandleFlatFieldMetadataUpdateSideEffectArgs = FromTo< | 'flatViewGroupMaps' | 'flatViewMaps' | 'flatViewFieldMaps' + | 'flatSearchFieldMetadataMaps' > & { flatApplication: FlatApplication; }; @@ -71,6 +72,7 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ flatViewGroupMaps, flatViewMaps, flatViewFieldMaps, + flatSearchFieldMetadataMaps, flatApplication, }: HandleFlatFieldMetadataUpdateSideEffectArgs): FieldInputTranspilationResult => { const sideEffectResult = structuredClone( @@ -143,24 +145,18 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ flatEntityId: fromFlatFieldMetadata.objectMetadataId, }); - const isLabelIdentifierFieldMetadata = - flatObjectMetadata.labelIdentifierFieldMetadataId === - toFlatFieldMetadata.id; + const { flatSearchVectorFieldToUpdate } = recomputeSearchVectorOnFieldRename({ + fromFlatFieldMetadata, + toFlatFieldMetadata, + flatObjectMetadata, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + }); - if (isLabelIdentifierFieldMetadata) { - const flatSearchVectorFieldToUpdate = - handleLabelIdentifierChangesDuringFieldUpdate({ - fromFlatFieldMetadata, - toFlatFieldMetadata, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - if (isDefined(flatSearchVectorFieldToUpdate)) { - sideEffectResult.flatFieldMetadatasToUpdate.push( - flatSearchVectorFieldToUpdate, - ); - } + if (isDefined(flatSearchVectorFieldToUpdate)) { + sideEffectResult.flatFieldMetadatasToUpdate.push( + flatSearchVectorFieldToUpdate, + ); } const { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-label-identifier-changes-during-field-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-label-identifier-changes-during-field-update.util.ts deleted file mode 100644 index 755a67a98a..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-label-identifier-changes-during-field-update.util.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type FieldMetadataType, type FromTo } from 'twenty-shared/types'; -import { findOrThrow, type SearchableFieldType } from 'twenty-shared/utils'; - -import { - FieldMetadataException, - FieldMetadataExceptionCode, -} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; -import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; -import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; - -type HandleLabelIdentifierChangesDuringFieldUpdateArgs = { - flatObjectMetadata: FlatObjectMetadata; -} & FromTo & - Pick; - -export const handleLabelIdentifierChangesDuringFieldUpdate = ({ - fromFlatFieldMetadata, - toFlatFieldMetadata, - flatObjectMetadata, - flatFieldMetadataMaps, -}: HandleLabelIdentifierChangesDuringFieldUpdateArgs): - | FlatFieldMetadata - | undefined => { - const hasNameChanged = - fromFlatFieldMetadata.name !== toFlatFieldMetadata.name; - - if (!hasNameChanged) { - return undefined; - } - - const objectFlatFieldMetadatas = - findManyFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatFieldMetadataMaps, - flatEntityIds: flatObjectMetadata.fieldIds, - }); - - const searchVectorField = findOrThrow( - objectFlatFieldMetadatas, - (field) => field.name === SEARCH_VECTOR_FIELD.name, - new FieldMetadataException( - `Search vector field not found for object metadata ${flatObjectMetadata.id}`, - FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, - ), - ) as FlatFieldMetadata; - - try { - const newAsExpression = getTsVectorColumnExpressionFromFields([ - { - name: toFlatFieldMetadata.name, - type: toFlatFieldMetadata.type as SearchableFieldType, - }, - ]); - - return { - ...searchVectorField, - universalSettings: { - ...searchVectorField.universalSettings, - asExpression: newAsExpression, - }, - }; - } catch { - throw new FieldMetadataException( - `Failed to compute search vector column expression for field ${toFlatFieldMetadata.name}`, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - ); - } -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util.ts new file mode 100644 index 0000000000..f6691ad6db --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util.ts @@ -0,0 +1,68 @@ +import { type FieldMetadataType, type FromTo } from 'twenty-shared/types'; + +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { recomputeSearchVectorFieldFromSearchFieldMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-from-search-field-metadatas.util'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; + +export type FieldMetadataUpdateSearchVectorSideEffect = { + flatSearchVectorFieldToUpdate: + | FlatFieldMetadata + | undefined; +}; + +type RecomputeSearchVectorOnFieldRenameArgs = { + flatObjectMetadata: FlatObjectMetadata; +} & FromTo & + Pick< + AllFlatEntityMaps, + 'flatFieldMetadataMaps' | 'flatSearchFieldMetadataMaps' + >; + +const EMPTY_SIDE_EFFECT: FieldMetadataUpdateSearchVectorSideEffect = { + flatSearchVectorFieldToUpdate: undefined, +}; + +export const recomputeSearchVectorOnFieldRename = ({ + fromFlatFieldMetadata, + toFlatFieldMetadata, + flatObjectMetadata, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, +}: RecomputeSearchVectorOnFieldRenameArgs): FieldMetadataUpdateSearchVectorSideEffect => { + if (!flatObjectMetadata.isSearchable) { + return EMPTY_SIDE_EFFECT; + } + + const objectSearchFieldMetadatas = + findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityMaps: flatSearchFieldMetadataMaps, + flatEntityIds: flatObjectMetadata.searchFieldMetadataIds, + }); + + const isIndexedField = objectSearchFieldMetadatas.some( + (searchFieldMetadata) => + searchFieldMetadata.fieldMetadataId === toFlatFieldMetadata.id, + ); + + const hasNameChanged = + fromFlatFieldMetadata.name !== toFlatFieldMetadata.name; + + if (!isIndexedField || !hasNameChanged) { + return EMPTY_SIDE_EFFECT; + } + + return { + flatSearchVectorFieldToUpdate: + recomputeSearchVectorFieldFromSearchFieldMetadatas({ + flatObjectMetadata, + flatFieldMetadataMaps, + searchFieldMetadatas: objectSearchFieldMetadatas, + overrideFlatFieldMetadataById: new Map([ + [toFlatFieldMetadata.id, toFlatFieldMetadata], + ]), + }), + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts index b2f340c6da..d1b3ae3347 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts @@ -20,6 +20,7 @@ export const getFlatObjectMetadataMock = ( return { viewIds: [], indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], fieldIds: [], @@ -55,6 +56,7 @@ export const getFlatObjectMetadataMock = ( fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: labelIdentifierFieldMetadataId, imageIdentifierFieldMetadataUniversalIdentifier: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/services/workspace-flat-object-metadata-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/services/workspace-flat-object-metadata-map-cache.service.ts index d57b07b9d6..5aff7149df 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/services/workspace-flat-object-metadata-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/services/workspace-flat-object-metadata-map-cache.service.ts @@ -14,6 +14,7 @@ import { fromObjectMetadataEntityToFlatObjectMetadata } from 'src/engine/metadat import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @@ -40,6 +41,8 @@ export class WorkspaceFlatObjectMetadataMapCacheService extends WorkspaceCachePr private readonly viewRepository: WorkspaceScopedRepository, @InjectWorkspaceScopedRepository(ObjectPermissionEntity) private readonly objectPermissionRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(SearchFieldMetadataEntity) + private readonly searchFieldMetadataRepository: WorkspaceScopedRepository, ) { super(); } @@ -54,6 +57,7 @@ export class WorkspaceFlatObjectMetadataMapCacheService extends WorkspaceCachePr indexMetadatas, views, objectPermissions, + searchFieldMetadatas, ] = await Promise.all([ this.objectMetadataRepository.find({ where: { workspaceId }, @@ -81,6 +85,9 @@ export class WorkspaceFlatObjectMetadataMapCacheService extends WorkspaceCachePr select: ['id', 'universalIdentifier', 'objectMetadataId'], withDeleted: true, }), + this.searchFieldMetadataRepository.find(workspaceId, { + select: ['id', 'universalIdentifier', 'objectMetadataId'], + }), ]); const [ @@ -88,6 +95,7 @@ export class WorkspaceFlatObjectMetadataMapCacheService extends WorkspaceCachePr indexesByObjectId, viewsByObjectId, objectPermissionsByObjectId, + searchFieldMetadatasByObjectId, ] = ( [ { @@ -106,6 +114,10 @@ export class WorkspaceFlatObjectMetadataMapCacheService extends WorkspaceCachePr entities: objectPermissions, foreignKey: 'objectMetadataId', }, + { + entities: searchFieldMetadatas, + foreignKey: 'objectMetadataId', + }, ] as const ).map(regroupEntitiesByRelatedEntityId); @@ -125,6 +137,8 @@ export class WorkspaceFlatObjectMetadataMapCacheService extends WorkspaceCachePr views: viewsByObjectId.get(objectMetadataEntity.id) || [], objectPermissions: objectPermissionsByObjectId.get(objectMetadataEntity.id) || [], + searchFieldMetadatas: + searchFieldMetadatasByObjectId.get(objectMetadataEntity.id) || [], }, applicationIdToUniversalIdentifierMap, fieldMetadataIdToUniversalIdentifierMap, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts index b22c52ee8a..101263732f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts @@ -10,9 +10,11 @@ import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metad import { buildDefaultFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; import { buildDefaultIndexesForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-index-for-custom-object.util'; import { buildDefaultRelationFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util'; +import { buildDefaultSearchFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; type FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs = { @@ -29,6 +31,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre relationTargetFlatFieldMetadataToCreate: UniversalFlatFieldMetadata[]; flatFieldMetadataToCreateOnObject: UniversalFlatFieldMetadata[]; flatIndexMetadataToCreate: UniversalFlatIndexMetadata[]; + flatSearchFieldMetadataToCreate: UniversalFlatSearchFieldMetadata[]; } => { const createObjectInput = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( @@ -94,6 +97,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier, imageIdentifierFieldMetadataUniversalIdentifier: null, }; @@ -119,12 +123,20 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre flatObjectMetadata: universalFlatObjectMetadataToCreate, }); + const defaultSearchFieldMetadatasForCustomObject = + buildDefaultSearchFieldMetadatasForCustomObject({ + defaultFlatFieldForCustomObjectMaps, + flatObjectMetadata: universalFlatObjectMetadataToCreate, + }); + return { flatObjectMetadataToCreate: universalFlatObjectMetadataToCreate, flatIndexMetadataToCreate: [ ...Object.values(defaultIndexesForCustomObject.indexes), ...standardTargetFlatIndexMetadatas, ], + flatSearchFieldMetadataToCreate: + defaultSearchFieldMetadatasForCustomObject.searchFieldMetadatas, relationTargetFlatFieldMetadataToCreate: standardTargetFlatFieldMetadatas, flatFieldMetadataToCreateOnObject: objectFlatFieldMetadatas, }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts index 57144209d9..0c12b796ac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts @@ -77,6 +77,9 @@ export const fromObjectMetadataEntityToFlatObjectMetadata = ({ updatedAt: objectMetadataEntity.updatedAt.toISOString(), viewIds: objectMetadataEntity.views.map(({ id }) => id), indexMetadataIds: objectMetadataEntity.indexMetadatas.map(({ id }) => id), + searchFieldMetadataIds: objectMetadataEntity.searchFieldMetadatas.map( + ({ id }) => id, + ), fieldIds: objectMetadataEntity.fields.map(({ id }) => id), objectPermissionIds: objectMetadataEntity.objectPermissions.map( ({ id }) => id, @@ -92,6 +95,10 @@ export const fromObjectMetadataEntityToFlatObjectMetadata = ({ indexMetadataUniversalIdentifiers: objectMetadataEntity.indexMetadatas.map( ({ universalIdentifier }) => universalIdentifier, ), + searchFieldMetadataUniversalIdentifiers: + objectMetadataEntity.searchFieldMetadatas?.map( + ({ universalIdentifier }) => universalIdentifier, + ) ?? [], viewUniversalIdentifiers: objectMetadataEntity.views.map( ({ universalIdentifier }) => universalIdentifier, ), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts index 8849cd7f6f..bbff2b9ac0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts @@ -30,6 +30,7 @@ type FromUpdateObjectInputToFlatObjectMetadataArgs = { | 'flatFieldMetadataMaps' | 'flatViewFieldMaps' | 'flatViewMaps' + | 'flatSearchFieldMetadataMaps' >; export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = @@ -40,6 +41,7 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = flatFieldMetadataMaps, flatViewFieldMaps, flatViewMaps, + flatSearchFieldMetadataMaps, }: FromUpdateObjectInputToFlatObjectMetadataArgs): FlatObjectMetadataUpdateSideEffects & { flatObjectMetadataToUpdate: UniversalFlatObjectMetadata; } => { @@ -101,6 +103,7 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = flatViewFieldsToUpdate, otherObjectFlatFieldMetadatasToUpdate, sameObjectFlatFieldMetadatasToUpdate, + searchFieldMetadatasToCreate, } = handleFlatObjectMetadataUpdateSideEffect({ fromFlatObjectMetadata: existingFlatObjectMetadata, toFlatObjectMetadata, @@ -109,6 +112,7 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = flatIndexMaps, flatViewFieldMaps, flatViewMaps, + flatSearchFieldMetadataMaps, }); return { @@ -118,5 +122,6 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = flatViewFieldsToUpdate, otherObjectFlatFieldMetadatasToUpdate, sameObjectFlatFieldMetadatasToUpdate, + searchFieldMetadatasToCreate, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts index ed15d533a2..7bd20b0beb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts @@ -5,11 +5,12 @@ import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { recomputeIndexAfterFlatObjectMetadataSingularNameUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-index-after-flat-object-metadata-singular-name-update.util'; -import { recomputeSearchVectorFieldAfterLabelIdentifierUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-after-label-identifier-update.util'; +import { recomputeSearchVectorOnLabelIdentifierUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util'; import { recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util'; import { renameRelatedMorphFieldOnObjectNamesUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/rename-related-morph-field-on-object-names-update.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; export type FlatObjectMetadataUpdateSideEffects = { @@ -18,6 +19,7 @@ export type FlatObjectMetadataUpdateSideEffects = { flatViewFieldsToUpdate: UniversalFlatViewField[]; flatViewFieldsToCreate: UniversalFlatViewField[]; flatIndexMetadatasToUpdate: UniversalFlatIndexMetadata[]; + searchFieldMetadatasToCreate: UniversalFlatSearchFieldMetadata[]; }; type HandleFlatObjectMetadataUpdateSideEffectArgs = FromTo< @@ -31,6 +33,7 @@ type HandleFlatObjectMetadataUpdateSideEffectArgs = FromTo< | 'flatViewFieldMaps' | 'flatIndexMaps' | 'flatViewMaps' + | 'flatSearchFieldMetadataMaps' >; export const handleFlatObjectMetadataUpdateSideEffect = ({ @@ -39,6 +42,7 @@ export const handleFlatObjectMetadataUpdateSideEffect = ({ flatObjectMetadataMaps, flatViewFieldMaps, flatViewMaps, + flatSearchFieldMetadataMaps, fromFlatObjectMetadata, toFlatObjectMetadata, }: HandleFlatObjectMetadataUpdateSideEffectArgs): FlatObjectMetadataUpdateSideEffects => { @@ -87,25 +91,16 @@ export const handleFlatObjectMetadataUpdateSideEffect = ({ const sameObjectFlatFieldMetadatasToUpdate: FlatFieldMetadata[] = []; - if ( - toFlatObjectMetadata.isSearchable && - isDefined( - toFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, - ) && - fromFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier !== - toFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier - ) { - const updatedSearchVectorField = - recomputeSearchVectorFieldAfterLabelIdentifierUpdate({ - existingFlatObjectMetadata: fromFlatObjectMetadata, - flatFieldMetadataMaps, - labelIdentifierFieldMetadataUniversalIdentifier: - toFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, - }); + const { flatSearchVectorFieldToUpdate, searchFieldMetadatasToCreate } = + recomputeSearchVectorOnLabelIdentifierUpdate({ + fromFlatObjectMetadata, + toFlatObjectMetadata, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + }); - if (isDefined(updatedSearchVectorField)) { - sameObjectFlatFieldMetadatasToUpdate.push(updatedSearchVectorField); - } + if (isDefined(flatSearchVectorFieldToUpdate)) { + sameObjectFlatFieldMetadatasToUpdate.push(flatSearchVectorFieldToUpdate); } return { @@ -117,5 +112,6 @@ export const handleFlatObjectMetadataUpdateSideEffect = ({ flatViewFieldsToUpdate, otherObjectFlatFieldMetadatasToUpdate: morphFlatFieldMetadatasToUpdate, sameObjectFlatFieldMetadatasToUpdate, + searchFieldMetadatasToCreate, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-after-label-identifier-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-after-label-identifier-update.util.ts deleted file mode 100644 index 8e798fe9ac..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-after-label-identifier-update.util.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type FieldMetadataType } from 'twenty-shared/types'; -import { - findOrThrow, - isDefined, - type SearchableFieldType, -} from 'twenty-shared/utils'; - -import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; -import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { - ObjectMetadataException, - ObjectMetadataExceptionCode, -} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; -import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; -import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; - -type RecomputeSearchVectorFieldAfterLabelIdentifierUpdateArgs = { - existingFlatObjectMetadata: FlatObjectMetadata; - labelIdentifierFieldMetadataUniversalIdentifier: string; -} & Pick; - -export const recomputeSearchVectorFieldAfterLabelIdentifierUpdate = ({ - existingFlatObjectMetadata, - flatFieldMetadataMaps, - labelIdentifierFieldMetadataUniversalIdentifier, -}: RecomputeSearchVectorFieldAfterLabelIdentifierUpdateArgs): - | FlatFieldMetadata - | undefined => { - const objectFlatFieldMetadatas = - findManyFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatFieldMetadataMaps, - flatEntityIds: existingFlatObjectMetadata.fieldIds, - }); - - const searchVectorField = findOrThrow( - objectFlatFieldMetadatas, - (field) => field.name === SEARCH_VECTOR_FIELD.name, - ) as FlatFieldMetadata; - - const newLabelIdentifierField = findFlatEntityByUniversalIdentifier({ - flatEntityMaps: flatFieldMetadataMaps, - universalIdentifier: labelIdentifierFieldMetadataUniversalIdentifier, - }); - - if (!isDefined(newLabelIdentifierField)) { - throw new ObjectMetadataException( - `New label identifier field not found for object metadata`, - ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - ); - } - - try { - const newAsExpression = getTsVectorColumnExpressionFromFields([ - { - name: newLabelIdentifierField.name, - type: newLabelIdentifierField.type as SearchableFieldType, - }, - ]); - - return { - ...searchVectorField, - universalSettings: { - ...searchVectorField.universalSettings, - asExpression: newAsExpression, - generatedType: 'STORED', - }, - }; - } catch { - throw new ObjectMetadataException( - `Failed to compute search vector column expression for field ${newLabelIdentifierField.name}`, - ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - ); - } -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-from-search-field-metadatas.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-from-search-field-metadatas.util.ts new file mode 100644 index 0000000000..14f7eebcb0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-from-search-field-metadatas.util.ts @@ -0,0 +1,112 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { + buildSearchVectorTargetField, + computeSearchVectorAsExpressionFromSearchFieldMetadatas, +} from 'src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; + +type RecomputeSearchVectorFieldFromSearchFieldMetadatasArgs = { + flatObjectMetadata: FlatObjectMetadata; + // Post-change searchFieldMetadata rows for the object, carrying the field id and the + // per-object position that drives the deterministic asExpression order. + searchFieldMetadatas: Pick< + FlatSearchFieldMetadata, + 'fieldMetadataId' | 'position' | 'universalIdentifier' + >[]; + // Takes precedence over the flat maps when resolving an id — for a field created or + // renamed in the same migration, whose maps entry is absent or stale. + overrideFlatFieldMetadataById?: Map; +} & Pick; + +export const recomputeSearchVectorFieldFromSearchFieldMetadatas = ({ + flatObjectMetadata, + flatFieldMetadataMaps, + searchFieldMetadatas, + overrideFlatFieldMetadataById, +}: RecomputeSearchVectorFieldFromSearchFieldMetadatasArgs): + | FlatFieldMetadata + | undefined => { + const objectFlatFieldMetadatas = + findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityIds: flatObjectMetadata.fieldIds, + }); + + const searchVectorField = objectFlatFieldMetadatas.find( + (field) => field.name === SEARCH_VECTOR_FIELD.name, + ); + + if ( + !isDefined(searchVectorField) || + !isFlatFieldMetadataOfType(searchVectorField, FieldMetadataType.TS_VECTOR) + ) { + throw new ObjectMetadataException( + `Search vector field not found for object metadata ${flatObjectMetadata.id}`, + ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, + ); + } + + const targetSearchableFields = searchFieldMetadatas.map( + (searchFieldMetadata) => { + const { fieldMetadataId, position, universalIdentifier } = + searchFieldMetadata; + + const overriddenFlatFieldMetadata = + overrideFlatFieldMetadataById?.get(fieldMetadataId); + + const flatFieldMetadata = + overriddenFlatFieldMetadata ?? + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(flatFieldMetadata)) { + throw new ObjectMetadataException( + `Field metadata ${fieldMetadataId} referenced by searchFieldMetadata not found for object metadata ${flatObjectMetadata.id}`, + ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, + ); + } + + return buildSearchVectorTargetField( + flatFieldMetadata, + position, + universalIdentifier, + ); + }, + ); + + try { + const newAsExpression = + computeSearchVectorAsExpressionFromSearchFieldMetadatas( + targetSearchableFields, + ); + + return { + ...searchVectorField, + universalSettings: { + ...searchVectorField.universalSettings, + asExpression: newAsExpression, + generatedType: 'STORED', + }, + }; + } catch (error) { + throw new ObjectMetadataException( + `Failed to compute search vector column expression for object metadata ${flatObjectMetadata.id}: ${error instanceof Error ? error.message : String(error)}`, + ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util.ts new file mode 100644 index 0000000000..e33cbf60dc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util.ts @@ -0,0 +1,113 @@ +import { type FieldMetadataType, type FromTo } from 'twenty-shared/types'; +import { isDefined, isSearchableFieldType } from 'twenty-shared/utils'; + +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { recomputeSearchVectorFieldFromSearchFieldMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-field-from-search-field-metadatas.util'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; + +export type LabelIdentifierUpdateSearchVectorSideEffect = { + flatSearchVectorFieldToUpdate: + | FlatFieldMetadata + | undefined; + searchFieldMetadatasToCreate: UniversalFlatSearchFieldMetadata[]; +}; + +type RecomputeSearchVectorOnLabelIdentifierUpdateArgs = FromTo< + FlatObjectMetadata, + 'flatObjectMetadata' +> & + Pick< + AllFlatEntityMaps, + 'flatFieldMetadataMaps' | 'flatSearchFieldMetadataMaps' + >; + +const EMPTY_SIDE_EFFECT: LabelIdentifierUpdateSearchVectorSideEffect = { + flatSearchVectorFieldToUpdate: undefined, + searchFieldMetadatasToCreate: [], +}; + +// Relabeling is additive: it indexes the new label identifier field while preserving the +// existing search surface (e.g. the provisioned name row). We never drop a row here so the +// previous label identifier stays searchable. Removal of fields from the search surface is +// deferred to a follow-up PR. +export const recomputeSearchVectorOnLabelIdentifierUpdate = ({ + fromFlatObjectMetadata, + toFlatObjectMetadata, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, +}: RecomputeSearchVectorOnLabelIdentifierUpdateArgs): LabelIdentifierUpdateSearchVectorSideEffect => { + const newLabelIdentifierFieldMetadataId = + toFlatObjectMetadata.labelIdentifierFieldMetadataId; + + if ( + !toFlatObjectMetadata.isSearchable || + !isDefined(newLabelIdentifierFieldMetadataId) || + fromFlatObjectMetadata.labelIdentifierFieldMetadataId === + newLabelIdentifierFieldMetadataId + ) { + return EMPTY_SIDE_EFFECT; + } + + const existingSearchFieldMetadatas = + findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityMaps: flatSearchFieldMetadataMaps, + flatEntityIds: fromFlatObjectMetadata.searchFieldMetadataIds, + }); + + const newLabelIdentifierAlreadyIndexed = existingSearchFieldMetadatas.some( + (searchFieldMetadata) => + searchFieldMetadata.fieldMetadataId === newLabelIdentifierFieldMetadataId, + ); + + const newLabelIdentifierField = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: newLabelIdentifierFieldMetadataId, + }); + + const shouldIndexNewLabelIdentifier = + isDefined(newLabelIdentifierField) && + isSearchableFieldType(newLabelIdentifierField.type) && + !newLabelIdentifierAlreadyIndexed; + + if (!shouldIndexNewLabelIdentifier) { + return EMPTY_SIDE_EFFECT; + } + + const newLabelIdentifierPosition = + existingSearchFieldMetadatas.reduce( + (maxPosition, searchFieldMetadata) => + Math.max(maxPosition, searchFieldMetadata.position), + -1, + ) + 1; + + const newSearchFieldMetadata = buildFlatSearchFieldMetadataForField({ + flatObjectMetadata: fromFlatObjectMetadata, + flatFieldMetadata: newLabelIdentifierField, + position: newLabelIdentifierPosition, + }); + + const nextSearchFieldMetadatas = [ + ...existingSearchFieldMetadatas, + { + fieldMetadataId: newLabelIdentifierFieldMetadataId, + position: newLabelIdentifierPosition, + universalIdentifier: newSearchFieldMetadata.universalIdentifier, + }, + ]; + + return { + flatSearchVectorFieldToUpdate: + recomputeSearchVectorFieldFromSearchFieldMetadatas({ + flatObjectMetadata: fromFlatObjectMetadata, + flatFieldMetadataMaps, + searchFieldMetadatas: nextSearchFieldMetadatas, + }), + searchFieldMetadatasToCreate: [newSearchFieldMetadata], + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/services/workspace-flat-search-field-metadata-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/services/workspace-flat-search-field-metadata-map-cache.service.ts new file mode 100644 index 0000000000..2618f7ce39 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/services/workspace-flat-search-field-metadata-map-cache.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { FlatSearchFieldMetadataMaps } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata-maps.type'; +import { fromSearchFieldMetadataEntityToFlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/from-search-field-metadata-entity-to-flat-search-field-metadata.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util'; +import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; + +@Injectable() +@WorkspaceCache('flatSearchFieldMetadataMaps') +export class WorkspaceFlatSearchFieldMetadataMapCacheService extends WorkspaceCacheProvider { + constructor( + @InjectWorkspaceScopedRepository(SearchFieldMetadataEntity) + private readonly searchFieldMetadataRepository: WorkspaceScopedRepository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(ObjectMetadataEntity) + private readonly objectMetadataRepository: Repository, + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + ) { + super(); + } + + async computeForCache( + workspaceId: string, + ): Promise { + const [ + existingSearchFieldMetadatas, + applications, + objectMetadatas, + fieldMetadatas, + ] = await Promise.all([ + this.searchFieldMetadataRepository.find(workspaceId), + this.applicationRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.objectMetadataRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.fieldMetadataRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + ]); + + const applicationIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(applications); + const objectMetadataIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(objectMetadatas); + const fieldMetadataIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(fieldMetadatas); + + const flatSearchFieldMetadataMaps = createEmptyFlatEntityMaps(); + + for (const searchFieldMetadata of existingSearchFieldMetadatas) { + const flatSearchFieldMetadata = + fromSearchFieldMetadataEntityToFlatSearchFieldMetadata({ + entity: searchFieldMetadata, + applicationIdToUniversalIdentifierMap, + objectMetadataIdToUniversalIdentifierMap, + fieldMetadataIdToUniversalIdentifierMap, + }); + + addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ + flatEntity: flatSearchFieldMetadata, + flatEntityMapsToMutate: flatSearchFieldMetadataMaps, + }); + } + + return flatSearchFieldMetadataMaps; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata-maps.type.ts new file mode 100644 index 0000000000..24cc0f7211 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata-maps.type.ts @@ -0,0 +1,5 @@ +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; + +export type FlatSearchFieldMetadataMaps = + FlatEntityMaps; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type.ts new file mode 100644 index 0000000000..ed64ff5812 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type.ts @@ -0,0 +1,4 @@ +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type'; +import { type SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; + +export type FlatSearchFieldMetadata = FlatEntityFrom; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts new file mode 100644 index 0000000000..51250533d7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts @@ -0,0 +1,30 @@ +import { v4 } from 'uuid'; + +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; + +export const buildFlatSearchFieldMetadataForField = ({ + flatObjectMetadata, + flatFieldMetadata, + position, +}: { + flatObjectMetadata: Pick< + FlatObjectMetadata, + 'applicationUniversalIdentifier' | 'universalIdentifier' + >; + flatFieldMetadata: { universalIdentifier: string }; + position: number; +}): UniversalFlatSearchFieldMetadata => { + const createdAt = new Date().toISOString(); + + return { + universalIdentifier: v4(), + createdAt, + updatedAt: createdAt, + position, + applicationUniversalIdentifier: + flatObjectMetadata.applicationUniversalIdentifier, + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + fieldMetadataUniversalIdentifier: flatFieldMetadata.universalIdentifier, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util.ts new file mode 100644 index 0000000000..89e7407ee7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util.ts @@ -0,0 +1,53 @@ +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isSearchableFieldType } from 'twenty-shared/utils'; + +import { + type FieldTypeAndNameMetadata, + getTsVectorColumnExpressionFromFields, +} from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; + +export type SearchVectorTargetField = { + name: string; + type: FieldMetadataType; + // Per-object ordinal from the searchFieldMetadata row, driving deterministic order. + position: number; + // Tie-break for rows sharing a position (searchFieldMetadata universalIdentifier). + sortKey: string; +}; + +export const buildSearchVectorTargetField = ( + field: { name: string; type: FieldMetadataType }, + position: number, + sortKey: string, +): SearchVectorTargetField => ({ + name: field.name, + type: field.type, + position, + sortKey, +}); + +// Builds the searchVector to_tsvector asExpression from an object's targeted fields. +// Callers MUST pass the POST-change field set (account for rows added/removed in the +// same operation), not re-read stale maps. Ordering is deterministic only to minimize +// asExpression churn — tsvector matching is order-insensitive. +export const computeSearchVectorAsExpressionFromSearchFieldMetadatas = ( + targetSearchableFields: SearchVectorTargetField[], +): string => { + const orderedSearchableFields: FieldTypeAndNameMetadata[] = [ + ...targetSearchableFields, + ] + .sort((a, b) => { + if (a.position !== b.position) { + return a.position - b.position; + } + + return a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0; + }) + .flatMap((targetField) => + isSearchableFieldType(targetField.type) + ? [{ name: targetField.name, type: targetField.type }] + : [], + ); + + return getTsVectorColumnExpressionFromFields(orderedSearchableFields); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/from-search-field-metadata-entity-to-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/from-search-field-metadata-entity-to-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..54d54ad237 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/from-search-field-metadata-entity-to-flat-search-field-metadata.util.ts @@ -0,0 +1,68 @@ +import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils'; + +import { + FlatEntityMapsException, + FlatEntityMapsExceptionCode, +} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import type { FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type'; + +export const fromSearchFieldMetadataEntityToFlatSearchFieldMetadata = ({ + entity: searchFieldMetadataEntity, + applicationIdToUniversalIdentifierMap, + objectMetadataIdToUniversalIdentifierMap, + fieldMetadataIdToUniversalIdentifierMap, +}: FromEntityToFlatEntityArgs<'searchFieldMetadata'>): FlatSearchFieldMetadata => { + const searchFieldMetadataEntityWithoutRelations = removePropertiesFromRecord( + searchFieldMetadataEntity, + getMetadataEntityRelationProperties('searchFieldMetadata'), + ); + + const applicationUniversalIdentifier = + applicationIdToUniversalIdentifierMap.get( + searchFieldMetadataEntity.applicationId, + ); + + if (!isDefined(applicationUniversalIdentifier)) { + throw new FlatEntityMapsException( + `Application with id ${searchFieldMetadataEntity.applicationId} not found for searchFieldMetadata ${searchFieldMetadataEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const objectMetadataUniversalIdentifier = + objectMetadataIdToUniversalIdentifierMap.get( + searchFieldMetadataEntity.objectMetadataId, + ); + + if (!isDefined(objectMetadataUniversalIdentifier)) { + throw new FlatEntityMapsException( + `Object metadata with id ${searchFieldMetadataEntity.objectMetadataId} not found for searchFieldMetadata ${searchFieldMetadataEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const fieldMetadataUniversalIdentifier = + fieldMetadataIdToUniversalIdentifierMap.get( + searchFieldMetadataEntity.fieldMetadataId, + ); + + if (!isDefined(fieldMetadataUniversalIdentifier)) { + throw new FlatEntityMapsException( + `Field metadata with id ${searchFieldMetadataEntity.fieldMetadataId} not found for searchFieldMetadata ${searchFieldMetadataEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + return { + ...searchFieldMetadataEntityWithoutRelations, + createdAt: searchFieldMetadataEntity.createdAt.toISOString(), + updatedAt: searchFieldMetadataEntity.updatedAt.toISOString(), + universalIdentifier: + searchFieldMetadataEntityWithoutRelations.universalIdentifier, + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index 9721c55a4e..072c576983 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -12,6 +12,7 @@ import { import { type WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto'; import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; @@ -142,6 +143,15 @@ export class ObjectMetadataEntity }) indexMetadatas: Relation; + @OneToMany( + () => SearchFieldMetadataEntity, + (searchFieldMetadata) => searchFieldMetadata.objectMetadata, + { + cascade: true, + }, + ) + searchFieldMetadatas: Relation; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index b6a05c4697..74f76c5201 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -89,6 +89,7 @@ export class ObjectMetadataService extends TypeOrmQueryService { + const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField; + + if (!isDefined(nameField)) { + return { + searchFieldMetadatas: [], + }; + } + + const nameSearchFieldMetadata = buildFlatSearchFieldMetadataForField({ + flatObjectMetadata, + flatFieldMetadata: nameField, + position: 0, + }); + + return { + searchFieldMetadatas: [nameSearchFieldMetadata], + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/exceptions/search-field-metadata.exception.ts b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/exceptions/search-field-metadata.exception.ts new file mode 100644 index 0000000000..c7b87bfd4c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/exceptions/search-field-metadata.exception.ts @@ -0,0 +1,6 @@ +export enum SearchFieldMetadataExceptionCode { + SEARCH_FIELD_METADATA_NOT_FOUND = 'SEARCH_FIELD_METADATA_NOT_FOUND', + INVALID_SEARCH_FIELD_METADATA_DATA = 'INVALID_SEARCH_FIELD_METADATA_DATA', + OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND', + FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts index 1c84143d95..10dcbc08af 100644 --- a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts @@ -13,23 +13,27 @@ import { import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; -@Entity('searchFieldMetadata') +@Entity({ name: 'searchFieldMetadata', schema: 'core' }) @Unique('IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE', [ 'objectMetadataId', 'fieldMetadataId', ]) @Index('IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID', ['workspaceId']) @Index('IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID', ['objectMetadataId']) -export class SearchFieldMetadataEntity extends WorkspaceRelatedEntity { +export class SearchFieldMetadataEntity extends SyncableEntity { @PrimaryGeneratedColumn('uuid') id: string; @Column({ nullable: false, type: 'uuid' }) objectMetadataId: string; - @ManyToOne(() => ObjectMetadataEntity, { onDelete: 'CASCADE' }) + @ManyToOne( + () => ObjectMetadataEntity, + (objectMetadata) => objectMetadata.searchFieldMetadatas, + { onDelete: 'CASCADE' }, + ) @JoinColumn({ name: 'objectMetadataId' }) objectMetadata: Relation; @@ -40,6 +44,9 @@ export class SearchFieldMetadataEntity extends WorkspaceRelatedEntity { @JoinColumn({ name: 'fieldMetadataId' }) fieldMetadata: Relation; + @Column({ nullable: false, type: 'float' }) + position: number; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 2b3a39af63..3bc418145f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -109,6 +109,7 @@ describe('WorkspaceEntityManager', () => { targetTableName: 'test_entity', fieldIds: ['field-id'], indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], viewIds: [], @@ -131,6 +132,7 @@ describe('WorkspaceEntityManager', () => { fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, }; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts index 3e7ad4e630..421d8bfdb3 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts @@ -25,6 +25,7 @@ describe('getColumnNameToFieldMetadataIdMap', () => { workspaceId: 'test-workspace-id', universalIdentifier: 'test-object-id', indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], fieldIds, @@ -47,6 +48,7 @@ describe('getColumnNameToFieldMetadataIdMap', () => { fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, }); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts index aad3c6e3f3..f775821b4c 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts @@ -25,6 +25,7 @@ describe('getFieldMetadataIdToColumnNamesMap', () => { workspaceId: 'test-workspace-id', universalIdentifier: 'test-object-id', indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], fieldIds, @@ -47,6 +48,7 @@ describe('getFieldMetadataIdToColumnNamesMap', () => { fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, }); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts index 27f29c781f..9ee1c32b7d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts @@ -25,6 +25,7 @@ describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => { workspaceId: 'test-workspace-id', universalIdentifier: 'test-object-id', indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], fieldIds, @@ -47,6 +48,7 @@ describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => { fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, }); diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index ba4b5b8a73..6977699b78 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -27,6 +27,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatViewFilterMaps: 'flat-maps:view-filter', flatViewFilterGroupMaps: 'flat-maps:view-filter-group', flatViewSortMaps: 'flat-maps:view-sort', + flatSearchFieldMetadataMaps: 'flat-maps:search-field-metadata', flatLogicFunctionMaps: 'flat-maps:logic-function', featureFlagsMap: 'feature-flag:feature-flags-map', rolesPermissions: 'metadata:permissions:roles-permissions', diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts index 3b700d3121..6916621296 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts @@ -2,6 +2,7 @@ import { type AllMetadataName } from 'twenty-shared/metadata'; export const TWENTY_STANDARD_ALL_METADATA_NAME = [ 'index', + 'searchFieldMetadata', 'objectMetadata', 'fieldMetadata', 'viewField', diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts index 6ebc8c9549..d6602ff865 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts @@ -106,6 +106,7 @@ export const createStandardObjectFlatMetadata = < targetTableName: 'DEPRECATED', fieldIds: [], indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], viewIds: [], @@ -119,6 +120,7 @@ export const createStandardObjectFlatMetadata = < fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier, imageIdentifierFieldMetadataUniversalIdentifier, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/build-standard-flat-search-field-metadata-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/build-standard-flat-search-field-metadata-maps.util.ts new file mode 100644 index 0000000000..418c9c4f28 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/build-standard-flat-search-field-metadata-maps.util.ts @@ -0,0 +1,64 @@ +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-name.type'; +import { buildCompanyStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-company-standard-flat-search-field-metadata.util'; +import { buildDashboardStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-dashboard-standard-flat-search-field-metadata.util'; +import { buildMessageListStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-message-list-standard-flat-search-field-metadata.util'; +import { buildNoteStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-note-standard-flat-search-field-metadata.util'; +import { buildOpportunityStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-opportunity-standard-flat-search-field-metadata.util'; +import { buildPersonStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-person-standard-flat-search-field-metadata.util'; +import { buildTaskStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-task-standard-flat-search-field-metadata.util'; +import { buildWorkflowStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workflow-standard-flat-search-field-metadata.util'; +import { buildWorkspaceMemberStandardFlatSearchFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workspace-member-standard-flat-search-field-metadata.util'; +import { type CreateStandardSearchFieldArgs } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +type StandardSearchFieldBuilder

= ( + args: Omit, 'context'>, +) => FlatSearchFieldMetadata[]; + +// Only isSearchable objects get rows; each object's field set mirrors its searchVector +// asExpression, built from the same SEARCH_FIELDS_FOR_* constant. +const STANDARD_FLAT_SEARCH_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME = { + company: buildCompanyStandardFlatSearchFieldMetadatas, + dashboard: buildDashboardStandardFlatSearchFieldMetadatas, + messageList: buildMessageListStandardFlatSearchFieldMetadatas, + note: buildNoteStandardFlatSearchFieldMetadatas, + opportunity: buildOpportunityStandardFlatSearchFieldMetadatas, + person: buildPersonStandardFlatSearchFieldMetadatas, + task: buildTaskStandardFlatSearchFieldMetadatas, + workflow: buildWorkflowStandardFlatSearchFieldMetadatas, + workspaceMember: buildWorkspaceMemberStandardFlatSearchFieldMetadatas, +} satisfies { + [P in AllStandardObjectName]?: StandardSearchFieldBuilder

; +}; + +export const buildStandardFlatSearchFieldMetadataMaps = ( + args: Omit, +): FlatEntityMaps => { + const allSearchFieldMetadatas: FlatSearchFieldMetadata[] = ( + Object.keys( + STANDARD_FLAT_SEARCH_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME, + ) as (keyof typeof STANDARD_FLAT_SEARCH_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME)[] + ).flatMap((objectName) => { + const builder: StandardSearchFieldBuilder = + STANDARD_FLAT_SEARCH_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME[objectName]; + + return builder({ + ...args, + objectName, + }); + }); + + let flatSearchFieldMetadataMaps = createEmptyFlatEntityMaps(); + + for (const searchFieldMetadata of allSearchFieldMetadatas) { + flatSearchFieldMetadataMaps = addFlatEntityToFlatEntityMapsOrThrow({ + flatEntity: searchFieldMetadata, + flatEntityMaps: flatSearchFieldMetadataMaps, + }); + } + + return flatSearchFieldMetadataMaps; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-company-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-company-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..8c2f9a7d54 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-company-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,34 @@ +import { SEARCH_FIELDS_FOR_COMPANY } from 'src/modules/company/standard-objects/company.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildCompanyStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'company'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_COMPANY.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: searchField.name as AllStandardObjectFieldName<'company'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-dashboard-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-dashboard-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..16b9e62214 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-dashboard-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,34 @@ +import { SEARCH_FIELDS_FOR_DASHBOARD } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildDashboardStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'dashboard'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_DASHBOARD.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: searchField.name as AllStandardObjectFieldName<'dashboard'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-message-list-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-message-list-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..0678d99e44 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-message-list-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,35 @@ +import { SEARCH_FIELDS_FOR_MESSAGE_LIST } from 'src/modules/emailing/standard-objects/message-list.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildMessageListStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'messageList'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_MESSAGE_LIST.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: + searchField.name as AllStandardObjectFieldName<'messageList'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-note-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-note-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..a89cd61ce4 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-note-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,34 @@ +import { SEARCH_FIELDS_FOR_NOTES } from 'src/modules/note/standard-objects/note.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildNoteStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'note'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_NOTES.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: searchField.name as AllStandardObjectFieldName<'note'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-opportunity-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-opportunity-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..6d7a916bf1 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-opportunity-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,35 @@ +import { SEARCH_FIELDS_FOR_OPPORTUNITY } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildOpportunityStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'opportunity'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_OPPORTUNITY.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: + searchField.name as AllStandardObjectFieldName<'opportunity'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-person-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-person-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..37686a3133 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-person-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,34 @@ +import { SEARCH_FIELDS_FOR_PERSON } from 'src/modules/person/standard-objects/person.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildPersonStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'person'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_PERSON.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: searchField.name as AllStandardObjectFieldName<'person'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-task-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-task-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..0196ca45b9 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-task-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,34 @@ +import { SEARCH_FIELDS_FOR_TASKS } from 'src/modules/task/standard-objects/task.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildTaskStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'task'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_TASKS.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: searchField.name as AllStandardObjectFieldName<'task'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workflow-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workflow-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..86e10afc58 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workflow-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,34 @@ +import { SEARCH_FIELDS_FOR_WORKFLOWS } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildWorkflowStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'workflow'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_WORKFLOWS.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: searchField.name as AllStandardObjectFieldName<'workflow'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workspace-member-standard-flat-search-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workspace-member-standard-flat-search-field-metadata.util.ts new file mode 100644 index 0000000000..19820aa6ab --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/compute-workspace-member-standard-flat-search-field-metadata.util.ts @@ -0,0 +1,35 @@ +import { SEARCH_FIELDS_FOR_WORKSPACE_MEMBER } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { + type CreateStandardSearchFieldArgs, + createStandardSearchFieldFlatMetadata, +} from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util'; + +export const buildWorkspaceMemberStandardFlatSearchFieldMetadatas = ({ + now, + objectName, + workspaceId, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, +}: Omit< + CreateStandardSearchFieldArgs<'workspaceMember'>, + 'context' +>): FlatSearchFieldMetadata[] => + SEARCH_FIELDS_FOR_WORKSPACE_MEMBER.map((searchField, position) => + createStandardSearchFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: + searchField.name as AllStandardObjectFieldName<'workspaceMember'>, + position, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts new file mode 100644 index 0000000000..1e7b62f96c --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts @@ -0,0 +1,74 @@ +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { v4 } from 'uuid'; + +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type'; +import { type AllStandardObjectName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-name.type'; +import { type StandardBuilderArgs } from 'src/engine/workspace-manager/twenty-standard-application/types/metadata-standard-buillder-args.type'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; + +export type CreateStandardSearchFieldOptions = + { + fieldName: AllStandardObjectFieldName; + position: number; + }; + +export type CreateStandardSearchFieldArgs< + O extends AllStandardObjectName = AllStandardObjectName, +> = StandardBuilderArgs<'searchFieldMetadata'> & { + objectName: O; + context: CreateStandardSearchFieldOptions; +}; + +export const createStandardSearchFieldFlatMetadata = < + O extends AllStandardObjectName, +>({ + workspaceId, + objectName, + context: { fieldName, position }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps: { flatFieldMetadataMaps, flatObjectMetadataMaps }, + twentyStandardApplicationId, + now, +}: CreateStandardSearchFieldArgs): FlatSearchFieldMetadata => { + const objectFields = STANDARD_OBJECTS[objectName].fields; + + const objectMetadataId = + standardObjectMetadataRelatedEntityIds[objectName].id; + const fieldMetadataId = + standardObjectMetadataRelatedEntityIds[objectName].fields[fieldName].id; + + const objectMetadataUniversalIdentifier = + STANDARD_OBJECTS[objectName].universalIdentifier; + const flatObjectMetadata = + findFlatEntityByUniversalIdentifierOrThrow({ + universalIdentifier: objectMetadataUniversalIdentifier, + flatEntityMaps: flatObjectMetadataMaps, + }); + + const fieldMetadataUniversalIdentifier = + objectFields[fieldName as keyof typeof objectFields].universalIdentifier; + const flatFieldMetadata = + findFlatEntityByUniversalIdentifierOrThrow({ + universalIdentifier: fieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }); + + return { + id: v4(), + universalIdentifier: v4(), + applicationId: twentyStandardApplicationId, + applicationUniversalIdentifier: + flatObjectMetadata.applicationUniversalIdentifier, + objectMetadataId, + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + fieldMetadataId, + fieldMetadataUniversalIdentifier: flatFieldMetadata.universalIdentifier, + position, + workspaceId, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts index 89134ed89c..f41c7cbc59 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts @@ -17,6 +17,7 @@ import { buildStandardFlatPageLayoutWidgetMetadataMaps } from 'src/engine/worksp import { buildStandardFlatPageLayoutMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout/build-standard-flat-page-layout-metadata-maps.util'; import { buildStandardFlatPermissionFlagMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/permission-flag/build-standard-flat-permission-flag-metadata-maps.util'; import { buildStandardFlatRoleMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/role-metadata/build-standard-flat-role-metadata-maps.util'; +import { buildStandardFlatSearchFieldMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/build-standard-flat-search-field-metadata-maps.util'; import { buildStandardFlatSkillMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/build-standard-flat-skill-metadata-maps.util'; import { buildStandardFlatViewFieldMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/build-standard-flat-view-field-metadata-maps.util'; import { buildStandardFlatViewFieldGroupMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field-group/build-standard-flat-view-field-group-metadata-maps.util'; @@ -74,6 +75,17 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({ twentyStandardApplicationId, }); + const flatSearchFieldMetadataMaps = buildStandardFlatSearchFieldMetadataMaps({ + dependencyFlatEntityMaps: { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }, + now, + standardObjectMetadataRelatedEntityIds, + workspaceId, + twentyStandardApplicationId, + }); + const flatViewMaps = buildStandardFlatViewMetadataMaps({ dependencyFlatEntityMaps: { flatFieldMetadataMaps, @@ -217,6 +229,7 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({ flatViewGroupMaps, flatViewMaps, flatIndexMaps, + flatSearchFieldMetadataMaps, flatFieldMetadataMaps, flatObjectMetadataMaps, flatNavigationMenuItemMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts index 535b6c699b..b69ca891ab 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts @@ -304,7 +304,8 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ case 'fieldPermission': case 'webhook': case 'applicationVariable': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { if (!isDefined(idByUniversalIdentifier)) { return action; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 94808e85fc..eb69b39ac1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -44,6 +44,7 @@ import { WorkspaceMigrationRoleTargetActionsBuilderService } from 'src/engine/wo import { WorkspaceMigrationRoleActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/role/workspace-migration-role-actions-builder.service'; import { WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/row-level-permission-predicate-group/workspace-migration-row-level-permission-predicate-group-actions-builder.service'; import { WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/row-level-permission-predicate/workspace-migration-row-level-permission-predicate-actions-builder.service'; +import { WorkspaceMigrationSearchFieldMetadataActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/workspace-migration-search-field-metadata-actions.builder.service'; import { WorkspaceMigrationSkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/skill/workspace-migration-skill-actions-builder.service'; import { WorkspaceMigrationViewFieldGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field-group/workspace-migration-view-field-group-actions-builder.service'; import { WorkspaceMigrationViewFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/workspace-migration-view-field-actions-builder.service'; @@ -156,6 +157,7 @@ export class WorkspaceMigrationBuildOrchestratorService { workspaceMigrationWebhookActionsBuilderService: WorkspaceMigrationWebhookActionsBuilderService, workspaceMigrationApplicationVariableActionsBuilderService: WorkspaceMigrationApplicationVariableActionsBuilderService, workspaceMigrationConnectionProviderActionsBuilderService: WorkspaceMigrationConnectionProviderActionsBuilderService, + workspaceMigrationSearchFieldMetadataActionsBuilderService: WorkspaceMigrationSearchFieldMetadataActionsBuilderService, ) { // The order of this array defines the execution order of the per-entity // builders. Each builder may mutate `optimisticAllFlatEntityMaps`, so @@ -204,6 +206,10 @@ export class WorkspaceMigrationBuildOrchestratorService { ALL_METADATA_NAME.viewSort, workspaceMigrationViewSortActionsBuilderService, ), + createEntityActionsBuilderTask( + ALL_METADATA_NAME.searchFieldMetadata, + workspaceMigrationSearchFieldMetadataActionsBuilderService, + ), createEntityActionsBuilderTask( ALL_METADATA_NAME.logicFunction, workspaceMigrationLogicFunctionActionsBuilderService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap index 3ef9799ef5..9afcfe8106 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap @@ -26,6 +26,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc "objectPermissionUniversalIdentifiers", "fieldPermissionUniversalIdentifiers", "viewUniversalIdentifiers", + "searchFieldMetadataUniversalIdentifiers", ], "objectPermission": [], "pageLayout": [ @@ -53,6 +54,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc "childRowLevelPermissionPredicateGroupUniversalIdentifiers", "rowLevelPermissionPredicateUniversalIdentifiers", ], + "searchFieldMetadata": [], "skill": [], "view": [ "viewFieldUniversalIdentifiers", diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts index fb50a07742..c1687b4fd0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts @@ -49,6 +49,7 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = { frontComponent: {}, applicationVariable: {}, connectionProvider: {}, + searchFieldMetadata: {}, } as const satisfies { [P in AllMetadataName]: Partial<{ // TODO prastoin: improve strict typing to recursively serach for nested SerializedRelation diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type.ts new file mode 100644 index 0000000000..0afc68c4ea --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type.ts @@ -0,0 +1,7 @@ +import { type SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; +import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type'; + +export type UniversalFlatSearchFieldMetadata = UniversalFlatEntityFrom< + SearchFieldMetadataEntity, + 'searchFieldMetadata' +>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts index 234f1cb873..6be0002d4e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts @@ -6,6 +6,7 @@ export const computeOrderedMigrationActions = ( ): AllUniversalWorkspaceMigrationAction[] => { return [ // Object and fields and indexes + ...aggregatedOrchestratorActionsReport.searchFieldMetadata.delete, ...aggregatedOrchestratorActionsReport.index.delete, ...aggregatedOrchestratorActionsReport.fieldMetadata.delete, ...aggregatedOrchestratorActionsReport.objectMetadata.delete, @@ -15,6 +16,8 @@ export const computeOrderedMigrationActions = ( ...aggregatedOrchestratorActionsReport.fieldMetadata.update, ...aggregatedOrchestratorActionsReport.index.create, ...aggregatedOrchestratorActionsReport.index.update.flat(), + ...aggregatedOrchestratorActionsReport.searchFieldMetadata.create, + ...aggregatedOrchestratorActionsReport.searchFieldMetadata.update, /// // Views diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type.ts new file mode 100644 index 0000000000..130c39b36a --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type.ts @@ -0,0 +1,24 @@ +import { type BaseFlatCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-create-workspace-migration-action.type'; +import { type BaseFlatUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-update-workspace-migration-action.type'; +import { type BaseFlatDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-delete-workspace-migration-action.type'; +import { type BaseUniversalCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-create-workspace-migration-action.type'; +import { type BaseUniversalUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type'; +import { type BaseUniversalDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type'; + +export type FlatCreateSearchFieldMetadataAction = + BaseFlatCreateWorkspaceMigrationAction<'searchFieldMetadata'>; + +export type FlatUpdateSearchFieldMetadataAction = + BaseFlatUpdateWorkspaceMigrationAction<'searchFieldMetadata'>; + +export type FlatDeleteSearchFieldMetadataAction = + BaseFlatDeleteWorkspaceMigrationAction<'searchFieldMetadata'>; + +export type UniversalCreateSearchFieldMetadataAction = + BaseUniversalCreateWorkspaceMigrationAction<'searchFieldMetadata'>; + +export type UniversalUpdateSearchFieldMetadataAction = + BaseUniversalUpdateWorkspaceMigrationAction<'searchFieldMetadata'>; + +export type UniversalDeleteSearchFieldMetadataAction = + BaseUniversalDeleteWorkspaceMigrationAction<'searchFieldMetadata'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/workspace-migration-search-field-metadata-actions.builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/workspace-migration-search-field-metadata-actions.builder.service.ts new file mode 100644 index 0000000000..ac7cbfeea4 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/workspace-migration-search-field-metadata-actions.builder.service.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UniversalUpdateSearchFieldMetadataAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; +import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; +import { UniversalFlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-result.type'; +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { FlatSearchFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-search-field-metadata-validator.service'; + +@Injectable() +export class WorkspaceMigrationSearchFieldMetadataActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.searchFieldMetadata +> { + constructor( + private readonly flatSearchFieldMetadataValidatorService: FlatSearchFieldMetadataValidatorService, + ) { + super(ALL_METADATA_NAME.searchFieldMetadata); + } + + protected validateFlatEntityCreation( + args: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.searchFieldMetadata + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.searchFieldMetadata, + 'create' + > { + const validationResult = + this.flatSearchFieldMetadataValidatorService.validateFlatSearchFieldMetadataCreation( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatSearchFieldMetadataToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'searchFieldMetadata', + flatEntity: flatSearchFieldMetadataToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.searchFieldMetadata + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.searchFieldMetadata, + 'delete' + > { + const validationResult = + this.flatSearchFieldMetadataValidatorService.validateFlatSearchFieldMetadataDeletion( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatSearchFieldMetadataToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'searchFieldMetadata', + universalIdentifier: + flatSearchFieldMetadataToValidate.universalIdentifier, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.searchFieldMetadata + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.searchFieldMetadata, + 'update' + > { + const validationResult = + this.flatSearchFieldMetadataValidatorService.validateFlatSearchFieldMetadataUpdate( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { universalIdentifier, flatEntityUpdate } = args; + + const updateSearchFieldMetadataAction: UniversalUpdateSearchFieldMetadataAction = + { + type: 'update', + metadataName: 'searchFieldMetadata', + universalIdentifier, + update: flatEntityUpdate, + }; + + return { + status: 'success', + action: updateSearchFieldMetadataAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-search-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-search-field-metadata-validator.service.ts new file mode 100644 index 0000000000..0dd8d06a68 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-search-field-metadata-validator.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +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 { SearchFieldMetadataExceptionCode } from 'src/engine/metadata-modules/search-field-metadata/exceptions/search-field-metadata.exception'; +import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; +import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; + +@Injectable() +export class FlatSearchFieldMetadataValidatorService { + constructor() {} + + validateFlatSearchFieldMetadataCreation({ + flatEntityToValidate: flatSearchFieldMetadataToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatSearchFieldMetadataMaps: optimisticFlatSearchFieldMetadataMaps, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }, + }: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.searchFieldMetadata + >): FailedFlatEntityValidation<'searchFieldMetadata', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier: + flatSearchFieldMetadataToValidate.universalIdentifier, + objectMetadataUniversalIdentifier: + flatSearchFieldMetadataToValidate.objectMetadataUniversalIdentifier, + }, + metadataName: 'searchFieldMetadata', + type: 'create', + }); + + const existingFlatSearchFieldMetadata = findFlatEntityByUniversalIdentifier( + { + universalIdentifier: + flatSearchFieldMetadataToValidate.universalIdentifier, + flatEntityMaps: optimisticFlatSearchFieldMetadataMaps, + }, + ); + + if (isDefined(existingFlatSearchFieldMetadata)) { + const searchFieldMetadataId = + flatSearchFieldMetadataToValidate.universalIdentifier; + + validationResult.errors.push({ + code: SearchFieldMetadataExceptionCode.INVALID_SEARCH_FIELD_METADATA_DATA, + message: t`Search field metadata with id ${searchFieldMetadataId} already exists`, + userFriendlyMessage: msg`Search field metadata already exists`, + }); + } + + const flatFieldMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + flatSearchFieldMetadataToValidate.fieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(flatFieldMetadata)) { + validationResult.errors.push({ + code: SearchFieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + message: t`Field metadata not found`, + userFriendlyMessage: msg`Field metadata not found`, + }); + } + + const flatObjectMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + flatSearchFieldMetadataToValidate.objectMetadataUniversalIdentifier, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(flatObjectMetadata)) { + validationResult.errors.push({ + code: SearchFieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + message: t`Object metadata not found`, + userFriendlyMessage: msg`Object metadata not found`, + }); + + return validationResult; + } + + const otherFlatSearchFieldMetadatas = + findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow({ + universalIdentifiers: + flatObjectMetadata.searchFieldMetadataUniversalIdentifiers, + flatEntityMaps: optimisticFlatSearchFieldMetadataMaps, + }); + + const equivalentExistingFlatSearchFieldMetadataExists = + otherFlatSearchFieldMetadatas.some( + (flatSearchFieldMetadata) => + flatSearchFieldMetadata.universalIdentifier !== + flatSearchFieldMetadataToValidate.universalIdentifier && + flatSearchFieldMetadata.fieldMetadataUniversalIdentifier === + flatSearchFieldMetadataToValidate.fieldMetadataUniversalIdentifier, + ); + + if (equivalentExistingFlatSearchFieldMetadataExists) { + validationResult.errors.push({ + code: SearchFieldMetadataExceptionCode.INVALID_SEARCH_FIELD_METADATA_DATA, + message: t`Search field metadata with same fieldMetadataId and objectMetadataId already exists`, + userFriendlyMessage: msg`Search field metadata already exists`, + }); + } + + return validationResult; + } + + public validateFlatSearchFieldMetadataUpdate({ + universalIdentifier, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatSearchFieldMetadataMaps: optimisticFlatSearchFieldMetadataMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.searchFieldMetadata + >): FailedFlatEntityValidation<'searchFieldMetadata', 'update'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier, + }, + metadataName: 'searchFieldMetadata', + type: 'update', + }); + + const existingFlatSearchFieldMetadata = findFlatEntityByUniversalIdentifier( + { + universalIdentifier, + flatEntityMaps: optimisticFlatSearchFieldMetadataMaps, + }, + ); + + if (!isDefined(existingFlatSearchFieldMetadata)) { + validationResult.errors.push({ + code: SearchFieldMetadataExceptionCode.SEARCH_FIELD_METADATA_NOT_FOUND, + message: t`Search field metadata to update not found`, + userFriendlyMessage: msg`Search field metadata to update not found`, + }); + + return validationResult; + } + + return validationResult; + } + + public validateFlatSearchFieldMetadataDeletion({ + flatEntityToValidate: { universalIdentifier }, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatSearchFieldMetadataMaps: optimisticFlatSearchFieldMetadataMaps, + }, + }: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.searchFieldMetadata + >): FailedFlatEntityValidation<'searchFieldMetadata', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier, + }, + metadataName: 'searchFieldMetadata', + type: 'delete', + }); + + const existingFlatSearchFieldMetadata = findFlatEntityByUniversalIdentifier( + { + universalIdentifier, + flatEntityMaps: optimisticFlatSearchFieldMetadataMaps, + }, + ); + + if (!isDefined(existingFlatSearchFieldMetadata)) { + validationResult.errors.push({ + code: SearchFieldMetadataExceptionCode.SEARCH_FIELD_METADATA_NOT_FOUND, + message: t`Search field metadata to delete not found`, + userFriendlyMessage: msg`Search field metadata to delete not found`, + }); + + return validationResult; + } + + return validationResult; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts index f5b0939fb3..f3a18247ac 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts @@ -33,6 +33,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace import { FlatApplicationVariableValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-application-variable-validator.service'; import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service'; import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service'; +import { FlatSearchFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-search-field-metadata-validator.service'; @Module({ imports: [FeatureFlagModule], @@ -69,6 +70,7 @@ import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-man FlatWebhookValidatorService, FlatApplicationVariableValidatorService, FlatConnectionProviderValidatorService, + FlatSearchFieldMetadataValidatorService, ], exports: [ FlatViewValidatorService, @@ -102,6 +104,7 @@ import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-man FlatWebhookValidatorService, FlatApplicationVariableValidatorService, FlatConnectionProviderValidatorService, + FlatSearchFieldMetadataValidatorService, ], }) export class WorkspaceMigrationBuilderValidatorsModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts index b4b69ec6e9..fb7406e2b6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts @@ -32,6 +32,7 @@ import { WorkspaceMigrationViewActionsBuilderService } from 'src/engine/workspac import { WorkspaceMigrationApplicationVariableActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/application-variable/workspace-migration-application-variable-actions-builder.service'; import { WorkspaceMigrationWebhookActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service'; import { WorkspaceMigrationConnectionProviderActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service'; +import { WorkspaceMigrationSearchFieldMetadataActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/workspace-migration-search-field-metadata-actions.builder.service'; import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module'; @Module({ @@ -68,6 +69,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationWebhookActionsBuilderService, WorkspaceMigrationApplicationVariableActionsBuilderService, WorkspaceMigrationConnectionProviderActionsBuilderService, + WorkspaceMigrationSearchFieldMetadataActionsBuilderService, ], exports: [ WorkspaceMigrationViewActionsBuilderService, @@ -101,6 +103,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationWebhookActionsBuilderService, WorkspaceMigrationApplicationVariableActionsBuilderService, WorkspaceMigrationConnectionProviderActionsBuilderService, + WorkspaceMigrationSearchFieldMetadataActionsBuilderService, ], }) export class WorkspaceMigrationBuilderModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts index 15abb65fda..98a04494f1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts @@ -117,6 +117,7 @@ export const fromUniversalFlatObjectMetadataToFlatObjectMetadata = ({ fieldIds: [], viewIds: [], indexMetadataIds: [], + searchFieldMetadataIds: [], objectPermissionIds: [], fieldPermissionIds: [], ...emptyUniversalForeignKeyAggregators, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/create-search-field-metadata-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/create-search-field-metadata-action-handler.service.ts new file mode 100644 index 0000000000..01946b39ab --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/create-search-field-metadata-action-handler.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; + +import { v4 } from 'uuid'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { + FlatCreateSearchFieldMetadataAction, + UniversalCreateSearchFieldMetadataAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type'; +import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateSearchFieldMetadataActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'searchFieldMetadata', +) { + override async transpileUniversalActionToFlatAction({ + action, + allFlatEntityMaps, + flatApplication, + workspaceId, + }: WorkspaceMigrationActionRunnerArgs): Promise { + const { objectMetadataId, fieldMetadataId } = + resolveUniversalRelationIdentifiersToIds({ + flatEntityMaps: allFlatEntityMaps, + metadataName: action.metadataName, + universalForeignKeyValues: action.flatEntity, + }); + + return { + ...action, + flatEntity: { + ...action.flatEntity, + objectMetadataId, + fieldMetadataId, + id: action.id ?? v4(), + applicationId: flatApplication.id, + workspaceId, + }, + }; + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner } = context; + const { flatEntity } = flatAction; + + await this.insertFlatEntitiesInRepository({ + queryRunner, + flatEntities: [flatEntity], + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/delete-search-field-metadata-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/delete-search-field-metadata-action-handler.service.ts new file mode 100644 index 0000000000..a1d1fb34a7 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/delete-search-field-metadata-action-handler.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; +import { + FlatDeleteSearchFieldMetadataAction, + UniversalDeleteSearchFieldMetadataAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteSearchFieldMetadataActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'searchFieldMetadata', +) { + constructor() { + super(); + } + + override async transpileUniversalActionToFlatAction( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return this.transpileUniversalDeleteActionToFlatDeleteAction(context); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner, workspaceId } = context; + + const searchFieldMetadataRepository = + queryRunner.manager.getRepository( + SearchFieldMetadataEntity, + ); + + await searchFieldMetadataRepository.delete({ + id: flatAction.entityId, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/update-search-field-metadata-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/update-search-field-metadata-action-handler.service.ts new file mode 100644 index 0000000000..253c1f33be --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/update-search-field-metadata-action-handler.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util'; +import { + FlatUpdateSearchFieldMetadataAction, + UniversalUpdateSearchFieldMetadataAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/search-field-metadata/types/workspace-migration-search-field-metadata-action.type'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class UpdateSearchFieldMetadataActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'searchFieldMetadata', +) { + constructor() { + super(); + } + + override async transpileUniversalActionToFlatAction( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, allFlatEntityMaps } = context; + + const flatSearchFieldMetadata = findFlatEntityByUniversalIdentifierOrThrow({ + flatEntityMaps: allFlatEntityMaps.flatSearchFieldMetadataMaps, + universalIdentifier: action.universalIdentifier, + }); + + const update = resolveUniversalUpdateRelationIdentifiersToIds({ + metadataName: 'searchFieldMetadata', + universalUpdate: action.update, + allFlatEntityMaps, + }); + + return { + type: 'update', + metadataName: 'searchFieldMetadata', + entityId: flatSearchFieldMetadata.id, + update, + }; + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner, workspaceId } = context; + const { entityId, update } = flatAction; + + const searchFieldMetadataRepository = + queryRunner.manager.getRepository( + SearchFieldMetadataEntity, + ); + + await searchFieldMetadataRepository.update( + { id: entityId, workspaceId }, + update, + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index 134276e311..8fa05ac9eb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -94,6 +94,9 @@ import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/ import { CreateConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service'; import { DeleteConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service'; import { UpdateConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service'; +import { CreateSearchFieldMetadataActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/create-search-field-metadata-action-handler.service'; +import { DeleteSearchFieldMetadataActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/delete-search-field-metadata-action-handler.service'; +import { UpdateSearchFieldMetadataActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/search-field-metadata/services/update-search-field-metadata-action-handler.service'; @Module({ imports: [ @@ -221,6 +224,10 @@ import { UpdateConnectionProviderActionHandlerService } from 'src/engine/workspa CreateConnectionProviderActionHandlerService, UpdateConnectionProviderActionHandlerService, DeleteConnectionProviderActionHandlerService, + + CreateSearchFieldMetadataActionHandlerService, + UpdateSearchFieldMetadataActionHandlerService, + DeleteSearchFieldMetadataActionHandlerService, ], }) export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts index 24597d7984..5d86f3c000 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts @@ -31,4 +31,5 @@ export const METADATA_EVENTS_TO_EMIT = { webhook: false, applicationVariable: false, connectionProvider: true, + searchFieldMetadata: true, } as const satisfies { [P in AllMetadataName]: boolean }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts index cfeb6849bb..60435f88c2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts @@ -98,7 +98,8 @@ const deriveAllMetadataEventsFromCreateAction = ( case 'fieldPermission': case 'viewSort': case 'webhook': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { return [ { type: 'created', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts index 2d9d59c41f..3ab5f114d4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts @@ -62,7 +62,8 @@ const deriveAllMetadataEventsFromDeleteAction = ({ case 'fieldPermission': case 'viewSort': case 'webhook': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< MetadataFlatEntity >({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts index 5eb2f5fff2..5ccde47af6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts @@ -140,7 +140,8 @@ const deriveAllMetadataEventsFromUpdateAction = ({ case 'fieldPermission': case 'viewSort': case 'webhook': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( flatAction.metadataName, ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts index d583d27b05..77d0ef6847 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts @@ -74,7 +74,8 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = ({ case 'fieldPermission': case 'webhook': case 'applicationVariable': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({ flatEntity: flatAction.flatEntity, flatEntityAndRelatedMapsToMutate: allFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts index a479fe30de..d7ef88fad8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts @@ -46,7 +46,8 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = ({ case 'fieldPermission': case 'webhook': case 'applicationVariable': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< MetadataFlatEntity >({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts index a094581e79..a40c055060 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts @@ -67,7 +67,8 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = ({ case 'fieldPermission': case 'webhook': case 'applicationVariable': - case 'connectionProvider': { + case 'connectionProvider': + case 'searchFieldMetadata': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( flatAction.metadataName, ); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-and-delete-field-metadata-search-vector-side-effect.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-and-delete-field-metadata-search-vector-side-effect.integration-spec.ts new file mode 100644 index 0000000000..65bf5ef30d --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-and-delete-field-metadata-search-vector-side-effect.integration-spec.ts @@ -0,0 +1,178 @@ +import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util'; +import { search } from 'test/integration/graphql/utils/search.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; + +// ISO search surface: a custom object's searchVector indexes the name field only. Creating or +// deleting an additional searchable field must NOT change the search surface through the API. +describe('Field metadata create/delete - search vector ISO surface', () => { + let testObjectMetadataId: string; + let extraFieldMetadataId: string; + + const OBJECT_NAME_SINGULAR = 'searchSurfaceObject'; + const OBJECT_NAME_PLURAL = 'searchSurfaceObjects'; + const EXTRA_FIELD_NAME = 'extraSearchableField'; + const RECORD_NAME_VALUE = 'SearchSurfaceNameToken11'; + const RECORD_EXTRA_VALUE = 'SearchSurfaceExtraToken22'; + + const getSearchVectorAsExpression = async (): Promise => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: { id: { eq: testObjectMetadataId } }, + paging: { first: 1 }, + }, + gqlFields: ` + id + nameSingular + fieldsList { + id + name + type + settings + } + `, + }); + + const testObject = objects[0]; + + jestExpectToBeDefined(testObject); + jestExpectToBeDefined(testObject.fieldsList); + + const searchVectorField = testObject.fieldsList.find( + (field: FieldMetadataDTO) => field.type === FieldMetadataType.TS_VECTOR, + ); + + jestExpectToBeDefined(searchVectorField); + + const settings = searchVectorField.settings as { + asExpression?: string; + }; + + jestExpectToBeDefined(settings); + jestExpectToBeDefined(settings.asExpression); + + return settings.asExpression; + }; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: OBJECT_NAME_SINGULAR, + namePlural: OBJECT_NAME_PLURAL, + labelSingular: 'Search Surface Object', + labelPlural: 'Search Surface Objects', + icon: 'IconSearch', + isLabelSyncedWithName: false, + }, + }); + + testObjectMetadataId = objectMetadataId; + }); + + afterAll(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: testObjectMetadataId, + updatePayload: { isActive: false }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testObjectMetadataId }, + }); + }); + + it('should not add a newly created searchable field to the search vector or to global search', async () => { + const { + data: { + createOneField: { id: extraFieldId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: EXTRA_FIELD_NAME, + label: 'Extra Searchable Field', + type: FieldMetadataType.TEXT, + objectMetadataId: testObjectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: `id name type`, + }); + + extraFieldMetadataId = extraFieldId; + + const asExpressionAfterCreate = await getSearchVectorAsExpression(); + + expect(asExpressionAfterCreate).not.toContain(EXTRA_FIELD_NAME); + + await createManyOperation({ + objectMetadataSingularName: OBJECT_NAME_SINGULAR, + objectMetadataPluralName: OBJECT_NAME_PLURAL, + gqlFields: `id name ${EXTRA_FIELD_NAME}`, + data: [ + { + name: RECORD_NAME_VALUE, + [EXTRA_FIELD_NAME]: RECORD_EXTRA_VALUE, + }, + ], + expectToFail: false, + }); + + // The name field is indexed, so the record is reachable through its name value. + const searchByName = await search({ + searchInput: RECORD_NAME_VALUE, + includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], + limit: 10, + expectToFail: false, + }); + + expect(searchByName.data.search.edges.length).toBe(1); + + // The extra field is outside the search surface, so its value is not searchable. + const searchByExtraField = await search({ + searchInput: RECORD_EXTRA_VALUE, + includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], + limit: 10, + expectToFail: false, + }); + + expect(searchByExtraField.data.search.edges.length).toBe(0); + }); + + it('should keep the search vector unchanged after the extra field is deleted', async () => { + // Custom fields must be deactivated before deletion. + await updateOneFieldMetadata({ + input: { + idToUpdate: extraFieldMetadataId, + updatePayload: { isActive: false }, + }, + gqlFields: `id`, + expectToFail: false, + }); + + await deleteOneFieldMetadata({ + input: { idToDelete: extraFieldMetadataId }, + expectToFail: false, + }); + + const asExpressionAfterDelete = await getSearchVectorAsExpression(); + + expect(asExpressionAfterDelete).not.toContain(EXTRA_FIELD_NAME); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts index cc618c78c3..cc54c365d5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts @@ -1,6 +1,5 @@ import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util'; import { search } from 'test/integration/graphql/utils/search.util'; -import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; @@ -11,125 +10,24 @@ import { FieldMetadataType } from 'twenty-shared/types'; import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; -describe('Field metadata update - search vector side effect', () => { +// ISO search surface: renaming an indexed field (the name field, the default search field of a +// custom object) must recompute the searchVector asExpression so global search keeps working, +// since the expression embeds the field's column name. +describe('Field metadata update - search vector rename recompute', () => { let testObjectMetadataId: string; - let testFieldMetadataId: string; + let nameFieldMetadataId: string; let createdRecordId: string; - const OBJECT_NAME_SINGULAR = 'searchVectorTestObject'; - const OBJECT_NAME_PLURAL = 'searchVectorTestObjects'; - const INITIAL_FIELD_NAME = 'searchableTitle'; - const UPDATED_FIELD_NAME = 'renamedTitle'; - const RECORD_FIELD_VALUE = 'UniqueSearchableValue123'; - const RECORD_NAME_FIELD_VALUE = 'NameValue'; - - beforeAll(async () => { - const { - data: { - createOneObject: { id: objectMetadataId }, - }, - } = await createOneObjectMetadata({ - expectToFail: false, - input: { - nameSingular: OBJECT_NAME_SINGULAR, - namePlural: OBJECT_NAME_PLURAL, - labelSingular: 'Search Vector Test Object', - labelPlural: 'Search Vector Test Objects', - icon: 'IconSearch', - isLabelSyncedWithName: false, - }, - }); - - testObjectMetadataId = objectMetadataId; - - const { - data: { - createOneField: { id: fieldMetadataId }, - }, - } = await createOneFieldMetadata({ - expectToFail: false, - input: { - name: INITIAL_FIELD_NAME, - label: 'Searchable Title', - type: FieldMetadataType.TEXT, - objectMetadataId: testObjectMetadataId, - isLabelSyncedWithName: false, - }, - gqlFields: ` - id - name - label - `, - }); - - testFieldMetadataId = fieldMetadataId; - - await updateOneObjectMetadata({ - input: { - idToUpdate: testObjectMetadataId, - updatePayload: { - labelIdentifierFieldMetadataId: testFieldMetadataId, - }, - }, - expectToFail: false, - }); - - const { data } = await createManyOperation({ - objectMetadataSingularName: OBJECT_NAME_SINGULAR, - objectMetadataPluralName: OBJECT_NAME_PLURAL, - gqlFields: `id name ${INITIAL_FIELD_NAME}`, - data: [ - { - [INITIAL_FIELD_NAME]: RECORD_FIELD_VALUE, - name: RECORD_NAME_FIELD_VALUE, - }, - ], - expectToFail: false, - }); - - createdRecordId = data.createdRecords[0].id; - }); - - afterAll(async () => { - await updateOneObjectMetadata({ - expectToFail: false, - input: { - idToUpdate: testObjectMetadataId, - updatePayload: { - isActive: false, - }, - }, - }); - await deleteOneObjectMetadata({ - expectToFail: false, - input: { idToDelete: testObjectMetadataId }, - }); - }); - - it('should update search vector asExpression when renaming a field used as label identifier and search should work with new field name', async () => { - await updateOneFieldMetadata({ - input: { - idToUpdate: testFieldMetadataId, - updatePayload: { - name: UPDATED_FIELD_NAME, - label: 'Renamed Title', - isLabelSyncedWithName: false, - }, - }, - gqlFields: ` - id - name - label - `, - expectToFail: false, - }); + const OBJECT_NAME_SINGULAR = 'searchVectorRenameObject'; + const OBJECT_NAME_PLURAL = 'searchVectorRenameObjects'; + const RENAMED_NAME_FIELD = 'searchableLabelColumn'; + const RECORD_NAME_VALUE = 'RenameRecomputeSearchToken33'; + const getSearchVectorAsExpression = async (): Promise => { const { objects } = await findManyObjectMetadata({ expectToFail: false, input: { - filter: { - id: { eq: testObjectMetadataId }, - }, + filter: { id: { eq: testObjectMetadataId } }, paging: { first: 1 }, }, gqlFields: ` @@ -144,9 +42,6 @@ describe('Field metadata update - search vector side effect', () => { `, }); - expect(objects).toBeDefined(); - expect(objects.length).toBe(1); - const testObject = objects[0]; jestExpectToBeDefined(testObject); @@ -160,24 +55,105 @@ describe('Field metadata update - search vector side effect', () => { const settings = searchVectorField.settings as { asExpression?: string; - generatedType?: string; }; jestExpectToBeDefined(settings); - expect(settings.asExpression).toBeDefined(); - expect(settings.asExpression).toContain(UPDATED_FIELD_NAME); - expect(settings.asExpression).not.toContain(INITIAL_FIELD_NAME); + jestExpectToBeDefined(settings.asExpression); + + return settings.asExpression; + }; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: OBJECT_NAME_SINGULAR, + namePlural: OBJECT_NAME_PLURAL, + labelSingular: 'Search Vector Rename Object', + labelPlural: 'Search Vector Rename Objects', + icon: 'IconSearch', + isLabelSyncedWithName: false, + }, + }); + + testObjectMetadataId = objectMetadataId; + + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: { id: { eq: testObjectMetadataId } }, + paging: { first: 1 }, + }, + gqlFields: ` + id + fieldsList { + id + name + } + `, + }); + + const nameField = objects[0]?.fieldsList?.find( + (field: FieldMetadataDTO) => field.name === 'name', + ); + + jestExpectToBeDefined(nameField); + nameFieldMetadataId = nameField.id; + + const { data } = await createManyOperation({ + objectMetadataSingularName: OBJECT_NAME_SINGULAR, + objectMetadataPluralName: OBJECT_NAME_PLURAL, + gqlFields: `id name`, + data: [{ name: RECORD_NAME_VALUE }], + expectToFail: false, + }); + + createdRecordId = data.createdRecords[0].id; + }); + + afterAll(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: testObjectMetadataId, + updatePayload: { isActive: false }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testObjectMetadataId }, + }); + }); + + it('should recompute the search vector and keep search working when the indexed name field is renamed', async () => { + await updateOneFieldMetadata({ + input: { + idToUpdate: nameFieldMetadataId, + updatePayload: { + name: RENAMED_NAME_FIELD, + label: 'Searchable Label Column', + isLabelSyncedWithName: false, + }, + }, + gqlFields: `id name`, + expectToFail: false, + }); + + const asExpression = await getSearchVectorAsExpression(); + + expect(asExpression).toContain(RENAMED_NAME_FIELD); const searchResult = await search({ - searchInput: RECORD_FIELD_VALUE, + searchInput: RECORD_NAME_VALUE, includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], limit: 10, expectToFail: false, }); - expect(searchResult.data).toBeDefined(); - expect(searchResult.data.search).toBeDefined(); - expect(searchResult.data.search.edges).toBeDefined(); expect(searchResult.data.search.edges.length).toBe(1); expect(searchResult.data.search.edges[0].node.recordId).toBe( createdRecordId, diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap index ffacba5c0b..d434a3aa13 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap @@ -403,6 +403,29 @@ exports[`Object metadata creation should fail v2 when labelPlural contains only "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -636,12 +659,13 @@ exports[`Object metadata creation should fail v2 when labelPlural contains only }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -1055,6 +1079,29 @@ exports[`Object metadata creation should fail v2 when labelPlural exceeds maximu "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -1288,12 +1335,13 @@ exports[`Object metadata creation should fail v2 when labelPlural exceeds maximu }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -1718,6 +1766,29 @@ exports[`Object metadata creation should fail v2 when labelSingular contains onl "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -1951,12 +2022,13 @@ exports[`Object metadata creation should fail v2 when labelSingular contains onl }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -2370,6 +2442,29 @@ exports[`Object metadata creation should fail v2 when labelSingular exceeds maxi "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -2603,12 +2698,13 @@ exports[`Object metadata creation should fail v2 when labelSingular exceeds maxi }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -3057,6 +3153,29 @@ exports[`Object metadata creation should fail v2 when name exceeds maximum lengt "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -3290,12 +3409,13 @@ exports[`Object metadata creation should fail v2 when name exceeds maximum lengt }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -3709,6 +3829,29 @@ exports[`Object metadata creation should fail v2 when namePlural has invalid cha "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -3942,12 +4085,13 @@ exports[`Object metadata creation should fail v2 when namePlural has invalid cha }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -4361,6 +4505,29 @@ exports[`Object metadata creation should fail v2 when namePlural is a reserved k "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -4594,12 +4761,13 @@ exports[`Object metadata creation should fail v2 when namePlural is a reserved k }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -5024,6 +5192,29 @@ exports[`Object metadata creation should fail v2 when namePlural is not camelCas "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -5257,12 +5448,13 @@ exports[`Object metadata creation should fail v2 when namePlural is not camelCas }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -5700,6 +5892,29 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -5933,12 +6148,13 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -6358,6 +6574,29 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -6591,12 +6830,13 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -7034,6 +7274,29 @@ exports[`Object metadata creation should fail v2 when nameSingular has invalid c "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -7267,12 +7530,13 @@ exports[`Object metadata creation should fail v2 when nameSingular has invalid c }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -7686,6 +7950,29 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -7919,12 +8206,13 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -8373,6 +8661,29 @@ exports[`Object metadata creation should fail v2 when nameSingular is not camelC "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -8606,12 +8917,13 @@ exports[`Object metadata creation should fail v2 when nameSingular is not camelC }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -9025,6 +9337,29 @@ exports[`Object metadata creation should fail v2 when names are identical 1`] = "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -9258,12 +9593,13 @@ exports[`Object metadata creation should fail v2 when names are identical 1`] = }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, @@ -9677,6 +10013,29 @@ exports[`Object metadata creation should fail v2 when names with whitespaces res "type": "create", }, ], + "searchFieldMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "OBJECT_METADATA_NOT_FOUND", + "message": "Object metadata not found", + "userFriendlyMessage": "Object metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "objectMetadataUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "searchFieldMetadata", + "status": "fail", + "type": "create", + }, + ], "view": [ { "errors": [ @@ -9910,12 +10269,13 @@ exports[`Object metadata creation should fail v2 when names with whitespaces res }, ], }, - "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices", + "message": "Validation failed for 17 fieldMetadata, 1 objectMetadata, 2 views, 9 viewFields, 5 indices, 1 searchFieldMetadata", "summary": { "fieldMetadata": 17, "index": 5, "objectMetadata": 1, - "totalErrors": 34, + "searchFieldMetadata": 1, + "totalErrors": 35, "view": 2, "viewField": 9, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/backfill-search-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/backfill-search-field-metadata.integration-spec.ts new file mode 100644 index 0000000000..1c9bd80155 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/backfill-search-field-metadata.integration-spec.ts @@ -0,0 +1,153 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +// Asserts the DB-level invariant the backfill relies on: a custom object's +// searchFieldMetadata rows are persisted with the object's own applicationId, never the +// standard application's. The backfill's selection/grouping/idempotency is unit-tested +// in build-search-field-metadata-backfill-operations.util.spec.ts. +const LABEL_FIELD_NAME = 'tag'; +const PREFIX_OVERLAP_FIELD_NAME = 'tagline'; + +const STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER = + '20202020-64aa-4b6f-b003-9c74b97cee20'; + +type SearchFieldMetadataRow = { + objectApplicationId: string; + searchFieldApplicationId: string; + fieldObjectMetadataId: string; + fieldName: string; +}; + +const querySearchFieldMetadataRows = async ( + objectMetadataId: string, +): Promise => { + return global.testDataSource.query( + `SELECT + om."applicationId" AS "objectApplicationId", + sfm."applicationId" AS "searchFieldApplicationId", + fm."objectMetadataId" AS "fieldObjectMetadataId", + fm.name AS "fieldName" + FROM core."searchFieldMetadata" sfm + INNER JOIN core."objectMetadata" om ON om.id = sfm."objectMetadataId" + INNER JOIN core."fieldMetadata" fm ON fm.id = sfm."fieldMetadataId" + WHERE sfm."objectMetadataId" = $1 + ORDER BY fm.name ASC`, + [objectMetadataId], + ); +}; + +// Resolves the standard application id of the workspace that owns the given object, +// without needing the object's (GraphQL-hidden) workspaceId. +const queryStandardApplicationIdForObjectWorkspace = async ( + objectMetadataId: string, +): Promise => { + const rows: { id: string }[] = await global.testDataSource.query( + `SELECT app.id AS id + FROM core."application" app + INNER JOIN core."objectMetadata" om ON om."workspaceId" = app."workspaceId" + WHERE om.id = $1 AND app."universalIdentifier" = $2`, + [objectMetadataId, STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER], + ); + + return rows[0].id; +}; + +describe('searchFieldMetadata rows - app-correct and deterministic per object', () => { + let testObjectMetadataId: string; + let labelFieldMetadataId: string; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'backfillSearchFieldObject', + namePlural: 'backfillSearchFieldObjects', + labelSingular: 'Backfill Search Field Object', + labelPlural: 'Backfill Search Field Objects', + icon: 'IconSearch', + isLabelSyncedWithName: false, + }, + }); + + testObjectMetadataId = objectMetadataId; + + const { + data: { + createOneField: { id: labelFieldId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: LABEL_FIELD_NAME, + label: 'Tag', + type: FieldMetadataType.TEXT, + objectMetadataId: testObjectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: `id name`, + }); + + labelFieldMetadataId = labelFieldId; + + await createOneFieldMetadata({ + expectToFail: false, + input: { + name: PREFIX_OVERLAP_FIELD_NAME, + label: 'Tagline', + type: FieldMetadataType.TEXT, + objectMetadataId: testObjectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: `id name`, + }); + + await updateOneObjectMetadata({ + input: { + idToUpdate: testObjectMetadataId, + updatePayload: { + labelIdentifierFieldMetadataId: labelFieldMetadataId, + }, + }, + expectToFail: false, + }); + }); + + afterAll(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: testObjectMetadataId, + updatePayload: { isActive: false }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testObjectMetadataId }, + }); + }); + + it('persists every searchFieldMetadata row of a custom object with the custom object application id, not the standard application id', async () => { + const rows = await querySearchFieldMetadataRows(testObjectMetadataId); + const standardApplicationId = + await queryStandardApplicationIdForObjectWorkspace(testObjectMetadataId); + + expect(rows.length).toBeGreaterThan(0); + + for (const row of rows) { + // A custom object's rows carry its own applicationId, never the standard one. + expect(row.searchFieldApplicationId).toBe(row.objectApplicationId); + expect(row.searchFieldApplicationId).not.toBe(standardApplicationId); + // No cross-object leakage: every targeted field belongs to this object. + expect(row.fieldObjectMetadataId).toBe(testObjectMetadataId); + } + + expect(rows.some((row) => row.fieldName === LABEL_FIELD_NAME)).toBe(true); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/update-one-object-metadata-serach-vector-side-effect.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/update-one-object-metadata-serach-vector-side-effect.integration-spec.ts index 2541848ab1..1819c28cc2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/update-one-object-metadata-serach-vector-side-effect.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/update-one-object-metadata-serach-vector-side-effect.integration-spec.ts @@ -94,7 +94,7 @@ describe('Object metadata update - search vector side effect', () => { }); }); - it('should update search vector asExpression when updating label identifier and search should work with new field', async () => { + it('should index the new label identifier without dropping the existing name surface', async () => { await updateOneObjectMetadata({ input: { idToUpdate: testObjectMetadataId, @@ -146,25 +146,46 @@ describe('Object metadata update - search vector side effect', () => { jestExpectToBeDefined(settings); expect(settings.asExpression).toBeDefined(); + // Relabeling is additive: the new label identifier joins the search surface while the + // previously provisioned name row is preserved. expect(settings.asExpression).toContain(NEW_LABEL_IDENTIFIER_FIELD_NAME); - expect(settings.asExpression).not.toContain('name'); + expect(settings.asExpression).toContain('name'); - const searchResult = await search({ + // Position-ordered derivation appends the new label identifier last: the provisioned + // name row (position 0) stays first, the relabel row (max position + 1) comes after. + const asExpression = settings.asExpression as string; + + expect(asExpression.indexOf('"name"')).toBeLessThan( + asExpression.indexOf(`"${NEW_LABEL_IDENTIFIER_FIELD_NAME}"`), + ); + + // The record is now reachable through the new label identifier value. + const searchByNewLabelField = await search({ searchInput: RECORD_FIELD_VALUE, includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], limit: 10, expectToFail: false, }); - expect(searchResult.data).toBeDefined(); - expect(searchResult.data.search).toBeDefined(); - expect(searchResult.data.search.edges).toBeDefined(); - expect(searchResult.data.search.edges.length).toBe(1); - expect(searchResult.data.search.edges[0].node.recordId).toBe( + expect(searchByNewLabelField.data.search.edges.length).toBe(1); + expect(searchByNewLabelField.data.search.edges[0].node.recordId).toBe( createdRecordId, ); - expect(searchResult.data.search.edges[0].node.objectNameSingular).toBe( - OBJECT_NAME_SINGULAR, + expect( + searchByNewLabelField.data.search.edges[0].node.objectNameSingular, + ).toBe(OBJECT_NAME_SINGULAR); + + // The previously indexed name field remains searchable. + const searchByName = await search({ + searchInput: RECORD_NAME_FIELD_VALUE, + includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], + limit: 10, + expectToFail: false, + }); + + expect(searchByName.data.search.edges.length).toBe(1); + expect(searchByName.data.search.edges[0].node.recordId).toBe( + createdRecordId, ); }); }); diff --git a/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts b/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts index f59df21572..1653ddcc66 100644 --- a/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts @@ -29,4 +29,5 @@ export const ALL_METADATA_NAME = { webhook: 'webhook', applicationVariable: 'applicationVariable', connectionProvider: 'connectionProvider', + searchFieldMetadata: 'searchFieldMetadata', } as const;