From 5f9435c71843fe0079002dfc8a0d4a87b8681108 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 3 Oct 2024 17:18:49 +0200 Subject: [PATCH] Search (#7237) Steps to test 1. Run metadata migrations 2. Run sync-metadata on your workspace 3. Enable the following feature flags: IS_SEARCH_ENABLED IS_QUERY_RUNNER_TWENTY_ORM_ENABLED IS_WORKSPACE_MIGRATED_FOR_SEARCH 4. Type Cmd + K and search anything --- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.tsx | 1 + .../command-menu/components/CommandMenu.tsx | 57 +++++++- .../ObjectMetadataItemsLoadEffect.tsx | 55 +++++--- .../types/RecordGqlOperationSearchResult.ts | 5 + .../object-record/hooks/useSearchRecords.ts | 94 +++++++++++++ .../hooks/useSearchRecordsQuery.ts | 33 +++++ .../utils/generateSearchRecordsQuery.ts | 40 ++++++ .../getSearchRecordsQueryResponseField.ts | 4 + .../src/modules/opportunities/Opportunity.ts | 8 ++ .../types/SettingsSupportedFieldType.ts | 2 +- .../modules/workspace/types/FeatureFlagKey.ts | 5 +- .../typeorm-seeds/core/feature-flags.ts | 10 ++ .../migrations/1725893697807-addIndexType.ts | 24 ++++ .../1726848397026-addTypeOrmMetadata.ts | 22 +++ ...709905-addIsCustomColumnToIndexMetadata.ts | 23 +++ .../graphql-query-runner.module.ts | 8 +- .../graphql-query-runner.service.ts | 18 +++ .../graphql-query-search-resolver.service.ts | 132 ++++++++++++++++++ .../factories/factories.ts | 10 +- .../factories/factories.ts | 3 + .../factories/search-resolver-factory.ts | 40 ++++++ .../workspace-resolvers-builder.interface.ts | 8 +- .../workspace-resolver.factory.ts | 3 + .../input-type-definition.factory.ts | 62 ++------ .../object-type-definition.factory.ts | 53 ++----- .../factories/root-type.factory.ts | 17 ++- .../utils/generate-fields.utils.ts | 100 +++++++++++++ .../utils/get-resolver-args.util.ts | 11 ++ .../enums/feature-flag-key.enum.ts | 2 + .../search-vector-field.constants.ts | 5 + .../field-metadata/field-metadata.entity.ts | 1 + .../interfaces/field-metadata.interface.ts | 2 + .../utils/compute-column-name.util.ts | 12 +- .../index-metadata/index-metadata.entity.ts | 16 +++ .../index-metadata/index-metadata.service.ts | 11 +- .../object-metadata/object-metadata.module.ts | 4 + .../object-metadata.service.ts | 96 ++++++++++++- .../relation-metadata.service.ts | 1 + .../identifier-max-char-length.constants.ts} | 0 ...lidate-database-identifier-length.utils.ts | 2 +- .../factories/factories.ts | 2 + .../ts-vector-column-action.factory.ts | 52 +++++++ ...field-metadata-type-to-column-type.util.ts | 2 + .../workspace-migration.entity.ts | 4 + .../workspace-migration.factory.ts | 6 + .../workspace-migration.module.ts | 8 +- .../twenty-orm/custom.workspace-entity.ts | 23 +++ .../decorators/workspace-field.decorator.ts | 4 + .../decorators/workspace-index.decorator.ts | 73 +++++++--- ...workspace-field-metadata-args.interface.ts | 10 ++ ...workspace-index-metadata-args.interface.ts | 7 + .../repository/workspace.repository.ts | 2 +- ...get-default-columns-for-index.util.spec.ts | 22 +++ .../get-default-columns-for-index.util.ts | 10 ++ .../engine/utils/get-resolver-name.util.ts | 2 + .../workspace-migration-index.factory.ts | 5 +- .../workspace-migration-runner.service.ts | 25 +++- .../comparators/workspace-field.comparator.ts | 2 + .../constants/standard-field-ids.ts | 4 + .../factories/standard-field.factory.ts | 2 + .../factories/standard-index.factory.ts | 82 +++++++++-- .../partial-field-metadata.interface.ts | 2 + .../workspace-sync-field-metadata.service.ts | 32 ++++- .../workspace-sync-index-metadata.service.ts | 64 ++++++--- .../standard-objects/index.ts | 8 -- ...ts-vectors-column-expression.utils.spec.ts | 103 ++++++++++++++ .../get-ts-vector-column-expression.util.ts | 88 ++++++++++++ .../company.workspace-entity.ts | 28 +++- .../opportunity.workspace-entity.ts | 21 +++ .../person.workspace-entity.ts | 32 ++++- 71 files changed, 1517 insertions(+), 209 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useSearchRecordsQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/generateSearchRecordsQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/getSearchRecordsQueryResponseField.ts create mode 100644 packages/twenty-front/src/modules/opportunities/Opportunity.ts create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1727699709905-addIsCustomColumnToIndexMetadata.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/constants/search-vector-field.constants.ts rename packages/twenty-server/src/engine/metadata-modules/utils/{metadata.constants.ts => constants/identifier-max-char-length.constants.ts} (100%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9ac0c5122..4f48fe60c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -369,6 +369,7 @@ export enum FieldMetadataType { RichText = 'RICH_TEXT', Select = 'SELECT', Text = 'TEXT', + TsVector = 'TS_VECTOR', Uuid = 'UUID' } diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 02d20ed6f..a1d58b1d9 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -280,6 +280,7 @@ export enum FieldMetadataType { RichText = 'RICH_TEXT', Select = 'SELECT', Text = 'TEXT', + TsVector = 'TS_VECTOR', Uuid = 'UUID' } diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 1c238462c..d6cf7ff02 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -16,7 +16,9 @@ import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeybo import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useSearchRecords } from '@/object-record/hooks/useSearchRecords'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; +import { Opportunity } from '@/opportunities/Opportunity'; import { Person } from '@/people/types/Person'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; @@ -165,8 +167,21 @@ export const CommandMenu = () => { [closeCommandMenu], ); - const { records: people } = useFindManyRecords({ - skip: !isCommandMenuOpened, + const isTwentyOrmEnabled = useIsFeatureEnabled( + 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED', + ); + + const isWorkspaceMigratedForSearch = useIsFeatureEnabled( + 'IS_WORKSPACE_MIGRATED_FOR_SEARCH', + ); + + const isSearchEnabled = + useIsFeatureEnabled('IS_SEARCH_ENABLED') && + isTwentyOrmEnabled && + isWorkspaceMigratedForSearch; + + const { records: peopleFromFindMany } = useFindManyRecords({ + skip: !isCommandMenuOpened || isSearchEnabled, objectNameSingular: CoreObjectNameSingular.Person, filter: commandMenuSearch ? makeOrFilterVariables([ @@ -183,9 +198,24 @@ export const CommandMenu = () => { : undefined, limit: 3, }); + const { records: peopleFromSearch } = useSearchRecords({ + skip: !isCommandMenuOpened || !isSearchEnabled, + objectNameSingular: CoreObjectNameSingular.Person, + limit: 3, + searchInput: commandMenuSearch ?? undefined, + }); - const { records: companies } = useFindManyRecords({ - skip: !isCommandMenuOpened, + const people = isSearchEnabled ? peopleFromSearch : peopleFromFindMany; + + const { records: companiesFromSearch } = useSearchRecords({ + skip: !isCommandMenuOpened || !isSearchEnabled, + objectNameSingular: CoreObjectNameSingular.Company, + limit: 3, + searchInput: commandMenuSearch ?? undefined, + }); + + const { records: companiesFromFindMany } = useFindManyRecords({ + skip: !isCommandMenuOpened || isSearchEnabled, objectNameSingular: CoreObjectNameSingular.Company, filter: commandMenuSearch ? { @@ -195,6 +225,10 @@ export const CommandMenu = () => { limit: 3, }); + const companies = isSearchEnabled + ? companiesFromSearch + : companiesFromFindMany; + const { records: notes } = useFindManyRecords({ skip: !isCommandMenuOpened, objectNameSingular: CoreObjectNameSingular.Note, @@ -207,8 +241,8 @@ export const CommandMenu = () => { limit: 3, }); - const { records: opportunities } = useFindManyRecords({ - skip: !isCommandMenuOpened, + const { records: opportunitiesFromFindMany } = useFindManyRecords({ + skip: !isCommandMenuOpened || isSearchEnabled, objectNameSingular: CoreObjectNameSingular.Opportunity, filter: commandMenuSearch ? { @@ -218,6 +252,17 @@ export const CommandMenu = () => { limit: 3, }); + const { records: opportunitiesFromSearch } = useSearchRecords({ + skip: !isCommandMenuOpened || !isSearchEnabled, + objectNameSingular: CoreObjectNameSingular.Opportunity, + limit: 3, + searchInput: commandMenuSearch ?? undefined, + }); + + const opportunities = isSearchEnabled + ? opportunitiesFromSearch + : opportunitiesFromFindMany; + const peopleCommands = useMemo( () => people.map(({ id, name: { firstName, lastName } }) => ({ diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx index ecc0772b8..dd1971790 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx @@ -1,16 +1,29 @@ import { useEffect } from 'react'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilCallback, useRecoilValue } from 'recoil'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { WorkspaceActivationStatus } from '~/generated/graphql'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +const filterTsVectorFields = ( + objectMetadataItems: ObjectMetadataItem[], +): ObjectMetadataItem[] => { + return objectMetadataItems.map((item) => ({ + ...item, + fields: item.fields.filter( + (field) => field.type !== FieldMetadataType.TsVector, + ), + })); +}; + export const ObjectMetadataItemsLoadEffect = () => { const currentUser = useRecoilValue(currentUserState); const currentWorkspace = useRecoilValue(currentWorkspaceState); @@ -21,26 +34,32 @@ export const ObjectMetadataItemsLoadEffect = () => { skip: !isLoggedIn, }); - const [objectMetadataItems, setObjectMetadataItems] = useRecoilState( - objectMetadataItemsState, + const updateObjectMetadataItems = useRecoilCallback( + ({ set, snapshot }) => + () => { + const filteredFields = filterTsVectorFields(newObjectMetadataItems); + const toSetObjectMetadataItems = + isUndefinedOrNull(currentUser) || + currentWorkspace?.activationStatus !== + WorkspaceActivationStatus.Active + ? generatedMockObjectMetadataItems + : filteredFields; + + if ( + !isDeeplyEqual( + snapshot.getLoadable(objectMetadataItemsState).getValue(), + toSetObjectMetadataItems, + ) + ) { + set(objectMetadataItemsState, toSetObjectMetadataItems); + } + }, + [currentUser, currentWorkspace?.activationStatus, newObjectMetadataItems], ); useEffect(() => { - const toSetObjectMetadataItems = - isUndefinedOrNull(currentUser) || - currentWorkspace?.activationStatus !== WorkspaceActivationStatus.Active - ? generatedMockObjectMetadataItems - : newObjectMetadataItems; - if (!isDeeplyEqual(objectMetadataItems, toSetObjectMetadataItems)) { - setObjectMetadataItems(toSetObjectMetadataItems); - } - }, [ - currentUser, - currentWorkspace?.activationStatus, - newObjectMetadataItems, - objectMetadataItems, - setObjectMetadataItems, - ]); + updateObjectMetadataItems(); + }, [updateObjectMetadataItems]); return <>; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts new file mode 100644 index 000000000..7cd2f5e31 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts @@ -0,0 +1,5 @@ +import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; + +export type RecordGqlOperationSearchResult = { + [objectNamePlural: string]: RecordGqlConnection; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts new file mode 100644 index 000000000..afedd99b8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts @@ -0,0 +1,94 @@ +import { useQuery, WatchQueryFetchPolicy } from '@apollo/client'; +import { useRecoilValue } from 'recoil'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { RecordGqlOperationSearchResult } from '@/object-record/graphql/types/RecordGqlOperationSearchResult'; +import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; +import { useSearchRecordsQuery } from '@/object-record/hooks/useSearchRecordsQuery'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useMemo } from 'react'; +import { logError } from '~/utils/logError'; + +export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & + RecordGqlOperationVariables & { + onError?: (error?: Error) => void; + skip?: boolean; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + fetchPolicy?: WatchQueryFetchPolicy; + searchInput?: string; + }; + +export const useSearchRecords = ({ + objectNameSingular, + searchInput, + limit, + skip, + recordGqlFields, + fetchPolicy, +}: UseSearchRecordsParams) => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + const { searchRecordsQuery } = useSearchRecordsQuery({ + objectNameSingular, + recordGqlFields, + }); + + const { enqueueSnackBar } = useSnackBar(); + + const { data, loading, error } = useQuery( + searchRecordsQuery, + { + skip: + skip || !objectMetadataItem || !currentWorkspaceMember || !searchInput, + variables: { + search: searchInput, + limit: limit, + }, + fetchPolicy: fetchPolicy, + onError: (error) => { + logError( + `useSearchRecords for "${objectMetadataItem.namePlural}" error : ` + + error, + ); + enqueueSnackBar( + `Error during useSearchRecords for "${objectMetadataItem.namePlural}", ${error.message}`, + { + variant: SnackBarVariant.Error, + }, + ); + }, + }, + ); + + const queryResponseField = getSearchRecordsQueryResponseField( + objectMetadataItem.namePlural, + ); + + const result = data?.[queryResponseField]; + + const records = useMemo( + () => + result + ? (getRecordsFromRecordConnection({ + recordConnection: result, + }) as T[]) + : [], + [result], + ); + + return { + objectMetadataItem, + records: records, + loading, + error, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useSearchRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useSearchRecordsQuery.ts new file mode 100644 index 000000000..6cc3972ca --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useSearchRecordsQuery.ts @@ -0,0 +1,33 @@ +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { generateSearchRecordsQuery } from '@/object-record/utils/generateSearchRecordsQuery'; + +export const useSearchRecordsQuery = ({ + objectNameSingular, + recordGqlFields, + computeReferences, +}: { + objectNameSingular: string; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + computeReferences?: boolean; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const searchRecordsQuery = generateSearchRecordsQuery({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields, + computeReferences, + }); + + return { + searchRecordsQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateSearchRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/utils/generateSearchRecordsQuery.ts new file mode 100644 index 000000000..070aaf020 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/generateSearchRecordsQuery.ts @@ -0,0 +1,40 @@ +import gql from 'graphql-tag'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField'; +import { capitalize } from '~/utils/string/capitalize'; + +export type QueryCursorDirection = 'before' | 'after'; + +export const generateSearchRecordsQuery = ({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields, + computeReferences, +}: { + objectMetadataItem: ObjectMetadataItem; + objectMetadataItems: ObjectMetadataItem[]; // TODO - what is this used for? + recordGqlFields?: RecordGqlOperationGqlRecordFields; + computeReferences?: boolean; +}) => gql` + query Search${capitalize(objectMetadataItem.namePlural)}($search: String, $limit: Int) { + ${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(searchInput: $search, limit: $limit){ + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + recordGqlFields, + computeReferences, + })} + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } +} +`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getSearchRecordsQueryResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getSearchRecordsQueryResponseField.ts new file mode 100644 index 000000000..fa6b7daf5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getSearchRecordsQueryResponseField.ts @@ -0,0 +1,4 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getSearchRecordsQueryResponseField = (objectNamePlural: string) => + `search${capitalize(objectNamePlural)}`; diff --git a/packages/twenty-front/src/modules/opportunities/Opportunity.ts b/packages/twenty-front/src/modules/opportunities/Opportunity.ts new file mode 100644 index 000000000..fe4212d56 --- /dev/null +++ b/packages/twenty-front/src/modules/opportunities/Opportunity.ts @@ -0,0 +1,8 @@ +export type Opportunity = { + __typename: 'Opportunity'; + id: string; + createdAt: string; + updatedAt?: string; + deletedAt?: string | null; + name: string | null; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts b/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts index 014960168..215e96ff1 100644 --- a/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts +++ b/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts @@ -2,5 +2,5 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export type SettingsSupportedFieldType = Exclude< FieldMetadataType, - FieldMetadataType.Position + FieldMetadataType.Position | FieldMetadataType.TsVector >; diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 409723f16..aca969f43 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -9,4 +9,7 @@ export type FeatureFlagKey = | 'IS_FREE_ACCESS_ENABLED' | 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED' | 'IS_WORKFLOW_ENABLED' - | 'IS_WORKSPACE_FAVORITE_ENABLED'; + | 'IS_WORKSPACE_FAVORITE_ENABLED' + | 'IS_SEARCH_ENABLED' + | 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED' + | 'IS_WORKSPACE_MIGRATED_FOR_SEARCH'; diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 8d3036235..b3068a265 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -60,6 +60,16 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IsSearchEnabled, + workspaceId: workspaceId, + value: true, + }, + { + key: FeatureFlagKey.IsWorkspaceMigratedForSearch, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts new file mode 100644 index 000000000..41edac1a5 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIndexType1725893697807 implements MigrationInterface { + name = 'AddIndexType1725893697807'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE metadata."indextype_enum" AS ENUM ('BTREE', 'GIN')`, + ); + + await queryRunner.query(` + ALTER TABLE metadata."indexMetadata" + ADD COLUMN "indexType" metadata."indextype_enum" NOT NULL DEFAULT 'BTREE'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE metadata."indexMetadata" DROP COLUMN "indexType" + `); + + await queryRunner.query(`DROP TYPE metadata."indextype_enum"`); + } +} diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts new file mode 100644 index 000000000..3fc52fb67 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTypeOrmMetadata1726848397026 implements MigrationInterface { + name = 'AddTypeOrmMetadata1726848397026'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "core"."typeorm_metadata" ( + "type" character varying NOT NULL, + "database" character varying, + "schema" character varying, + "table" character varying, + "name" character varying, + "value" text + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "core"."typeorm_metadata"`); + } +} diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1727699709905-addIsCustomColumnToIndexMetadata.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1727699709905-addIsCustomColumnToIndexMetadata.ts new file mode 100644 index 000000000..e40465dda --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1727699709905-addIsCustomColumnToIndexMetadata.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIsCustomColumnToIndexMetadata1727699709905 + implements MigrationInterface +{ + name = 'AddIsCustomColumnToIndexMetadata1727699709905'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "metadata"."indexMetadata" + ADD COLUMN "isCustom" BOOLEAN + NOT NULL + DEFAULT FALSE; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "metadata"."indexMetadata" + DROP COLUMN "isCustom" + `); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts index 96f15862e..28cb362ca 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts @@ -3,9 +3,13 @@ import { Module } from '@nestjs/common'; import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module'; import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; - +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @Module({ - imports: [WorkspaceQueryHookModule, WorkspaceQueryRunnerModule], + imports: [ + WorkspaceQueryHookModule, + WorkspaceQueryRunnerModule, + FeatureFlagModule, + ], providers: [GraphqlQueryRunnerService], exports: [GraphqlQueryRunnerService], }) diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts index 8eb4a614a..8c866695b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts @@ -14,6 +14,7 @@ import { FindManyResolverArgs, FindOneResolverArgs, ResolverArgsType, + SearchResolverArgs, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; @@ -21,6 +22,7 @@ import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/gr import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service'; import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; +import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service'; import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; import { CallWebhookJobsJob, @@ -36,6 +38,7 @@ import { import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; @@ -48,6 +51,7 @@ import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/worksp export class GraphqlQueryRunnerService { constructor( private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly featureFlagService: FeatureFlagService, private readonly workspaceQueryHookService: WorkspaceQueryHookService, private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory, private readonly workspaceEventEmitter: WorkspaceEventEmitter, @@ -178,6 +182,20 @@ export class GraphqlQueryRunnerService { return results?.[0] as ObjectRecord; } + @LogExecutionTime() + async search( + args: SearchResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise> { + const graphqlQuerySearchResolverService = + new GraphqlQuerySearchResolverService( + this.twentyORMGlobalManager, + this.featureFlagService, + ); + + return graphqlQuerySearchResolverService.search(args, options); + } + @LogExecutionTime() async createMany( args: CreateManyResolverArgs>, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts new file mode 100644 index 000000000..c8d1892bb --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts @@ -0,0 +1,132 @@ +import { + Record as IRecord, + OrderByDirection, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { SearchResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant'; +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; +import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +export class GraphqlQuerySearchResolverService { + private twentyORMGlobalManager: TwentyORMGlobalManager; + private featureFlagService: FeatureFlagService; + + constructor( + twentyORMGlobalManager: TwentyORMGlobalManager, + featureFlagService: FeatureFlagService, + ) { + this.twentyORMGlobalManager = twentyORMGlobalManager; + this.featureFlagService = featureFlagService; + } + + async search( + args: SearchResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise> { + const { authContext, objectMetadataItem, objectMetadataCollection } = + options; + + const featureFlagsForWorkspace = + await this.featureFlagService.getWorkspaceFeatureFlags( + authContext.workspace.id, + ); + + const isQueryRunnerTwentyORMEnabled = + featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED; + + const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED; + + if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) { + throw new GraphqlQueryRunnerException( + 'This endpoint is not available yet, please use findMany instead.', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + authContext.workspace.id, + objectMetadataItem.nameSingular, + ); + + const objectMetadataMap = generateObjectMetadataMap( + objectMetadataCollection, + ); + + const objectMetadata = objectMetadataMap[objectMetadataItem.nameSingular]; + + if (!objectMetadata) { + throw new GraphqlQueryRunnerException( + `Object metadata not found for ${objectMetadataItem.nameSingular}`, + GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND, + ); + } + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap); + + if (!args.searchInput) { + return typeORMObjectRecordsParser.createConnection( + [], + objectMetadataItem.nameSingular, + 0, + 0, + [{ id: OrderByDirection.AscNullsFirst }], + false, + false, + ); + } + const searchTerms = this.formatSearchTerms(args.searchInput); + + const limit = args?.limit ?? QUERY_MAX_RECORDS; + + const resultsWithTsVector = (await repository + .createQueryBuilder() + .where(`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery(:searchTerms)`, { + searchTerms, + }) + .orderBy( + `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, + 'DESC', + ) + .setParameter('searchTerms', searchTerms) + .limit(limit) + .getMany()) as ObjectRecord[]; + + const objectRecords = await repository.formatResult(resultsWithTsVector); + + const totalCount = await repository.count(); + const order = undefined; + + return typeORMObjectRecordsParser.createConnection( + objectRecords ?? [], + objectMetadataItem.nameSingular, + limit, + totalCount, + order, + false, + false, + ); + } + + private formatSearchTerms(searchTerm: string) { + const words = searchTerm.trim().split(/\s+/); + const formattedWords = words.map((word) => { + const escapedWord = word.replace(/[\\:'&|!()]/g, '\\$&'); + + return `${escapedWord}:*`; + }); + + return formattedWords.join(' | '); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts index e10fab44e..58c97cd26 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts @@ -2,18 +2,18 @@ import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/wor import { ArgsAliasFactory } from './args-alias.factory'; import { ArgsStringFactory } from './args-string.factory'; -import { RelationFieldAliasFactory } from './relation-field-alias.factory'; import { CreateManyQueryFactory } from './create-many-query.factory'; +import { DeleteManyQueryFactory } from './delete-many-query.factory'; import { DeleteOneQueryFactory } from './delete-one-query.factory'; import { FieldAliasFactory } from './field-alias.factory'; import { FieldsStringFactory } from './fields-string.factory'; +import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory'; import { FindManyQueryFactory } from './find-many-query.factory'; import { FindOneQueryFactory } from './find-one-query.factory'; -import { UpdateOneQueryFactory } from './update-one-query.factory'; -import { UpdateManyQueryFactory } from './update-many-query.factory'; -import { DeleteManyQueryFactory } from './delete-many-query.factory'; -import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory'; import { RecordPositionQueryFactory } from './record-position-query.factory'; +import { RelationFieldAliasFactory } from './relation-field-alias.factory'; +import { UpdateManyQueryFactory } from './update-many-query.factory'; +import { UpdateOneQueryFactory } from './update-one-query.factory'; export const workspaceQueryBuilderFactories = [ ArgsAliasFactory, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts index 1724242e8..b728ef898 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts @@ -1,6 +1,7 @@ import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory'; import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory'; import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory'; +import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { CreateManyResolverFactory } from './create-many-resolver.factory'; @@ -25,6 +26,7 @@ export const workspaceResolverBuilderFactories = [ DestroyOneResolverFactory, DestroyManyResolverFactory, RestoreManyResolverFactory, + SearchResolverFactory, ]; export const workspaceResolverBuilderMethodNames = { @@ -32,6 +34,7 @@ export const workspaceResolverBuilderMethodNames = { FindManyResolverFactory.methodName, FindOneResolverFactory.methodName, FindDuplicatesResolverFactory.methodName, + SearchResolverFactory.methodName, ], mutations: [ CreateManyResolverFactory.methodName, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts new file mode 100644 index 000000000..5b32d5279 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; +import { + Resolver, + SearchResolverArgs, +} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; + +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; +import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; + +@Injectable() +export class SearchResolverFactory + implements WorkspaceResolverBuilderFactoryInterface +{ + public static methodName = 'search' as const; + + constructor( + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, + ) {} + + create(context: WorkspaceSchemaBuilderContext): Resolver { + const internalContext = context; + + return async (_source, args, _context, info) => { + try { + return await this.graphqlQueryRunnerService.search(args, { + authContext: internalContext.authContext, + objectMetadataItem: internalContext.objectMetadataItem, + info, + fieldMetadataCollection: internalContext.fieldMetadataCollection, + objectMetadataCollection: internalContext.objectMetadataCollection, + }); + } catch (error) { + workspaceQueryRunnerGraphqlApiExceptionHandler(error); + } + }; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 22c070597..219b185c4 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -48,6 +48,11 @@ export interface FindDuplicatesResolverArgs< data?: Data[]; } +export interface SearchResolverArgs { + searchInput?: string; + limit?: number; +} + export interface CreateOneResolverArgs< Data extends Partial = Partial, > { @@ -123,4 +128,5 @@ export type ResolverArgs = | UpdateManyResolverArgs | UpdateOneResolverArgs | DestroyManyResolverArgs - | RestoreManyResolverArgs; + | RestoreManyResolverArgs + | SearchResolverArgs; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts index 6c06b85d9..616c73458 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts @@ -8,6 +8,7 @@ import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-reso import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory'; import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory'; import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory'; +import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; @@ -42,6 +43,7 @@ export class WorkspaceResolverFactory { private readonly deleteManyResolverFactory: DeleteManyResolverFactory, private readonly restoreManyResolverFactory: RestoreManyResolverFactory, private readonly destroyManyResolverFactory: DestroyManyResolverFactory, + private readonly searchResolverFactory: SearchResolverFactory, ) {} async create( @@ -65,6 +67,7 @@ export class WorkspaceResolverFactory { ['deleteMany', this.deleteManyResolverFactory], ['restoreMany', this.restoreManyResolverFactory], ['destroyMany', this.destroyManyResolverFactory], + ['search', this.searchResolverFactory], ]); const resolvers: IResolvers = { Query: {}, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts index 7db06ad36..67b3ee176 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts @@ -1,14 +1,12 @@ import { Inject, Injectable, forwardRef } from '@nestjs/common'; -import { GraphQLInputFieldConfigMap, GraphQLInputObjectType } from 'graphql'; +import { GraphQLInputObjectType } from 'graphql'; import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; -import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; -import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; +import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils'; import { pascalCase } from 'src/utils/pascal-case'; import { InputTypeFactory } from './input-type.factory'; @@ -55,7 +53,12 @@ export class InputTypeDefinitionFactory { }); return { - ...this.generateFields(objectMetadata, kind, options), + ...generateFields( + objectMetadata, + kind, + options, + this.inputTypeFactory, + ), and: { type: andOrType, }, @@ -73,7 +76,12 @@ export class InputTypeDefinitionFactory { * Other input types are generated with fields only */ default: - return this.generateFields(objectMetadata, kind, options); + return generateFields( + objectMetadata, + kind, + options, + this.inputTypeFactory, + ); } }, }); @@ -84,46 +92,4 @@ export class InputTypeDefinitionFactory { type: inputType, }; } - - private generateFields( - objectMetadata: ObjectMetadataInterface, - kind: InputTypeDefinitionKind, - options: WorkspaceBuildSchemaOptions, - ): GraphQLInputFieldConfigMap { - const fields: GraphQLInputFieldConfigMap = {}; - - for (const fieldMetadata of objectMetadata.fields) { - // Relation field types are generated during extension of object type definition - if (isRelationFieldMetadataType(fieldMetadata.type)) { - continue; - } - - const target = isCompositeFieldMetadataType(fieldMetadata.type) - ? fieldMetadata.type.toString() - : fieldMetadata.id; - - const isIdField = fieldMetadata.name === 'id'; - - const type = this.inputTypeFactory.create( - target, - fieldMetadata.type, - kind, - options, - { - nullable: fieldMetadata.isNullable, - defaultValue: fieldMetadata.defaultValue, - isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT, - settings: fieldMetadata.settings, - isIdField, - }, - ); - - fields[fieldMetadata.name] = { - type, - description: fieldMetadata.description, - }; - } - - return fields; - } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts index 1dbb46799..cf2efd3a7 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts @@ -1,14 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql'; +import { GraphQLObjectType } from 'graphql'; import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; +import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils'; import { pascalCase } from 'src/utils/pascal-case'; -import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; -import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { OutputTypeFactory } from './output-type.factory'; @@ -39,48 +37,13 @@ export class ObjectTypeDefinitionFactory { type: new GraphQLObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`, description: objectMetadata.description, - fields: this.generateFields(objectMetadata, kind, options), + fields: generateFields( + objectMetadata, + kind, + options, + this.outputTypeFactory, + ), }), }; } - - private generateFields( - objectMetadata: ObjectMetadataInterface, - kind: ObjectTypeDefinitionKind, - options: WorkspaceBuildSchemaOptions, - ): GraphQLFieldConfigMap { - const fields: GraphQLFieldConfigMap = {}; - - for (const fieldMetadata of objectMetadata.fields) { - // Relation field types are generated during extension of object type definition - if (isRelationFieldMetadataType(fieldMetadata.type)) { - continue; - } - - const target = isCompositeFieldMetadataType(fieldMetadata.type) - ? fieldMetadata.type.toString() - : fieldMetadata.id; - - const type = this.outputTypeFactory.create( - target, - fieldMetadata.type, - kind, - options, - { - nullable: fieldMetadata.isNullable, - isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT, - settings: fieldMetadata.settings, - // Scalar type is already defined in the entity itself. - isIdField: false, - }, - ); - - fields[fieldMetadata.name] = { - type, - description: fieldMetadata.description, - }; - } - - return fields; - } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts index c39111d3f..9f92a2e58 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts @@ -74,9 +74,7 @@ export class RootTypeFactory { const args = getResolverArgs(methodName); const objectType = this.typeDefinitionsStorage.getObjectTypeByKey( objectMetadata.id, - ['findMany', 'findDuplicates'].includes(methodName) - ? ObjectTypeDefinitionKind.Connection - : ObjectTypeDefinitionKind.Plain, + this.getObjectTypeDefinitionKindByMethodName(methodName), ); const argsType = this.argsFactory.create( { @@ -124,4 +122,17 @@ export class RootTypeFactory { return fieldConfigMap; } + + private getObjectTypeDefinitionKindByMethodName( + methodName: WorkspaceResolverBuilderMethodNames, + ): ObjectTypeDefinitionKind { + switch (methodName) { + case 'findMany': + case 'findDuplicates': + case 'search': + return ObjectTypeDefinitionKind.Connection; + default: + return ObjectTypeDefinitionKind.Plain; + } + } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts new file mode 100644 index 000000000..d27f0eba9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts @@ -0,0 +1,100 @@ +import { + GraphQLFieldConfigMap, + GraphQLInputFieldConfigMap, + GraphQLInputType, + GraphQLOutputType, +} from 'graphql'; + +import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface'; +import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; + +import { InputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory'; +import { ObjectTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; + +type TypeFactory = + { + create: ( + target: string, + fieldType: FieldMetadataType, + kind: T, + options: WorkspaceBuildSchemaOptions, + additionalOptions: { + nullable?: boolean; + defaultValue?: any; + isArray: boolean; + settings: any; + isIdField: boolean; + }, + ) => T extends InputTypeDefinitionKind + ? GraphQLInputType + : GraphQLOutputType; + }; + +export const generateFields = < + T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind, +>( + objectMetadata: ObjectMetadataInterface, + kind: T, + options: WorkspaceBuildSchemaOptions, + typeFactory: TypeFactory, +): T extends InputTypeDefinitionKind + ? GraphQLInputFieldConfigMap + : GraphQLFieldConfigMap => { + const fields = {}; + + for (const fieldMetadata of objectMetadata.fields) { + if ( + isRelationFieldMetadataType(fieldMetadata.type) || + fieldMetadata.type === FieldMetadataType.TS_VECTOR + ) { + continue; + } + + const target = isCompositeFieldMetadataType(fieldMetadata.type) + ? fieldMetadata.type.toString() + : fieldMetadata.id; + + const typeFactoryOptions = isInputTypeDefinitionKind(kind) + ? { + nullable: fieldMetadata.isNullable, + defaultValue: fieldMetadata.defaultValue, + isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT, + settings: fieldMetadata.settings, + isIdField: fieldMetadata.name === 'id', + } + : { + nullable: fieldMetadata.isNullable, + isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT, + settings: fieldMetadata.settings, + // Scalar type is already defined in the entity itself. + isIdField: false, + }; + + const type = typeFactory.create( + target, + fieldMetadata.type, + kind, + options, + typeFactoryOptions, + ); + + fields[fieldMetadata.name] = { + type, + description: fieldMetadata.description, + }; + } + + return fields; +}; + +// Type guard +const isInputTypeDefinitionKind = ( + kind: InputTypeDefinitionKind | ObjectTypeDefinitionKind, +): kind is InputTypeDefinitionKind => { + return Object.values(InputTypeDefinitionKind).includes( + kind as InputTypeDefinitionKind, + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index c829e0e40..7e1755218 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -137,6 +137,17 @@ export const getResolverArgs = ( isNullable: false, }, }; + case 'search': + return { + searchInput: { + type: GraphQLString, + isNullable: true, + }, + limit: { + type: GraphQLInt, + isNullable: true, + }, + }; default: throw new Error(`Unknown resolver type: ${type}`); } diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 7bd085cc4..397e92d7b 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -10,4 +10,6 @@ export enum FeatureFlagKey { IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', IsQueryRunnerTwentyORMEnabled = 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED', IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED', + IsSearchEnabled = 'IS_SEARCH_ENABLED', + IsWorkspaceMigratedForSearch = 'IS_WORKSPACE_MIGRATED_FOR_SEARCH', } diff --git a/packages/twenty-server/src/engine/metadata-modules/constants/search-vector-field.constants.ts b/packages/twenty-server/src/engine/metadata-modules/constants/search-vector-field.constants.ts new file mode 100644 index 000000000..498dca583 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/constants/search-vector-field.constants.ts @@ -0,0 +1,5 @@ +export const SEARCH_VECTOR_FIELD = { + name: 'searchVector', + label: 'Search vector', + description: 'Field used for full-text search', +} as const; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 3f5413aeb..5035fd3ed 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -47,6 +47,7 @@ export enum FieldMetadataType { RICH_TEXT = 'RICH_TEXT', ACTOR = 'ACTOR', ARRAY = 'ARRAY', + TS_VECTOR = 'TS_VECTOR', } @Entity('fieldMetadata') diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts index 7367a1dac..53a7a67a6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts @@ -22,4 +22,6 @@ export interface FieldMetadataInterface< fromRelationMetadata?: RelationMetadataEntity; toRelationMetadata?: RelationMetadataEntity; isCustom?: boolean; + generatedType?: 'STORED' | 'VIRTUAL'; + asExpression?: string; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/compute-column-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/compute-column-name.util.ts index 263448dab..bc24729a7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/compute-column-name.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/compute-column-name.util.ts @@ -11,6 +11,11 @@ import { pascalCase } from 'src/utils/pascal-case'; type ComputeColumnNameOptions = { isForeignKey?: boolean }; +export type FieldTypeAndNameMetadata = { + name: string; + type: FieldMetadataType; +}; + export function computeColumnName( fieldName: string, options?: ComputeColumnNameOptions, @@ -48,13 +53,16 @@ export function computeCompositeColumnName( export function computeCompositeColumnName< T extends FieldMetadataType | 'default', >( - fieldMetadata: FieldMetadataInterface, + fieldMetadata: FieldTypeAndNameMetadata | FieldMetadataInterface, compositeProperty: CompositeProperty, ): string; export function computeCompositeColumnName< T extends FieldMetadataType | 'default', >( - fieldMetadataOrFieldName: FieldMetadataInterface | string, + fieldMetadataOrFieldName: + | FieldTypeAndNameMetadata + | FieldMetadataInterface + | string, compositeProperty: CompositeProperty, ): string { const generateName = (name: string) => { diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts index 23f4e7bb8..7e44586d0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts @@ -13,6 +13,11 @@ import { import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +export enum IndexType { + BTREE = 'BTREE', + GIN = 'GIN', +} + @Entity('indexMetadata') export class IndexMetadataEntity { @PrimaryGeneratedColumn('uuid') @@ -48,4 +53,15 @@ export class IndexMetadataEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; + + @Column({ default: false }) + isCustom: boolean; + + @Column({ + type: 'enum', + enum: IndexType, + nullable: true, + default: IndexType.BTREE, + }) + indexType?: IndexType; } diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts index 97d9fdb9b..f84572c33 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts @@ -1,10 +1,14 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isDefined } from 'class-validator'; import { Repository } from 'typeorm'; 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 { + IndexMetadataEntity, + IndexType, +} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; @@ -28,6 +32,8 @@ export class IndexMetadataService { workspaceId: string, objectMetadata: ObjectMetadataEntity, fieldMetadataToIndex: Partial[], + isCustom: boolean, + indexType?: IndexType, ) { const tableName = computeObjectTargetTable(objectMetadata); @@ -53,6 +59,8 @@ export class IndexMetadataService { ), workspaceId, objectMetadataId: objectMetadata.id, + ...(isDefined(indexType) ? { indexType: indexType } : {}), + isCustom: isCustom, }); } catch (error) { throw new Error( @@ -74,6 +82,7 @@ export class IndexMetadataService { action: WorkspaceMigrationIndexActionType.CREATE, columns: columnNames, name: indexName, + type: indexType, }, ], } satisfies WorkspaceMigrationTableAction; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts index 94a721dec..14d9d58c2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts @@ -10,9 +10,11 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module'; import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook'; import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor'; import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver'; @@ -44,6 +46,8 @@ import { UpdateObjectPayload } from './dtos/update-object.input'; WorkspaceMigrationRunnerModule, WorkspaceMetadataVersionModule, RemoteTableRelationsModule, + IndexMetadataModule, + FeatureFlagModule, ], services: [ObjectMetadataService], resolvers: [ 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 c0240c6c2..b4102db85 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 @@ -5,19 +5,30 @@ import console from 'console'; import { Query, QueryOptions } from '@ptc-org/nestjs-query-core'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; +import { isDefined } from 'class-validator'; import { FindManyOptions, FindOneOptions, In, Repository } from 'typeorm'; import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { FieldMetadataEntity, FieldMetadataType, } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { + computeColumnName, + FieldTypeAndNameMetadata, +} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input'; import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; +import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants'; import { ObjectMetadataException, ObjectMetadataExceptionCode, @@ -33,6 +44,7 @@ import { RelationToDelete } from 'src/engine/metadata-modules/relation-metadata/ import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service'; import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util'; import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; +import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { WorkspaceMigrationColumnActionType, @@ -58,6 +70,7 @@ import { createForeignKeyDeterministicUuid, createRelationDeterministicUuid, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; @@ -79,9 +92,14 @@ export class ObjectMetadataService extends TypeOrmQueryService + field.id === createdObjectMetadata.labelIdentifierFieldMetadataId, + ) + : createdObjectMetadata.fields.find( + (field) => field.name === DEFAULT_LABEL_IDENTIFIER_FIELD_NAME, + ); + + if (!isDefined(searchableFieldForCustomObject)) { + throw new Error('No searchable field found for custom object'); + } + + this.workspaceMigrationService.createCustomMigration( + generateMigrationName(`create-${createdObjectMetadata.nameSingular}`), + createdObjectMetadata.workspaceId, + [ + { + name: computeTableName( + createdObjectMetadata.nameSingular, + createdObjectMetadata.isCustom, + ), + action: WorkspaceMigrationTableActionType.ALTER, + columns: this.tsVectorColumnActionFactory.handleCreateAction({ + ...searchVectorFieldMetadata, + defaultValue: undefined, + generatedType: 'STORED', + asExpression: getTsVectorColumnExpressionFromFields([ + searchableFieldForCustomObject as FieldTypeAndNameMetadata, + ]), + options: undefined, + } as FieldMetadataInterface), + }, + ], + ); + + await this.indexMetadataService.createIndex( + objectMetadataInput.workspaceId, + createdObjectMetadata, + [searchVectorFieldMetadata], + false, + IndexType.GIN, + ); + } + private async createActivityTargetRelation( workspaceId: string, createdObjectMetadata: ObjectMetadataEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts index affe7e1b7..474c2f63f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts @@ -153,6 +153,7 @@ export class RelationMetadataService extends TypeOrmQueryService { return string.length > IDENTIFIER_MAX_CHAR_LENGTH; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts index 5bda4bac1..3dcbe1fac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts @@ -1,8 +1,10 @@ import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory'; import { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; +import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory'; export const workspaceColumnActionFactories = [ + TsVectorColumnActionFactory, BasicColumnActionFactory, EnumColumnActionFactory, CompositeColumnActionFactory, diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory.ts new file mode 100644 index 000000000..dd72948f3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/workspace-migration/interfaces/workspace-column-action-options.interface'; + +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { ColumnActionAbstractFactory } from 'src/engine/metadata-modules/workspace-migration/factories/column-action-abstract.factory'; +import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util'; +import { + WorkspaceMigrationColumnActionType, + WorkspaceMigrationColumnAlter, + WorkspaceMigrationColumnCreate, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; +import { + WorkspaceMigrationException, + WorkspaceMigrationExceptionCode, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.exception'; + +export type TsVectorFieldMetadataType = FieldMetadataType.TS_VECTOR; + +@Injectable() +export class TsVectorColumnActionFactory extends ColumnActionAbstractFactory { + protected readonly logger = new Logger(TsVectorColumnActionFactory.name); + + handleCreateAction( + fieldMetadata: FieldMetadataInterface, + ): WorkspaceMigrationColumnCreate[] { + return [ + { + action: WorkspaceMigrationColumnActionType.CREATE, + columnName: computeColumnName(fieldMetadata), + columnType: fieldMetadataTypeToColumnType(fieldMetadata.type), + isNullable: fieldMetadata.isNullable ?? true, + defaultValue: undefined, + generatedType: fieldMetadata.generatedType, + asExpression: fieldMetadata.asExpression, + }, + ]; + } + + protected handleAlterAction( + _currentFieldMetadata: FieldMetadataInterface, + _alteredFieldMetadata: FieldMetadataInterface, + _options?: WorkspaceColumnActionOptions, + ): WorkspaceMigrationColumnAlter[] { + throw new WorkspaceMigrationException( + `TsVectorColumnActionFactory.handleAlterAction has not been implemented yet.`, + WorkspaceMigrationExceptionCode.INVALID_FIELD_METADATA, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts index 9d3ff6276..67955d0bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts @@ -38,6 +38,8 @@ export const fieldMetadataTypeToColumnType = ( return 'enum'; case FieldMetadataType.RAW_JSON: return 'jsonb'; + case FieldMetadataType.TS_VECTOR: + return 'tsvector'; default: throw new WorkspaceMigrationException( `Cannot convert ${fieldMetadataType} to column type.`, diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts index 0c1177fb7..e731f8cc0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts @@ -5,6 +5,7 @@ import { PrimaryGeneratedColumn, } from 'typeorm'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; export enum WorkspaceMigrationColumnActionType { @@ -30,12 +31,15 @@ export interface WorkspaceMigrationColumnDefinition { isArray?: boolean; isNullable: boolean; defaultValue: any; + generatedType?: 'STORED' | 'VIRTUAL'; + asExpression?: string; } export interface WorkspaceMigrationIndexAction { action: WorkspaceMigrationIndexActionType; name: string; columns: string[]; + type?: IndexType; } export interface WorkspaceMigrationColumnCreate diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts index 6f33cf013..53aa564e4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts @@ -8,6 +8,7 @@ import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/fi import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory'; import { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; +import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory'; import { WorkspaceMigrationColumnAction, WorkspaceMigrationColumnActionType, @@ -30,6 +31,7 @@ export class WorkspaceMigrationFactory { constructor( private readonly basicColumnActionFactory: BasicColumnActionFactory, + private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory, private readonly enumColumnActionFactory: EnumColumnActionFactory, private readonly compositeColumnActionFactory: CompositeColumnActionFactory, ) { @@ -106,6 +108,10 @@ export class WorkspaceMigrationFactory { FieldMetadataType.PHONES, { factory: this.compositeColumnActionFactory }, ], + [ + FieldMetadataType.TS_VECTOR, + { factory: this.tsVectorColumnActionFactory }, + ], ]); } diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.module.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.module.ts index 9e0d4072c..e7ecd97b0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.module.ts @@ -4,8 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { workspaceColumnActionFactories } from 'src/engine/metadata-modules/workspace-migration/factories/factories'; import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; -import { WorkspaceMigrationService } from './workspace-migration.service'; import { WorkspaceMigrationEntity } from './workspace-migration.entity'; +import { WorkspaceMigrationService } from './workspace-migration.service'; @Module({ imports: [TypeOrmModule.forFeature([WorkspaceMigrationEntity], 'metadata')], @@ -14,6 +14,10 @@ import { WorkspaceMigrationEntity } from './workspace-migration.entity'; WorkspaceMigrationFactory, WorkspaceMigrationService, ], - exports: [WorkspaceMigrationFactory, WorkspaceMigrationService], + exports: [ + ...workspaceColumnActionFactories, + WorkspaceMigrationFactory, + WorkspaceMigrationService, + ], }) export class WorkspaceMigrationModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts index 0b9b5201d..efbfed843 100644 --- a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts +++ b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts @@ -1,8 +1,11 @@ +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata, FieldActorSource, } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants'; import { RelationMetadataType, RelationOnDeleteAction, @@ -10,10 +13,12 @@ import { import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; @@ -136,4 +141,22 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsNullable() @WorkspaceIsSystem() timelineActivities: TimelineActivityWorkspaceEntity[]; + + @WorkspaceField({ + standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector, + type: FieldMetadataType.TS_VECTOR, + label: SEARCH_VECTOR_FIELD.label, + description: SEARCH_VECTOR_FIELD.description, + generatedType: 'STORED', + asExpression: getTsVectorColumnExpressionFromFields([ + { + name: DEFAULT_LABEL_IDENTIFIER_FIELD_NAME, + type: FieldMetadataType.TEXT, + }, + ]), + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + @WorkspaceIndex({ indexType: IndexType.GIN }) + [SEARCH_VECTOR_FIELD.name]: any; } diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts index c6dd8bfd7..c0ffcc5df 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field.decorator.ts @@ -20,6 +20,8 @@ export interface WorkspaceFieldOptions< options?: FieldMetadataOptions; settings?: FieldMetadataSettings; isActive?: boolean; + generatedType?: 'STORED' | 'VIRTUAL'; + asExpression?: string; } export function WorkspaceField( @@ -76,6 +78,8 @@ export function WorkspaceField( gate, isDeprecated, isActive: options.isActive, + asExpression: options.asExpression, + generatedType: options.generatedType, }); }; } diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts index bf2e43201..ef39e0cce 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts @@ -1,18 +1,36 @@ +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; +import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util'; import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util'; +import { isDefined } from 'src/utils/is-defined'; import { TypedReflect } from 'src/utils/typed-reflect'; -export function WorkspaceIndex(): PropertyDecorator; -export function WorkspaceIndex(columns: string[]): ClassDecorator; +export type WorkspaceIndexMetadata = { + columns?: string[]; + indexType?: IndexType; +}; + export function WorkspaceIndex( - columns?: string[], + metadata?: WorkspaceIndexMetadata, +): PropertyDecorator; +export function WorkspaceIndex( + metadata: WorkspaceIndexMetadata, +): ClassDecorator; +export function WorkspaceIndex( + metadata?: WorkspaceIndexMetadata, ): PropertyDecorator | ClassDecorator { return (target: any, propertyKey: string | symbol) => { - if (propertyKey === undefined && columns === undefined) { + if (propertyKey === undefined && metadata === undefined) { throw new Error('Class level WorkspaceIndex should be used with columns'); } + if (propertyKey !== undefined && metadata?.columns !== undefined) { + throw new Error( + 'Property level WorkspaceIndex should not be used with columns', + ); + } + const gate = TypedReflect.getMetadata( 'workspace:gate-metadata-args', target, @@ -20,29 +38,46 @@ export function WorkspaceIndex( ); // TODO: handle composite field metadata types + if (isDefined(metadata?.columns)) { + const columns = metadata.columns; + + if (columns.length > 0) { + metadataArgsStorage.addIndexes({ + name: `IDX_${generateDeterministicIndexName([ + convertClassNameToObjectMetadataName(target.name), + ...columns, + ])}`, + columns, + target: target, + gate, + ...(isDefined(metadata?.indexType) + ? { type: metadata.indexType } + : {}), + }); + + return; + } + } + + if (isDefined(propertyKey)) { + const additionalDefaultColumnsForIndex = getColumnsForIndex( + metadata?.indexType, + ); + const columns = [ + propertyKey.toString(), + ...additionalDefaultColumnsForIndex, + ]; - if (Array.isArray(columns) && columns.length > 0) { metadataArgsStorage.addIndexes({ name: `IDX_${generateDeterministicIndexName([ - convertClassNameToObjectMetadataName(target.name), + convertClassNameToObjectMetadataName(target.constructor.name), ...columns, ])}`, columns, - target: target, + target: target.constructor, + ...(isDefined(metadata?.indexType) ? { type: metadata.indexType } : {}), gate, }); - - return; } - - metadataArgsStorage.addIndexes({ - name: `IDX_${generateDeterministicIndexName([ - convertClassNameToObjectMetadataName(target.constructor.name), - ...[propertyKey.toString(), 'deletedAt'], - ])}`, - columns: [propertyKey.toString(), 'deletedAt'], - target: target.constructor, - gate, - }); }; } diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts index 09c339c93..46ad1132d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface.ts @@ -89,4 +89,14 @@ export interface WorkspaceFieldMetadataArgs { * Is active field. */ readonly isActive?: boolean; + + /** + * Is active field. + */ + readonly generatedType?: 'STORED' | 'VIRTUAL'; + + /** + * Is active field. + */ + readonly asExpression?: string; } diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts index 9412e417c..0ad260070 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts @@ -1,5 +1,7 @@ import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; + export interface WorkspaceIndexMetadataArgs { /** * Class to which index is applied. @@ -17,6 +19,11 @@ export interface WorkspaceIndexMetadataArgs { */ columns: string[]; + /* + * Index type. Defaults to Btree. + */ + type?: IndexType; + /** * Field gate. */ diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index ea3c3f9e8..bb4327cc8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -664,7 +664,7 @@ export class WorkspaceRepository< return formatData(data, objectMetadata) as T; } - private async formatResult( + async formatResult( data: T, objectMetadata?: ObjectMetadataMapItem, ): Promise { diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts new file mode 100644 index 000000000..fad8a9cd1 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts @@ -0,0 +1,22 @@ +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util'; + +describe('getColumnsForIndex', () => { + it('should return ["deletedAt"] when indexType is undefined', () => { + const result = getColumnsForIndex(); + + expect(result).toEqual(['deletedAt']); + }); + + it('should return an empty array when indexType is IndexType.GIN', () => { + const result = getColumnsForIndex(IndexType.GIN); + + expect(result).toEqual([]); + }); + + it('should return ["deletedAt"] when indexType is IndexType.BTREE', () => { + const result = getColumnsForIndex(IndexType.BTREE); + + expect(result).toEqual(['deletedAt']); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts new file mode 100644 index 000000000..b97d086a6 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts @@ -0,0 +1,10 @@ +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; + +export const getColumnsForIndex = (indexType?: IndexType) => { + switch (indexType) { + case IndexType.GIN: + return []; + default: + return ['deletedAt']; + } +}; diff --git a/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts b/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts index 77a30372a..f274475e7 100644 --- a/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts +++ b/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts @@ -33,6 +33,8 @@ export const getResolverName = ( return `delete${pascalCase(objectMetadata.namePlural)}`; case 'destroyMany': return `destroy${pascalCase(objectMetadata.namePlural)}`; + case 'search': + return `search${pascalCase(objectMetadata.namePlural)}`; default: throw new Error(`Unknown resolver type: ${type}`); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory.ts index 0392041ed..206e5097a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory.ts @@ -2,15 +2,15 @@ import { Injectable } from '@nestjs/common'; import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface'; +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 { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { WorkspaceMigrationEntity, WorkspaceMigrationIndexActionType, WorkspaceMigrationTableActionType, } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; -import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; -import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; @Injectable() export class WorkspaceMigrationIndexFactory { @@ -94,6 +94,7 @@ export class WorkspaceMigrationIndexFactory { return fieldMetadata.name; }), + type: indexMetadata.indexType, })); workspaceMigrations.push({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts index 3fec17109..2d6a20e14 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts @@ -26,6 +26,7 @@ import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceMigrationEnumService } from 'src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service'; import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util'; +import { isDefined } from 'src/utils/is-defined'; import { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service'; import { customTableDefaultColumns } from './utils/custom-table-default-column.util'; @@ -194,13 +195,21 @@ export class WorkspaceMigrationRunnerService { for (const index of indexes) { switch (index.action) { case WorkspaceMigrationIndexActionType.CREATE: - await queryRunner.createIndex( - `${schemaName}.${tableName}`, - new TableIndex({ - name: index.name, - columnNames: index.columns, - }), - ); + if (isDefined(index.type)) { + const quotedColumns = index.columns.map((column) => `"${column}"`); + + await queryRunner.query(` + CREATE INDEX "${index.name}" ON "${schemaName}"."${tableName}" USING ${index.type} (${quotedColumns.join(', ')}) + `); + } else { + await queryRunner.createIndex( + `${schemaName}.${tableName}`, + new TableIndex({ + name: index.name, + columnNames: index.columns, + }), + ); + } break; case WorkspaceMigrationIndexActionType.DROP: try { @@ -380,6 +389,8 @@ export class WorkspaceMigrationRunnerService { enumName: enumName, isArray: migrationColumn.isArray, isNullable: migrationColumn.isNullable, + asExpression: migrationColumn.asExpression, + generatedType: migrationColumn.generatedType, }), ); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts index 7e51a398f..d26c3aa8b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts @@ -24,6 +24,8 @@ const commonFieldPropertiesToIgnore = [ 'settings', 'joinColumn', 'gate', + 'asExpression', + 'generatedType', ]; const fieldPropertiesToStringify = ['defaultValue'] as const; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 890db119e..d9359e6eb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -135,6 +135,7 @@ export const COMPANY_STANDARD_FIELD_IDS = { favorites: '20202020-4d1d-41ac-b13b-621631298d55', attachments: '20202020-c1b5-4120-b0f0-987ca401ed53', timelineActivities: '20202020-0414-4daf-9c0d-64fe7b27f89f', + searchVector: '85c71601-72f9-4b7b-b343-d46100b2c74d', }; export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = { @@ -300,6 +301,7 @@ export const OPPORTUNITY_STANDARD_FIELD_IDS = { noteTargets: '20202020-dd3f-42d5-a382-db58aabf43d3', attachments: '20202020-87c7-4118-83d6-2f4031005209', timelineActivities: '20202020-30e2-421f-96c7-19c69d1cf631', + searchVector: '428a0da5-4b2e-4ce3-b695-89a8b384e6e3', }; export const PERSON_STANDARD_FIELD_IDS = { @@ -325,6 +327,7 @@ export const PERSON_STANDARD_FIELD_IDS = { messageParticipants: '20202020-498e-4c61-8158-fa04f0638334', calendarEventParticipants: '20202020-52ee-45e9-a702-b64b3753e3a9', timelineActivities: '20202020-a43e-4873-9c23-e522de906ce5', + searchVector: '57d1d7ad-fa10-44fc-82f3-ad0959ec2534', }; export const TASK_STANDARD_FIELD_IDS = { @@ -463,4 +466,5 @@ export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = { favorites: '20202020-a4a7-4686-b296-1c6c3482ee21', attachments: '20202020-8d59-46ca-b7b2-73d167712134', timelineActivities: '20202020-f1ef-4ba4-8f33-1a4577afa477', + searchVector: '70e56537-18ef-4811-b1c7-0a444006b815', }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts index 9cbe04fd1..28c6ca744 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts @@ -166,6 +166,8 @@ export class StandardFieldFactory { isCustom: workspaceFieldMetadataArgs.isDeprecated ? true : false, isSystem: workspaceFieldMetadataArgs.isSystem ?? false, isActive: workspaceFieldMetadataArgs.isActive ?? true, + asExpression: workspaceFieldMetadataArgs.asExpression, + generatedType: workspaceFieldMetadataArgs.generatedType, }, ]; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts index 17bd00215..f3d89d031 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts @@ -5,9 +5,12 @@ import { PartialIndexMetadata } from 'src/engine/workspace-manager/workspace-syn import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; +import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; +import { computeTableName } from 'src/engine/utils/compute-table-name.util'; import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; @Injectable() @@ -15,23 +18,37 @@ export class StandardIndexFactory { create( standardObjectMetadataDefinitions: (typeof BaseWorkspaceEntity)[], context: WorkspaceSyncContext, - originalObjectMetadataMap: Record, + originalStandardObjectMetadataMap: Record, + originalCustomObjectMetadataMap: Record, workspaceFeatureFlagsMap: FeatureFlagMap, ): Partial[] { - return standardObjectMetadataDefinitions.flatMap((standardObjectMetadata) => - this.createIndexMetadata( - standardObjectMetadata, + const standardIndexOnStandardObjects = + standardObjectMetadataDefinitions.flatMap((standardObjectMetadata) => + this.createStandardIndexMetadataForStandardObject( + standardObjectMetadata, + context, + originalStandardObjectMetadataMap, + workspaceFeatureFlagsMap, + ), + ); + + const standardIndexesOnCustomObjects = + this.createStandardIndexMetadataForCustomObject( context, - originalObjectMetadataMap, + originalCustomObjectMetadataMap, workspaceFeatureFlagsMap, - ), - ); + ); + + return [ + standardIndexOnStandardObjects, + standardIndexesOnCustomObjects, + ].flat(); } - private createIndexMetadata( + private createStandardIndexMetadataForStandardObject( target: typeof BaseWorkspaceEntity, context: WorkspaceSyncContext, - originalObjectMetadataMap: Record, + originalStandardObjectMetadataMap: Record, workspaceFeatureFlagsMap: FeatureFlagMap, ): Partial[] { const workspaceEntity = metadataArgsStorage.filterEntities(target); @@ -58,7 +75,7 @@ export class StandardIndexFactory { return workspaceIndexMetadataArgsCollection.map( (workspaceIndexMetadataArgs) => { const objectMetadata = - originalObjectMetadataMap[workspaceEntity.nameSingular]; + originalStandardObjectMetadataMap[workspaceEntity.nameSingular]; if (!objectMetadata) { throw new Error( @@ -71,10 +88,55 @@ export class StandardIndexFactory { objectMetadataId: objectMetadata.id, name: workspaceIndexMetadataArgs.name, columns: workspaceIndexMetadataArgs.columns, + isCustom: false, + indexType: workspaceIndexMetadataArgs.type, }; return indexMetadata; }, ); } + + private createStandardIndexMetadataForCustomObject( + context: WorkspaceSyncContext, + originalCustomObjectMetadataMap: Record, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Partial[] { + const target = CustomWorkspaceEntity; + const workspaceEntity = metadataArgsStorage.filterExtendedEntities(target); + + if (!workspaceEntity) { + throw new Error( + `Object metadata decorator not found, can't parse ${target.name}`, + ); + } + + const workspaceIndexMetadataArgsCollection = metadataArgsStorage + .filterIndexes(target) + .filter((workspaceIndexMetadataArgs) => { + return !isGatedAndNotEnabled( + workspaceIndexMetadataArgs.gate, + workspaceFeatureFlagsMap, + ); + }); + + return Object.entries(originalCustomObjectMetadataMap).flatMap( + ([customObjectName, customObjectMetadata]) => { + return workspaceIndexMetadataArgsCollection.map( + (workspaceIndexMetadataArgs) => { + const indexMetadata: PartialIndexMetadata = { + workspaceId: context.workspaceId, + objectMetadataId: customObjectMetadata.id, + name: `IDX_${generateDeterministicIndexName([computeTableName(customObjectName, true), ...workspaceIndexMetadataArgs.columns])}`, + columns: workspaceIndexMetadataArgs.columns, + isCustom: false, + indexType: workspaceIndexMetadataArgs.type, + }; + + return indexMetadata; + }, + ); + }, + ); + } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface.ts index 81722ad20..4f17d0ee3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface.ts @@ -16,6 +16,8 @@ export type PartialFieldMetadata = Omit< workspaceId: string; objectMetadataId?: string; isActive?: boolean; + asExpression?: string; + generatedType?: 'STORED' | 'VIRTUAL'; }; export type PartialComputedFieldMetadata = { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service.ts index d82e2bf69..4e10f7ea2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service.ts @@ -10,6 +10,7 @@ import { } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; @@ -143,13 +144,27 @@ export class WorkspaceSyncFieldMetadataService { ] of standardObjectStandardFieldMetadataMap) { const originalObjectMetadata = originalObjectMetadataMap[standardObjectId]; - const computedStandardFieldMetadataCollection = computeStandardFields( + + let computedStandardFieldMetadataCollection = computeStandardFields( standardFieldMetadataCollection, originalObjectMetadata, // We need to provide this for generated relations with custom objects customObjectMetadataCollection, ); + let originalObjectMetadataFields = originalObjectMetadata.fields; + + if (!workspaceFeatureFlagsMap.IS_SEARCH_ENABLED) { + computedStandardFieldMetadataCollection = + computedStandardFieldMetadataCollection.filter( + (field) => field.type !== FieldMetadataType.TS_VECTOR, + ); + + originalObjectMetadataFields = originalObjectMetadataFields.filter( + (field) => field.type !== FieldMetadataType.TS_VECTOR, + ); + } + const fieldComparatorResults = this.workspaceFieldComparator.compare( originalObjectMetadata.id, originalObjectMetadata.fields, @@ -177,11 +192,24 @@ export class WorkspaceSyncFieldMetadataService { // Loop over all custom objects from the DB and compare their fields with standard fields for (const customObjectMetadata of customObjectMetadataCollection) { // Also, maybe it's better to refactor a bit and move generation part into a separate module ? - const standardFieldMetadataCollection = computeStandardFields( + let standardFieldMetadataCollection = computeStandardFields( customObjectStandardFieldMetadataCollection, customObjectMetadata, ); + let customObjectMetadataFields = customObjectMetadata.fields; + + if (!workspaceFeatureFlagsMap.IS_SEARCH_ENABLED) { + standardFieldMetadataCollection = + standardFieldMetadataCollection.filter( + (field) => field.type !== FieldMetadataType.TS_VECTOR, + ); + + customObjectMetadataFields = customObjectMetadataFields.filter( + (field) => field.type !== FieldMetadataType.TS_VECTOR, + ); + } + /** * COMPARE FIELD METADATA */ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-index-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-index-metadata.service.ts index 4f7a30ef9..a5357d160 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-index-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-index-metadata.service.ts @@ -1,22 +1,25 @@ import { Injectable, Logger } from '@nestjs/common'; -import { EntityManager } from 'typeorm'; +import { Any, EntityManager } from 'typeorm'; import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; -import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; -import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface'; +import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; +import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; -import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; -import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage'; -import { StandardIndexFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory'; +import { + IndexMetadataEntity, + IndexType, +} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { mapObjectMetadataByUniqueIdentifier } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/sync-metadata.util'; -import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; -import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects'; -import { WorkspaceIndexComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-index.comparator'; -import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service'; +import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationIndexFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory'; +import { WorkspaceIndexComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-index.comparator'; +import { StandardIndexFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory'; +import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service'; +import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects'; +import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage'; +import { mapObjectMetadataByUniqueIdentifier } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/sync-metadata.util'; @Injectable() export class WorkspaceSyncIndexMetadataService { @@ -47,35 +50,60 @@ export class WorkspaceSyncIndexMetadataService { workspaceId: context.workspaceId, // We're only interested in standard fields fields: { isCustom: false }, - isCustom: false, }, relations: ['dataSource', 'fields', 'indexes'], }); // Create map of object metadata & field metadata by unique identifier - const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier( - originalObjectMetadataCollection, - // Relation are based on the singular name + const originalStandardObjectMetadataMap = + mapObjectMetadataByUniqueIdentifier( + originalObjectMetadataCollection.filter( + (objectMetadata) => !objectMetadata.isCustom, + ), + // Relation are based on the singular name + (objectMetadata) => objectMetadata.nameSingular, + ); + + const originalCustomObjectMetadataMap = mapObjectMetadataByUniqueIdentifier( + originalObjectMetadataCollection.filter( + (objectMetadata) => objectMetadata.isCustom, + ), (objectMetadata) => objectMetadata.nameSingular, ); const indexMetadataRepository = manager.getRepository(IndexMetadataEntity); - const originalIndexMetadataCollection = await indexMetadataRepository.find({ + let originalIndexMetadataCollection = await indexMetadataRepository.find({ where: { workspaceId: context.workspaceId, + objectMetadataId: Any( + Object.values(originalObjectMetadataCollection).map( + (object) => object.id, + ), + ), + isCustom: false, }, relations: ['indexFieldMetadatas.fieldMetadata'], }); // Generate index metadata from models - const standardIndexMetadataCollection = this.standardIndexFactory.create( + let standardIndexMetadataCollection = this.standardIndexFactory.create( standardObjectMetadataDefinitions, context, - originalObjectMetadataMap, + originalStandardObjectMetadataMap, + originalCustomObjectMetadataMap, workspaceFeatureFlagsMap, ); + if (!workspaceFeatureFlagsMap.IS_SEARCH_ENABLED) { + originalIndexMetadataCollection = originalIndexMetadataCollection.filter( + (index) => index.indexType !== IndexType.GIN, + ); + + standardIndexMetadataCollection = standardIndexMetadataCollection.filter( + (index) => index.indexType !== IndexType.GIN, + ); + } const indexComparatorResults = this.workspaceIndexComparator.compare( originalIndexMetadataCollection, standardIndexMetadataCollection, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index d510e4f2d..914ee491e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -54,8 +54,6 @@ export const standardObjectMetadataDefinitions = [ CompanyWorkspaceEntity, ConnectedAccountWorkspaceEntity, FavoriteWorkspaceEntity, - OpportunityWorkspaceEntity, - PersonWorkspaceEntity, TimelineActivityWorkspaceEntity, ViewFieldWorkspaceEntity, ViewFilterWorkspaceEntity, @@ -79,10 +77,4 @@ export const standardObjectMetadataDefinitions = [ PersonWorkspaceEntity, TaskWorkspaceEntity, TaskTargetWorkspaceEntity, - TimelineActivityWorkspaceEntity, - ViewFieldWorkspaceEntity, - ViewFilterWorkspaceEntity, - ViewSortWorkspaceEntity, - ViewWorkspaceEntity, - WebhookWorkspaceEntity, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts new file mode 100644 index 000000000..8c7594b49 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts @@ -0,0 +1,103 @@ +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; + +const nameTextField = { name: 'name', type: FieldMetadataType.TEXT }; +const nameFullNameField = { + name: 'name', + type: FieldMetadataType.FULL_NAME, +}; +const jobTitleTextField = { name: 'jobTitle', type: FieldMetadataType.TEXT }; +const emailsEmailsField = { name: 'emails', type: FieldMetadataType.EMAILS }; + +jest.mock( + 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util', + () => ({ + computeColumnName: jest.fn((name) => { + if (name === 'name') { + return 'name'; + } + if (name === 'jobTitle') { + return 'jobTitle'; + } + if (name === 'emailsPrimaryEmail') { + return 'emailsPrimaryEmail'; + } + if (name === 'emailsAdditionalEmails') { + return 'emailsAdditionalEmails'; + } + if (name === 'nameFirstName') { + return 'nameFirstName'; + } + if (name === 'nameLastName') { + return 'nameLastName'; + } + }), + computeCompositeColumnName: jest.fn((field, property) => { + if ( + field.name === emailsEmailsField.name && + property.name === 'primaryEmail' + ) { + return 'emailsPrimaryEmail'; + } + if ( + field.name === emailsEmailsField.name && + property.name === 'additionalEmails' + ) { + return 'emailsAdditionalEmails'; + } + if ( + field.name === nameFullNameField.name && + property.name === 'firstName' + ) { + return 'nameFirstName'; + } + if ( + field.name === nameFullNameField.name && + property.name === 'lastName' + ) { + return 'nameLastName'; + } + }), + }), +); + +describe('getTsVectorColumnExpressionFromFields', () => { + it('should generate correct expression for simple text field', () => { + const fields = [nameTextField]; + const result = getTsVectorColumnExpressionFromFields(fields); + + expect(result).toContain("to_tsvector('simple', COALESCE(\"name\", ''))"); + }); + + it('should handle multiple fields', () => { + const fields = [nameFullNameField, jobTitleTextField, emailsEmailsField]; + const result = getTsVectorColumnExpressionFromFields(fields); + const expected = ` + CASE + WHEN "deletedAt" IS NULL THEN + to_tsvector('simple', COALESCE("nameFirstName", '') || ' ' || COALESCE("nameLastName", '') || ' ' || COALESCE("jobTitle", '') || ' ' || + COALESCE( + replace( + "emailsPrimaryEmail", + '@', + ' ' + ), + '' + ) + ) + ELSE NULL + END + `.trim(); + + expect(result.trim()).toBe(expected); + }); + + it('should include CASE statement for handling deletedAt', () => { + const fields = [nameTextField]; + const result = getTsVectorColumnExpressionFromFields(fields); + + expect(result).toContain('CASE'); + expect(result).toContain('WHEN "deletedAt" IS NULL THEN'); + expect(result).toContain('ELSE NULL'); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts new file mode 100644 index 000000000..816c7b64d --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts @@ -0,0 +1,88 @@ +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +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 { + WorkspaceMigrationException, + WorkspaceMigrationExceptionCode, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.exception'; + +type FieldTypeAndNameMetadata = { + name: string; + type: FieldMetadataType; +}; + +export const getTsVectorColumnExpressionFromFields = ( + fieldsUsedForSearch: FieldTypeAndNameMetadata[], +): string => { + const columnExpressions = fieldsUsedForSearch.flatMap( + getColumnExpressionsFromField, + ); + const concatenatedExpression = columnExpressions.join(" || ' ' || "); + + const tsVectorExpression = `to_tsvector('simple', ${concatenatedExpression})`; + + return ` + CASE + WHEN "deletedAt" IS NULL THEN + ${tsVectorExpression} + ELSE NULL + END + `; +}; + +const getColumnExpressionsFromField = ( + fieldMetadataTypeAndName: FieldTypeAndNameMetadata, +): string[] => { + if (isCompositeFieldMetadataType(fieldMetadataTypeAndName.type)) { + const compositeType = compositeTypeDefinitions.get( + fieldMetadataTypeAndName.type, + ); + + if (!compositeType) { + throw new WorkspaceMigrationException( + `Composite type not found for field metadata type: ${fieldMetadataTypeAndName.type}`, + WorkspaceMigrationExceptionCode.INVALID_FIELD_METADATA, + ); + } + + return compositeType.properties + .filter((property) => property.type === FieldMetadataType.TEXT) + .map((property) => { + const columnName = computeCompositeColumnName( + fieldMetadataTypeAndName, + property, + ); + + return getColumnExpression(columnName, fieldMetadataTypeAndName.type); + }); + } + const columnName = computeColumnName(fieldMetadataTypeAndName.name); + + return [getColumnExpression(columnName, fieldMetadataTypeAndName.type)]; +}; + +const getColumnExpression = ( + columnName: string, + fieldType: FieldMetadataType, +): string => { + const quotedColumnName = `"${columnName}"`; + + if (fieldType === FieldMetadataType.EMAILS) { + return ` + COALESCE( + replace( + ${quotedColumnName}, + '@', + ' ' + ), + '' + ) + `; + } else { + return `COALESCE(${quotedColumnName}, '')`; + } +}; diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index 32e774d4c..df129f134 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -1,5 +1,6 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata, FieldActorSource, @@ -8,6 +9,7 @@ import { AddressMetadata } from 'src/engine/metadata-modules/field-metadata/comp import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { RelationMetadataType, RelationOnDeleteAction, @@ -15,6 +17,7 @@ import { import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator'; import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; @@ -22,6 +25,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace- import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { COMPANY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; @@ -32,6 +36,9 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +const NAME_FIELD_NAME = 'name'; +const DOMAIN_NAME_FIELD_NAME = 'domainName'; + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.company, namePlural: 'companies', @@ -49,7 +56,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { description: 'The company name', icon: 'IconBuildingSkyscraper', }) - name: string; + [NAME_FIELD_NAME]: string; @WorkspaceField({ standardId: COMPANY_STANDARD_FIELD_IDS.domainName, @@ -59,7 +66,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { 'The company website URL. We use this url to fetch the company icon', icon: 'IconLink', }) - domainName?: LinksMetadata; + [DOMAIN_NAME_FIELD_NAME]?: LinksMetadata; @WorkspaceField({ standardId: COMPANY_STANDARD_FIELD_IDS.employees, @@ -273,4 +280,21 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsDeprecated() @WorkspaceIsNullable() addressOld: string; + + @WorkspaceField({ + standardId: COMPANY_STANDARD_FIELD_IDS.searchVector, + type: FieldMetadataType.TS_VECTOR, + label: SEARCH_VECTOR_FIELD.label, + description: SEARCH_VECTOR_FIELD.description, + icon: 'IconUser', + generatedType: 'STORED', + asExpression: getTsVectorColumnExpressionFromFields([ + { name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT }, + { name: DOMAIN_NAME_FIELD_NAME, type: FieldMetadataType.LINKS }, + ]), + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + @WorkspaceIndex({ indexType: IndexType.GIN }) + [SEARCH_VECTOR_FIELD.name]: any; } diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts index 142b000b9..72e71098e 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts @@ -1,11 +1,13 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata, FieldActorSource, } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { RelationMetadataType, RelationOnDeleteAction, @@ -22,6 +24,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace- import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { OPPORTUNITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; @@ -31,6 +34,8 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; +const NAME_FIELD_NAME = 'name'; + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.opportunity, namePlural: 'opportunities', @@ -232,4 +237,20 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity { }) @WorkspaceIsDeprecated() probability: string; + + @WorkspaceField({ + standardId: OPPORTUNITY_STANDARD_FIELD_IDS.searchVector, + type: FieldMetadataType.TS_VECTOR, + label: SEARCH_VECTOR_FIELD.label, + description: SEARCH_VECTOR_FIELD.description, + icon: 'IconUser', + generatedType: 'STORED', + asExpression: getTsVectorColumnExpressionFromFields([ + { name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT }, + ]), + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + @WorkspaceIndex({ indexType: IndexType.GIN }) + [SEARCH_VECTOR_FIELD.name]: any; } diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index e162bb82b..d2cccd1d9 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -1,5 +1,6 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata, FieldActorSource, @@ -9,6 +10,7 @@ import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/com import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; import { PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { RelationMetadataType, RelationOnDeleteAction, @@ -16,6 +18,7 @@ import { import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator'; import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; @@ -23,6 +26,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace- import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; @@ -34,6 +38,10 @@ import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-obj import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; +const NAME_FIELD_NAME = 'name'; +const EMAILS_FIELD_NAME = 'emails'; +const JOB_TITLE_FIELD_NAME = 'jobTitle'; + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.person, namePlural: 'people', @@ -53,7 +61,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { icon: 'IconUser', }) @WorkspaceIsNullable() - name: FullNameMetadata | null; + [NAME_FIELD_NAME]: FullNameMetadata | null; @WorkspaceField({ standardId: PERSON_STANDARD_FIELD_IDS.email, @@ -72,7 +80,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { description: 'Contact’s Emails', icon: 'IconMail', }) - emails: EmailsMetadata; + [EMAILS_FIELD_NAME]: EmailsMetadata; @WorkspaceField({ standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink, @@ -101,7 +109,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { description: 'Contact’s job title', icon: 'IconBriefcase', }) - jobTitle: string; + [JOB_TITLE_FIELD_NAME]: string; @WorkspaceField({ standardId: PERSON_STANDARD_FIELD_IDS.phone, @@ -290,4 +298,22 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsNullable() @WorkspaceIsSystem() timelineActivities: Relation; + + @WorkspaceField({ + standardId: PERSON_STANDARD_FIELD_IDS.searchVector, + type: FieldMetadataType.TS_VECTOR, + label: SEARCH_VECTOR_FIELD.label, + description: SEARCH_VECTOR_FIELD.description, + icon: 'IconUser', + generatedType: 'STORED', + asExpression: getTsVectorColumnExpressionFromFields([ + { name: NAME_FIELD_NAME, type: FieldMetadataType.FULL_NAME }, + { name: EMAILS_FIELD_NAME, type: FieldMetadataType.EMAILS }, + { name: JOB_TITLE_FIELD_NAME, type: FieldMetadataType.TEXT }, + ]), + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + @WorkspaceIndex({ indexType: IndexType.GIN }) + [SEARCH_VECTOR_FIELD.name]: any; }