[Apps] Fix - app-synced object should be searchable (#19206)

## Summary

- **Make app-synced objects searchable**: `isSearchable` was hardcoded
to `false` and the `searchVector` field was missing the `GENERATED
ALWAYS AS (...)` expression, causing all records to have a `NULL` search
vector and be excluded from search results. Fixed by defaulting
`isSearchable` to `true` (configurable via the object manifest),
computing the `asExpression` from the label identifier field, and
allowing the update-field-action-handler to handle the `null` → defined
`asExpression` transition.
- **Make `isSearchable` updatable on an object**: The property had
`toCompare: false` in the entity properties configuration, so updates
via the API were silently ignored and never persisted. Fixed by setting
`toCompare: true`.
This commit is contained in:
Marie
2026-04-02 19:14:37 +02:00
committed by GitHub
parent 43baf7b91c
commit 2d6c8be7df
34 changed files with 639 additions and 51 deletions
@@ -29,7 +29,7 @@ export const fromObjectManifestToUniversalFlatObjectMetadata = ({
isSystem: false,
isUIReadOnly: false,
isAuditLogged: true,
isSearchable: false,
isSearchable: objectManifest.isSearchable ?? true,
duplicateCriteria: null,
shortcut: null,
isLabelSyncedWithName: false,
@@ -0,0 +1,149 @@
import { type ObjectManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeSearchVectorUniversalSettingsFromObjectManifest } from 'src/engine/core-modules/application/application-manifest/utils/compute-search-vector-universal-settings-from-object-manifest.util';
const buildObjectManifest = (
overrides: Partial<ObjectManifest> & {
fields: ObjectManifest['fields'];
labelIdentifierFieldMetadataUniversalIdentifier: string;
},
): ObjectManifest => ({
universalIdentifier: 'obj-uuid-1',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
...overrides,
});
describe('computeSearchVectorUniversalSettingsFromObjectManifest', () => {
it('should return asExpression and generatedType for a TEXT label identifier field', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier: 'field-uuid-name',
fields: [
{
universalIdentifier: 'field-uuid-name',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
},
],
}),
});
expect(result?.generatedType).toBe('STORED');
expect(result?.asExpression).toContain("to_tsvector('simple'");
expect(result?.asExpression).toContain('"name"');
});
it('should return asExpression for a FULL_NAME label identifier field', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier: 'field-uuid-name',
fields: [
{
universalIdentifier: 'field-uuid-name',
name: 'name',
label: 'Name',
type: FieldMetadataType.FULL_NAME,
},
],
}),
});
expect(result?.generatedType).toBe('STORED');
expect(result?.asExpression).toContain("to_tsvector('simple'");
expect(result?.asExpression).toContain('"nameFirstName"');
expect(result?.asExpression).toContain('"nameLastName"');
});
it('should return asExpression for an EMAILS label identifier field', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier: 'field-uuid-email',
fields: [
{
universalIdentifier: 'field-uuid-email',
name: 'email',
label: 'Email',
type: FieldMetadataType.EMAILS,
},
],
}),
});
expect(result?.generatedType).toBe('STORED');
expect(result?.asExpression).toContain("to_tsvector('simple'");
expect(result?.asExpression).toContain('"emailPrimaryEmail"');
});
it('should return asExpression for a UUID label identifier field', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier: 'field-uuid-id',
fields: [
{
universalIdentifier: 'field-uuid-id',
name: 'externalId',
label: 'External ID',
type: FieldMetadataType.UUID,
},
],
}),
});
expect(result?.generatedType).toBe('STORED');
expect(result?.asExpression).toContain("to_tsvector('simple'");
expect(result?.asExpression).toContain('"externalId"');
});
it('should return null when label identifier field is not found in fields', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier:
'non-existent-field-uuid',
fields: [
{
universalIdentifier: 'field-uuid-name',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
},
],
}),
});
expect(result).toBeNull();
});
it('should return null when label identifier field has a non-searchable type', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier: 'field-uuid-number',
fields: [
{
universalIdentifier: 'field-uuid-number',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
},
],
}),
});
expect(result).toBeNull();
});
it('should return null when fields array is empty', () => {
const result = computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest: buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier: 'field-uuid-name',
fields: [],
}),
});
expect(result).toBeNull();
});
});
@@ -1,4 +1,6 @@
import { type Manifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
@@ -11,6 +13,7 @@ import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engin
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util';
import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util';
import { computeSearchVectorUniversalSettingsFromObjectManifest } from 'src/engine/core-modules/application/application-manifest/utils/compute-search-vector-universal-settings-from-object-manifest.util';
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util';
import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util';
@@ -49,12 +52,25 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
});
for (const fieldManifest of objectManifest.fields) {
const enrichedFieldManifest =
fieldManifest.type === FieldMetadataType.TS_VECTOR &&
!isDefined(fieldManifest.universalSettings)
? {
...fieldManifest,
objectUniversalIdentifier: objectManifest.universalIdentifier,
universalSettings:
computeSearchVectorUniversalSettingsFromObjectManifest({
objectManifest,
}),
}
: {
...fieldManifest,
objectUniversalIdentifier: objectManifest.universalIdentifier,
};
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity: fromFieldManifestToUniversalFlatFieldMetadata({
fieldManifest: {
...fieldManifest,
objectUniversalIdentifier: objectManifest.universalIdentifier,
},
fieldManifest: enrichedFieldManifest,
applicationUniversalIdentifier,
now,
}),
@@ -0,0 +1,37 @@
import { type ObjectManifest } from 'twenty-shared/application';
import {
type FieldMetadataUniversalSettings,
FieldMetadataType,
} from 'twenty-shared/types';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { isDefined, isSearchableFieldType } from 'twenty-shared/utils';
export const computeSearchVectorUniversalSettingsFromObjectManifest = ({
objectManifest,
}: {
objectManifest: ObjectManifest;
}): FieldMetadataUniversalSettings<FieldMetadataType.TS_VECTOR> => {
const labelIdentifierField = objectManifest.fields.find(
(field) =>
field.universalIdentifier ===
objectManifest.labelIdentifierFieldMetadataUniversalIdentifier,
);
if (
!isDefined(labelIdentifierField) ||
!isSearchableFieldType(labelIdentifierField.type)
) {
return null;
}
return {
asExpression: getTsVectorColumnExpressionFromFields([
{
name: labelIdentifierField.name,
type: labelIdentifierField.type,
},
]),
generatedType: 'STORED',
};
};
@@ -142,6 +142,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"nameSingular",
"labelIdentifierFieldMetadataUniversalIdentifier",
"standardOverrides",
"isSearchable",
],
"propertiesToStringify": [
"standardOverrides",
@@ -227,7 +227,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
},
isSearchable: {
toCompare: false,
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
@@ -1,7 +1,7 @@
import {
type HasAllProperties,
type Equal,
type Expect,
type HasAllProperties,
} from 'twenty-shared/testing';
import { type FlatEntityUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-update.type';
@@ -49,6 +49,7 @@ type Assertions = [
| 'labelPlural'
| 'labelIdentifierFieldMetadataId'
| 'labelIdentifierFieldMetadataUniversalIdentifier'
| 'isSearchable'
>
>,
@@ -1,5 +1,5 @@
import { type FieldMetadataType, type FromTo } from 'twenty-shared/types';
import { findOrThrow } from 'twenty-shared/utils';
import { findOrThrow, type SearchableFieldType } from 'twenty-shared/utils';
import {
FieldMetadataException,
@@ -11,7 +11,6 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
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';
import { type SearchableFieldType } from 'src/engine/workspace-manager/utils/is-searchable-field.util';
type HandleLabelIdentifierChangesDuringFieldUpdateArgs = {
flatObjectMetadata: FlatObjectMetadata;
@@ -28,5 +28,14 @@ export const validateTsVectorFlatFieldMetadata = ({
});
}
if (!flatEntityToValidate.universalSettings?.asExpression) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'Field type TS_VECTOR must have an expression. This may have failed to be built because record identifier field does not exist or is not of a searchable type.',
userFriendlyMessage: msg`Field type TS_VECTOR must have an expression. This may have failed to be built because record identifier field does not exist or is not of a searchable type.`,
});
}
return errors;
};
@@ -1,5 +1,9 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
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';
@@ -12,7 +16,6 @@ import {
} 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';
import { type SearchableFieldType } from 'src/engine/workspace-manager/utils/is-searchable-field.util';
type RecomputeSearchVectorFieldAfterLabelIdentifierUpdateArgs = {
existingFlatObjectMetadata: FlatObjectMetadata;
@@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro';
import {
isDefined,
isLabelIdentifierFieldMetadataTypes,
isSearchableFieldType,
} from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
@@ -50,7 +51,14 @@ export const validateFlatObjectMetadataIdentifiers = ({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataUniversalIdentifier validation failed: field type not compatible',
userFriendlyMessage: msg`Field cannot be used as label identifier`,
userFriendlyMessage: msg`Field cannot be used as label identifier due to its type: should be of type UUID, text or full name`,
});
} else if (!isSearchableFieldType(universalFlatFieldMetadata.type)) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message:
'labelIdentifierFieldMetadataUniversalIdentifier validation failed: field type not compatible',
userFriendlyMessage: msg`Field cannot be used as label identifier due to its type`,
});
}
}
@@ -14,6 +14,7 @@ import {
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
export const buildFavoriteStandardFlatFieldMetadatas = ({
now,
@@ -185,6 +186,12 @@ export const buildFavoriteStandardFlatFieldMetadatas = ({
icon: 'IconUser',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
@@ -13,6 +13,7 @@ import {
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
export const buildMessageThreadStandardFlatFieldMetadatas = ({
now,
@@ -180,6 +181,12 @@ export const buildMessageThreadStandardFlatFieldMetadatas = ({
icon: 'IconUser',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
@@ -15,6 +15,7 @@ import {
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
export const buildNoteTargetStandardFlatFieldMetadatas = ({
now,
@@ -189,6 +190,12 @@ export const buildNoteTargetStandardFlatFieldMetadatas = ({
icon: 'IconUser',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
@@ -15,6 +15,7 @@ import {
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
export const buildTaskTargetStandardFlatFieldMetadatas = ({
now,
@@ -189,6 +190,12 @@ export const buildTaskTargetStandardFlatFieldMetadatas = ({
icon: 'IconUser',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
@@ -14,6 +14,7 @@ import {
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
export const buildWorkflowAutomatedTriggerStandardFlatFieldMetadatas = ({
now,
@@ -184,6 +185,12 @@ export const buildWorkflowAutomatedTriggerStandardFlatFieldMetadatas = ({
icon: 'IconUser',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
@@ -2,15 +2,15 @@ import {
FieldMetadataType,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import type { SearchableFieldType } from 'twenty-shared/utils';
import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/utils/is-searchable-field.util';
import { isSearchableSubfield } from 'src/engine/workspace-manager/utils/is-searchable-subfield.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
export type FieldTypeAndNameMetadata = {
name: string;
@@ -126,6 +126,9 @@ const getColumnExpression = (
case FieldMetadataType.PHONES:
return `COALESCE(${quotedColumnName}, '')`;
case FieldMetadataType.UUID:
return `COALESCE(${quotedColumnName}::text, '')`;
default:
return `COALESCE(public.unaccent_immutable(${quotedColumnName}), '')`;
}
@@ -1,19 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
const SEARCHABLE_FIELD_TYPES = [
FieldMetadataType.TEXT,
FieldMetadataType.FULL_NAME,
FieldMetadataType.EMAILS,
FieldMetadataType.ADDRESS,
FieldMetadataType.LINKS,
FieldMetadataType.PHONES,
FieldMetadataType.RICH_TEXT,
FieldMetadataType.UUID,
] as const;
export type SearchableFieldType = (typeof SEARCHABLE_FIELD_TYPES)[number];
export const isSearchableFieldType = (
type: FieldMetadataType,
): type is SearchableFieldType => {
return SEARCHABLE_FIELD_TYPES.includes(type as SearchableFieldType);
};
@@ -35,6 +35,7 @@ type Assertions = [
| 'labelSingular'
| 'labelPlural'
| 'labelIdentifierFieldMetadataUniversalIdentifier'
| 'isSearchable'
>
>,
];
@@ -266,9 +266,8 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
if (
isDefined(toSettings?.asExpression) &&
isDefined(fromSettings?.asExpression) &&
(toSettings.asExpression !== fromSettings.asExpression ||
toSettings.generatedType !== fromSettings.generatedType)
(toSettings.asExpression !== fromSettings?.asExpression ||
toSettings.generatedType !== fromSettings?.generatedType)
) {
await this.workspaceSchemaManagerService.columnManager.dropColumns({
queryRunner,