Fix - Update searchVector when labelIdentifier is updated (#16940)

Fixes https://github.com/twentyhq/twenty/issues/16891

In next PR, validation rules will be added in migration logic
This commit is contained in:
Etienne
2026-01-06 13:55:25 +01:00
committed by GitHub
parent bd9e5986d2
commit 0c6f4021bf
18 changed files with 823 additions and 177 deletions
@@ -171,12 +171,14 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
flatViewsToDelete,
flatViewFieldsToDelete,
flatViewsToUpdate,
flatFieldMetadatasToUpdate: flatFieldMetadatasToUpdateFromSideEffect,
} = sideEffectResult.result;
return {
flatFieldMetadatasToUpdate: [
...accumulator.flatFieldMetadatasToUpdate,
toFlatFieldMetadata,
...flatFieldMetadatasToUpdateFromSideEffect,
],
flatIndexMetadatasToUpdate: [
...accumulator.flatIndexMetadatasToUpdate,
@@ -1,6 +1,8 @@
import { type FromTo } 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 { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { handleEnumFlatFieldMetadataUpdateSideEffects } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util';
@@ -12,6 +14,7 @@ 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 { 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';
@@ -20,7 +23,9 @@ export type FlatFieldMetadataUpdateSideEffects =
FlatViewFiltersToDeleteAndUpdate &
FlatViewGroupsToDeleteUpdateAndCreate &
FieldMetadataUpdateIndexSideEffect &
FieldMetadataDeactivationSideEffect;
FieldMetadataDeactivationSideEffect & {
flatFieldMetadatasToUpdate: FlatFieldMetadata[];
};
type HandleFlatFieldMetadataUpdateSideEffectArgs = FromTo<
FlatFieldMetadata,
@@ -52,6 +57,7 @@ export const FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS: FlatFieldMetadataUpd
flatViewsToDelete: [],
flatViewFieldsToDelete: [],
flatViewsToUpdate: [],
flatFieldMetadatasToUpdate: [],
};
export const handleFlatFieldMetadataUpdateSideEffect = ({
@@ -131,6 +137,31 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({
return indexChangesSideEffectResult;
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: fromFlatFieldMetadata.objectMetadataId,
});
const isLabelIdentifierFieldMetadata =
flatObjectMetadata.labelIdentifierFieldMetadataId ===
toFlatFieldMetadata.id;
if (isLabelIdentifierFieldMetadata) {
const flatSearchVectorFieldToUpdate =
handleLabelIdentifierChangesDuringFieldUpdate({
fromFlatFieldMetadata,
toFlatFieldMetadata,
flatObjectMetadata,
flatFieldMetadataMaps,
});
if (isDefined(flatSearchVectorFieldToUpdate)) {
sideEffectResult.flatFieldMetadatasToUpdate.push(
flatSearchVectorFieldToUpdate,
);
}
}
const {
flatIndexMetadatasToUpdate,
flatIndexMetadatasToCreate,
@@ -0,0 +1,72 @@
import { type FieldMetadataType, type FromTo } from 'twenty-shared/types';
import { findOrThrow } 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/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
type HandleLabelIdentifierChangesDuringFieldUpdateArgs = {
flatObjectMetadata: FlatObjectMetadata;
} & FromTo<FlatFieldMetadata, 'flatFieldMetadata'> &
Pick<AllFlatEntityMaps, 'flatFieldMetadataMaps'>;
export const handleLabelIdentifierChangesDuringFieldUpdate = ({
fromFlatFieldMetadata,
toFlatFieldMetadata,
flatObjectMetadata,
flatFieldMetadataMaps,
}: HandleLabelIdentifierChangesDuringFieldUpdateArgs):
| FlatFieldMetadata<FieldMetadataType.TS_VECTOR>
| undefined => {
const hasNameChanged =
fromFlatFieldMetadata.name !== toFlatFieldMetadata.name;
if (!hasNameChanged) {
return undefined;
}
const objectFlatFieldMetadatas =
findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityIds: flatObjectMetadata.fieldMetadataIds,
});
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<FieldMetadataType.TS_VECTOR>;
try {
const newAsExpression = getTsVectorColumnExpressionFromFields([
{
name: toFlatFieldMetadata.name,
type: toFlatFieldMetadata.type as SearchableFieldType,
},
]);
return {
...searchVectorField,
settings: {
...searchVectorField.settings,
asExpression: newAsExpression,
},
};
} catch {
throw new FieldMetadataException(
`Failed to compute search vector column expression for field ${toFlatFieldMetadata.name}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
};
@@ -84,6 +84,7 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities =
flatViewFieldsToCreate,
flatViewFieldsToUpdate,
otherObjectFlatFieldMetadatasToUpdate,
sameObjectFlatFieldMetadatasToUpdate,
} = handleFlatObjectMetadataUpdateSideEffect({
fromFlatObjectMetadata: existingFlatObjectMetadata,
toFlatObjectMetadata,
@@ -100,5 +101,6 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities =
flatViewFieldsToCreate,
flatViewFieldsToUpdate,
otherObjectFlatFieldMetadatasToUpdate,
sameObjectFlatFieldMetadatasToUpdate,
};
};
@@ -6,12 +6,14 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-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 { 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 FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
export type FlatObjectMetadataUpdateSideEffects = {
otherObjectFlatFieldMetadatasToUpdate: FlatFieldMetadata[];
sameObjectFlatFieldMetadatasToUpdate: FlatFieldMetadata[];
flatViewFieldsToUpdate: FlatViewField[];
flatViewFieldsToCreate: FlatViewField[];
flatIndexMetadatasToUpdate: FlatIndexMetadata[];
@@ -81,6 +83,27 @@ export const handleFlatObjectMetadataUpdateSideEffect = ({
flatViewFieldsToUpdate: [],
};
const sameObjectFlatFieldMetadatasToUpdate: FlatFieldMetadata[] = [];
if (
toFlatObjectMetadata.isSearchable &&
isDefined(toFlatObjectMetadata.labelIdentifierFieldMetadataId) &&
fromFlatObjectMetadata.labelIdentifierFieldMetadataId !==
toFlatObjectMetadata.labelIdentifierFieldMetadataId
) {
const updatedSearchVectorField =
recomputeSearchVectorFieldAfterLabelIdentifierUpdate({
existingFlatObjectMetadata: fromFlatObjectMetadata,
flatFieldMetadataMaps,
updatedLabelIdentifierFieldMetadataId:
toFlatObjectMetadata.labelIdentifierFieldMetadataId,
});
if (isDefined(updatedSearchVectorField)) {
sameObjectFlatFieldMetadatasToUpdate.push(updatedSearchVectorField);
}
}
return {
flatIndexMetadatasToUpdate: [
...morphRelatedFlatIndexesToUpdate,
@@ -89,5 +112,6 @@ export const handleFlatObjectMetadataUpdateSideEffect = ({
flatViewFieldsToCreate,
flatViewFieldsToUpdate,
otherObjectFlatFieldMetadatasToUpdate: morphFlatFieldMetadatasToUpdate,
sameObjectFlatFieldMetadatasToUpdate,
};
};
@@ -0,0 +1,74 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { findOrThrow, 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 { 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/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
type RecomputeSearchVectorFieldAfterLabelIdentifierUpdateArgs = {
existingFlatObjectMetadata: FlatObjectMetadata;
updatedLabelIdentifierFieldMetadataId: string;
} & Pick<AllFlatEntityMaps, 'flatFieldMetadataMaps'>;
export const recomputeSearchVectorFieldAfterLabelIdentifierUpdate = ({
existingFlatObjectMetadata,
flatFieldMetadataMaps,
updatedLabelIdentifierFieldMetadataId,
}: RecomputeSearchVectorFieldAfterLabelIdentifierUpdateArgs):
| FlatFieldMetadata<FieldMetadataType.TS_VECTOR>
| undefined => {
const objectFlatFieldMetadatas =
findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityIds: existingFlatObjectMetadata.fieldMetadataIds,
});
const searchVectorField = findOrThrow(
objectFlatFieldMetadatas,
(field) => field.name === SEARCH_VECTOR_FIELD.name,
) as FlatFieldMetadata<FieldMetadataType.TS_VECTOR>;
const newLabelIdentifierField = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: updatedLabelIdentifierFieldMetadataId,
});
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,
settings: {
...searchVectorField.settings,
asExpression: newAsExpression,
generatedType: 'STORED',
},
};
} catch {
throw new ObjectMetadataException(
`Failed to compute search vector column expression for field ${newLabelIdentifierField.name}`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
};
@@ -81,6 +81,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const {
otherObjectFlatFieldMetadatasToUpdate,
sameObjectFlatFieldMetadatasToUpdate,
flatObjectMetadataToUpdate,
flatIndexMetadatasToUpdate,
flatViewFieldsToCreate,
@@ -111,7 +112,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: otherObjectFlatFieldMetadatasToUpdate,
flatEntityToUpdate: [
...otherObjectFlatFieldMetadatasToUpdate,
...sameObjectFlatFieldMetadatasToUpdate,
],
},
viewField: {
flatEntityToCreate: flatViewFieldsToCreate,
@@ -945,6 +945,8 @@ export class WorkspaceMigrationBuildOrchestratorService {
const { aggregatedOrchestratorActionsReport } =
aggregateOrchestratorActionsReport({
orchestratorActionsReport,
flatFieldMetadataMaps:
optimisticAllFlatEntityMaps.flatFieldMetadataMaps,
});
return {
@@ -0,0 +1,8 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-orchestrator.type';
export type AggregateOrchestratorActionsReportArgs = {
orchestratorActionsReport: OrchestratorActionsReport;
flatFieldMetadataMaps?: FlatEntityMaps<FlatFieldMetadata>;
};
@@ -0,0 +1,124 @@
import { FieldMetadataType } from 'twenty-shared/types';
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 { 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 { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { createEmptyOrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration-v2/constant/empty-orchestrator-actions-report.constant';
import { aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions } from 'src/engine/workspace-manager/workspace-migration-v2/utils/aggregate-orchestrator-actions-report-deprioritize-search-vector-update-field-actions.util';
import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/types/workspace-migration-field-action-v2';
describe('aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions', () => {
it('should move searchVector update actions to the end of the update list', () => {
const orchestratorActionsReport = {
...createEmptyOrchestratorActionsReport(),
fieldMetadata: {
create: [],
update: [
{
type: 'update',
metadataName: 'fieldMetadata',
entityId: 'search-vector-field-1',
objectMetadataId: 'object-1',
updates: [
{
property: 'label',
from: 'Search Vector',
to: 'Updated Search Vector',
},
],
} satisfies UpdateFieldAction,
{
type: 'update',
metadataName: 'fieldMetadata',
entityId: 'regular-field-1',
objectMetadataId: 'object-1',
updates: [
{
property: 'label',
from: 'First Name',
to: 'Updated First Name',
},
],
} satisfies UpdateFieldAction,
{
type: 'update',
metadataName: 'fieldMetadata',
entityId: 'regular-field-2',
objectMetadataId: 'object-1',
updates: [
{
property: 'label',
from: 'Last Name',
to: 'Updated Last Name',
},
],
} satisfies UpdateFieldAction,
],
delete: [],
},
};
const flatFieldMetadataMaps = [
getFlatFieldMetadataMock({
universalIdentifier: 'search-vector-field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TS_VECTOR,
id: 'search-vector-field-1',
name: SEARCH_VECTOR_FIELD.name,
}),
getFlatFieldMetadataMock({
universalIdentifier: 'regular-field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'regular-field-1',
name: 'firstName',
}),
getFlatFieldMetadataMock({
universalIdentifier: 'regular-field-2',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'regular-field-2',
name: 'lastName',
}),
].reduce<FlatEntityMaps<FlatFieldMetadata>>(
(flatEntityMaps, field) =>
addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: field,
flatEntityMaps,
}),
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
const result =
aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions(
{
orchestratorActionsReport,
flatFieldMetadataMaps,
},
);
const updateFieldActions = result.fieldMetadata
.update as UpdateFieldAction[];
const actualEntityIds = updateFieldActions.map((action) => action.entityId);
expect(actualEntityIds).toEqual([
'regular-field-1',
'regular-field-2',
'search-vector-field-1',
]);
expect(result.fieldMetadata.create).toEqual(
orchestratorActionsReport.fieldMetadata.create,
);
expect(result.fieldMetadata.delete).toEqual(
orchestratorActionsReport.fieldMetadata.delete,
);
expect(result.objectMetadata).toEqual(
orchestratorActionsReport.objectMetadata,
);
});
});
@@ -2,14 +2,11 @@ import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-orchestrator.type';
import { type CreateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/types/workspace-migration-field-action-v2';
import { type CreateObjectAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/object/types/workspace-migration-object-action-v2';
type AggregateOrchestratorActionsReportCreateObjectAndCreateFieldActionsArgs = {
orchestratorActionsReport: OrchestratorActionsReport;
};
type AggregatedActions = {
createdFieldActionByObjectMetadataId: Record<string, CreateFieldAction>;
createdObjectActionByObjectMetadataId: Record<string, CreateObjectAction>;
@@ -18,7 +15,7 @@ type AggregatedActions = {
export const aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions =
({
orchestratorActionsReport,
}: AggregateOrchestratorActionsReportCreateObjectAndCreateFieldActionsArgs): OrchestratorActionsReport => {
}: AggregateOrchestratorActionsReportArgs): OrchestratorActionsReport => {
const initialCreatedObjectActionByObjectMetadataId = (
orchestratorActionsReport.objectMetadata.create as CreateObjectAction[]
).reduce(
@@ -1,20 +1,17 @@
import { isDefined } from 'twenty-shared/utils';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-orchestrator.type';
import { type DeleteFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/types/workspace-migration-field-action-v2';
import { type DeleteObjectAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/object/types/workspace-migration-object-action-v2';
type AggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActionsArgs = {
orchestratorActionsReport: OrchestratorActionsReport;
};
type AggregatedActions = {
deleteFieldActionByFieldMetadataId: Record<string, DeleteFieldAction>;
};
export const aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions =
({
orchestratorActionsReport,
}: AggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActionsArgs): OrchestratorActionsReport => {
}: AggregateOrchestratorActionsReportArgs): OrchestratorActionsReport => {
const deleteObjectActionByObjectMetadataId = (
orchestratorActionsReport.objectMetadata.delete as DeleteObjectAction[]
).reduce<Record<string, DeleteObjectAction>>(
@@ -0,0 +1,64 @@
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-orchestrator.type';
import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/types/workspace-migration-field-action-v2';
export const aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions =
({
orchestratorActionsReport,
flatFieldMetadataMaps,
}: AggregateOrchestratorActionsReportArgs): OrchestratorActionsReport => {
assertIsDefinedOrThrow(flatFieldMetadataMaps);
const updateFieldActions = orchestratorActionsReport.fieldMetadata
.update as UpdateFieldAction[];
const { searchVectorUpdateFieldActions, otherUpdateFieldActions } =
updateFieldActions.reduce<{
searchVectorUpdateFieldActions: UpdateFieldAction[];
otherUpdateFieldActions: UpdateFieldAction[];
}>(
(acc, updateFieldAction) => {
const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: updateFieldAction.entityId,
});
const isSearchVectorUpdateFieldAction =
flatFieldMetadata?.name === SEARCH_VECTOR_FIELD.name;
if (isSearchVectorUpdateFieldAction) {
return {
...acc,
searchVectorUpdateFieldActions: [
...acc.searchVectorUpdateFieldActions,
updateFieldAction,
],
};
}
return {
...acc,
otherUpdateFieldActions: [
...acc.otherUpdateFieldActions,
updateFieldAction,
],
};
},
{
searchVectorUpdateFieldActions: [],
otherUpdateFieldActions: [],
},
);
return {
...orchestratorActionsReport,
fieldMetadata: {
...orchestratorActionsReport.fieldMetadata,
update: [...otherUpdateFieldActions, ...searchVectorUpdateFieldActions],
},
};
};
@@ -1,20 +1,21 @@
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-orchestrator.type';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions } from 'src/engine/workspace-manager/workspace-migration-v2/utils/aggregate-orchestrator-actions-report-create-object-and-create-field-actions.util';
import { aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions } from 'src/engine/workspace-manager/workspace-migration-v2/utils/aggregate-orchestrator-actions-report-delete-object-and-delete-field.util';
import { aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions } from 'src/engine/workspace-manager/workspace-migration-v2/utils/aggregate-orchestrator-actions-report-deprioritize-search-vector-update-field-actions.util';
type AggregateOrchestratorActionsReportArgs = {
orchestratorActionsReport: OrchestratorActionsReport;
};
export const aggregateOrchestratorActionsReport = ({
orchestratorActionsReport,
flatFieldMetadataMaps,
}: AggregateOrchestratorActionsReportArgs) => {
const aggregatedOrchestratorActionsReport = [
aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions,
aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions,
aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions,
].reduce(
(currentOrchestratorActionsReport, aggregator) =>
aggregator({
orchestratorActionsReport: currentOrchestratorActionsReport,
flatFieldMetadataMaps,
}),
orchestratorActionsReport,
);
@@ -190,6 +190,37 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
},
};
}
if (
isPropertyUpdate(update, 'settings') &&
isDefined(update.to?.asExpression) &&
isDefined(update.from?.asExpression) &&
(update.to.asExpression !== update.from.asExpression ||
update.to.generatedType !== update.from.generatedType)
) {
await this.workspaceSchemaManagerService.columnManager.dropColumns({
queryRunner,
schemaName,
tableName,
columnNames: [optimisticFlatFieldMetadata.name],
});
await this.workspaceSchemaManagerService.columnManager.addColumns({
queryRunner,
schemaName,
tableName,
columnDefinitions: [
{
name: optimisticFlatFieldMetadata.name,
type: 'tsvector',
...update.to,
},
],
});
optimisticFlatFieldMetadata = {
...optimisticFlatFieldMetadata,
settings: update.to,
};
}
}
}
@@ -0,0 +1,189 @@
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';
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';
describe('Field metadata update - search vector side effect', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: 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 { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {
id: { eq: testObjectMetadataId },
},
paging: { first: 1 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
type
settings
}
`,
});
expect(objects).toBeDefined();
expect(objects.length).toBe(1);
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;
generatedType?: string;
};
jestExpectToBeDefined(settings);
expect(settings.asExpression).toBeDefined();
expect(settings.asExpression).toContain(UPDATED_FIELD_NAME);
expect(settings.asExpression).not.toContain(INITIAL_FIELD_NAME);
const searchResult = 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(
createdRecordId,
);
expect(searchResult.data.search.edges[0].node.objectNameSingular).toBe(
OBJECT_NAME_SINGULAR,
);
});
});
@@ -4,59 +4,12 @@ exports[`Object metadata update should fail when labelIdentifier is a UUID field
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectMetadata": [
{
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
"message": "labelIdentifierFieldMetadataId validation failed: field type uuid is reserved for system object metadata",
"userFriendlyMessage": "Field cannot be used as label identifier",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"namePlural": "whatevers",
"nameSingular": "whatever",
"universalIdentifier": Any<String>,
},
"metadataName": "objectMetadata",
"status": "fail",
"type": "update",
},
],
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "View field position cannot be lower than label identifier view field position",
"userFriendlyMessage": "View field position cannot be lower than label identifier view field position",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataId": Any<String>,
"id": Any<String>,
"universalIdentifier": Any<String>,
"viewId": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 objectMetadata, 1 viewField",
"summary": {
"objectMetadata": 1,
"totalErrors": 2,
"viewField": 1,
},
"userFriendlyMessage": "Metadata validation failed",
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Multiple validation errors occurred while updating object",
"name": "GraphQLError",
"message": "Failed to compute search vector column expression for field id",
"name": "UserInputError",
},
]
`;
@@ -65,59 +18,12 @@ exports[`Object metadata update should fail when labelIdentifier is not a TEXT o
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectMetadata": [
{
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
"message": "labelIdentifierFieldMetadataId validation failed: field type not compatible",
"userFriendlyMessage": "Field cannot be used as label identifier",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"namePlural": "whatevers",
"nameSingular": "whatever",
"universalIdentifier": Any<String>,
},
"metadataName": "objectMetadata",
"status": "fail",
"type": "update",
},
],
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "View field position cannot be lower than label identifier view field position",
"userFriendlyMessage": "View field position cannot be lower than label identifier view field position",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataId": Any<String>,
"id": Any<String>,
"universalIdentifier": Any<String>,
"viewId": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 objectMetadata, 1 viewField",
"summary": {
"objectMetadata": 1,
"totalErrors": 2,
"viewField": 1,
},
"userFriendlyMessage": "Metadata validation failed",
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Multiple validation errors occurred while updating object",
"name": "GraphQLError",
"message": "Failed to compute search vector column expression for field testName",
"name": "UserInputError",
},
]
`;
@@ -126,64 +32,12 @@ exports[`Object metadata update should fail when labelIdentifier is not a known
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectMetadata": [
{
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
"message": "labelIdentifierFieldMetadataId validation failed: related field metadata not found",
"userFriendlyMessage": "Field declared as label identifier not found",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"namePlural": "whatevers",
"nameSingular": "whatever",
"universalIdentifier": Any<String>,
},
"metadataName": "objectMetadata",
"status": "fail",
"type": "update",
},
],
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
{
"code": "INVALID_VIEW_DATA",
"message": "View field position cannot be lower than label identifier view field position",
"userFriendlyMessage": "View field position cannot be lower than label identifier view field position",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataId": Any<String>,
"id": Any<String>,
"universalIdentifier": Any<String>,
"viewId": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 objectMetadata, 1 viewField",
"summary": {
"objectMetadata": 1,
"totalErrors": 2,
"viewField": 1,
},
"userFriendlyMessage": "Metadata validation failed",
"code": "BAD_USER_INPUT",
"subCode": "INVALID_OBJECT_INPUT",
"userFriendlyMessage": "Invalid object input.",
},
"message": "Multiple validation errors occurred while updating object",
"name": "GraphQLError",
"message": "New label identifier field not found for object metadata",
"name": "UserInputError",
},
]
`;
@@ -0,0 +1,170 @@
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 { 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';
describe('Object metadata update - search vector side effect', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: string;
let createdRecordId: string;
const OBJECT_NAME_SINGULAR = 'searchVectorTestObject';
const OBJECT_NAME_PLURAL = 'searchVectorTestObjects';
const NEW_LABEL_IDENTIFIER_FIELD_NAME = 'searchableTitle';
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: NEW_LABEL_IDENTIFIER_FIELD_NAME,
label: 'Searchable Title',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `
id
name
label
`,
});
testFieldMetadataId = fieldMetadataId;
const { data } = await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: `id name ${NEW_LABEL_IDENTIFIER_FIELD_NAME}`,
data: [
{
[NEW_LABEL_IDENTIFIER_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 updating label identifier and search should work with new field', async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
labelIdentifierFieldMetadataId: testFieldMetadataId,
},
},
expectToFail: false,
});
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {
id: { eq: testObjectMetadataId },
},
paging: { first: 1 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
type
settings
}
`,
});
expect(objects).toBeDefined();
expect(objects.length).toBe(1);
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;
generatedType?: string;
};
jestExpectToBeDefined(settings);
expect(settings.asExpression).toBeDefined();
expect(settings.asExpression).toContain(NEW_LABEL_IDENTIFIER_FIELD_NAME);
expect(settings.asExpression).not.toContain('name');
const searchResult = 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(
createdRecordId,
);
expect(searchResult.data.search.edges[0].node.objectNameSingular).toBe(
OBJECT_NAME_SINGULAR,
);
});
});