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
This commit is contained in:
Marie
2024-10-03 17:18:49 +02:00
committed by GitHub
parent 4c250dd811
commit 5f9435c718
71 changed files with 1517 additions and 209 deletions

View File

@ -369,6 +369,7 @@ export enum FieldMetadataType {
RichText = 'RICH_TEXT', RichText = 'RICH_TEXT',
Select = 'SELECT', Select = 'SELECT',
Text = 'TEXT', Text = 'TEXT',
TsVector = 'TS_VECTOR',
Uuid = 'UUID' Uuid = 'UUID'
} }

View File

@ -280,6 +280,7 @@ export enum FieldMetadataType {
RichText = 'RICH_TEXT', RichText = 'RICH_TEXT',
Select = 'SELECT', Select = 'SELECT',
Text = 'TEXT', Text = 'TEXT',
TsVector = 'TS_VECTOR',
Uuid = 'UUID' Uuid = 'UUID'
} }

View File

@ -16,7 +16,9 @@ import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeybo
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useSearchRecords } from '@/object-record/hooks/useSearchRecords';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
import { Opportunity } from '@/opportunities/Opportunity';
import { Person } from '@/people/types/Person'; import { Person } from '@/people/types/Person';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
@ -165,8 +167,21 @@ export const CommandMenu = () => {
[closeCommandMenu], [closeCommandMenu],
); );
const { records: people } = useFindManyRecords<Person>({ const isTwentyOrmEnabled = useIsFeatureEnabled(
skip: !isCommandMenuOpened, '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<Person>({
skip: !isCommandMenuOpened || isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Person, objectNameSingular: CoreObjectNameSingular.Person,
filter: commandMenuSearch filter: commandMenuSearch
? makeOrFilterVariables([ ? makeOrFilterVariables([
@ -183,9 +198,24 @@ export const CommandMenu = () => {
: undefined, : undefined,
limit: 3, limit: 3,
}); });
const { records: peopleFromSearch } = useSearchRecords<Person>({
skip: !isCommandMenuOpened || !isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Person,
limit: 3,
searchInput: commandMenuSearch ?? undefined,
});
const { records: companies } = useFindManyRecords<Company>({ const people = isSearchEnabled ? peopleFromSearch : peopleFromFindMany;
skip: !isCommandMenuOpened,
const { records: companiesFromSearch } = useSearchRecords<Company>({
skip: !isCommandMenuOpened || !isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Company,
limit: 3,
searchInput: commandMenuSearch ?? undefined,
});
const { records: companiesFromFindMany } = useFindManyRecords<Company>({
skip: !isCommandMenuOpened || isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Company, objectNameSingular: CoreObjectNameSingular.Company,
filter: commandMenuSearch filter: commandMenuSearch
? { ? {
@ -195,6 +225,10 @@ export const CommandMenu = () => {
limit: 3, limit: 3,
}); });
const companies = isSearchEnabled
? companiesFromSearch
: companiesFromFindMany;
const { records: notes } = useFindManyRecords<Note>({ const { records: notes } = useFindManyRecords<Note>({
skip: !isCommandMenuOpened, skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Note, objectNameSingular: CoreObjectNameSingular.Note,
@ -207,8 +241,8 @@ export const CommandMenu = () => {
limit: 3, limit: 3,
}); });
const { records: opportunities } = useFindManyRecords({ const { records: opportunitiesFromFindMany } = useFindManyRecords({
skip: !isCommandMenuOpened, skip: !isCommandMenuOpened || isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Opportunity, objectNameSingular: CoreObjectNameSingular.Opportunity,
filter: commandMenuSearch filter: commandMenuSearch
? { ? {
@ -218,6 +252,17 @@ export const CommandMenu = () => {
limit: 3, limit: 3,
}); });
const { records: opportunitiesFromSearch } = useSearchRecords<Opportunity>({
skip: !isCommandMenuOpened || !isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Opportunity,
limit: 3,
searchInput: commandMenuSearch ?? undefined,
});
const opportunities = isSearchEnabled
? opportunitiesFromSearch
: opportunitiesFromFindMany;
const peopleCommands = useMemo( const peopleCommands = useMemo(
() => () =>
people.map(({ id, name: { firstName, lastName } }) => ({ people.map(({ id, name: { firstName, lastName } }) => ({

View File

@ -1,16 +1,29 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems'; import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; 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 { WorkspaceActivationStatus } from '~/generated/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; 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 = () => { export const ObjectMetadataItemsLoadEffect = () => {
const currentUser = useRecoilValue(currentUserState); const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspace = useRecoilValue(currentWorkspaceState);
@ -21,26 +34,32 @@ export const ObjectMetadataItemsLoadEffect = () => {
skip: !isLoggedIn, skip: !isLoggedIn,
}); });
const [objectMetadataItems, setObjectMetadataItems] = useRecoilState( const updateObjectMetadataItems = useRecoilCallback(
objectMetadataItemsState, ({ 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(() => { useEffect(() => {
const toSetObjectMetadataItems = updateObjectMetadataItems();
isUndefinedOrNull(currentUser) || }, [updateObjectMetadataItems]);
currentWorkspace?.activationStatus !== WorkspaceActivationStatus.Active
? generatedMockObjectMetadataItems
: newObjectMetadataItems;
if (!isDeeplyEqual(objectMetadataItems, toSetObjectMetadataItems)) {
setObjectMetadataItems(toSetObjectMetadataItems);
}
}, [
currentUser,
currentWorkspace?.activationStatus,
newObjectMetadataItems,
objectMetadataItems,
setObjectMetadataItems,
]);
return <></>; return <></>;
}; };

View File

@ -0,0 +1,5 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
export type RecordGqlOperationSearchResult = {
[objectNamePlural: string]: RecordGqlConnection;
};

View File

@ -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 = <T extends ObjectRecord = ObjectRecord>({
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<RecordGqlOperationSearchResult>(
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,
};
};

View File

@ -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,
};
};

View File

@ -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
}
}
}
`;

View File

@ -0,0 +1,4 @@
import { capitalize } from '~/utils/string/capitalize';
export const getSearchRecordsQueryResponseField = (objectNamePlural: string) =>
`search${capitalize(objectNamePlural)}`;

View File

@ -0,0 +1,8 @@
export type Opportunity = {
__typename: 'Opportunity';
id: string;
createdAt: string;
updatedAt?: string;
deletedAt?: string | null;
name: string | null;
};

View File

@ -2,5 +2,5 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsSupportedFieldType = Exclude< export type SettingsSupportedFieldType = Exclude<
FieldMetadataType, FieldMetadataType,
FieldMetadataType.Position FieldMetadataType.Position | FieldMetadataType.TsVector
>; >;

View File

@ -9,4 +9,7 @@ export type FeatureFlagKey =
| 'IS_FREE_ACCESS_ENABLED' | 'IS_FREE_ACCESS_ENABLED'
| 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED' | 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'
| 'IS_WORKFLOW_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';

View File

@ -60,6 +60,16 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId, workspaceId: workspaceId,
value: true, value: true,
}, },
{
key: FeatureFlagKey.IsSearchEnabled,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsWorkspaceMigratedForSearch,
workspaceId: workspaceId,
value: true,
},
]) ])
.execute(); .execute();
}; };

View File

@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIndexType1725893697807 implements MigrationInterface {
name = 'AddIndexType1725893697807';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE metadata."indexMetadata" DROP COLUMN "indexType"
`);
await queryRunner.query(`DROP TYPE metadata."indextype_enum"`);
}
}

View File

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTypeOrmMetadata1726848397026 implements MigrationInterface {
name = 'AddTypeOrmMetadata1726848397026';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE "core"."typeorm_metadata"`);
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsCustomColumnToIndexMetadata1727699709905
implements MigrationInterface
{
name = 'AddIsCustomColumnToIndexMetadata1727699709905';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "metadata"."indexMetadata"
ADD COLUMN "isCustom" BOOLEAN
NOT NULL
DEFAULT FALSE;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "metadata"."indexMetadata"
DROP COLUMN "isCustom"
`);
}
}

View File

@ -3,9 +3,13 @@ import { Module } from '@nestjs/common';
import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; 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 { 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 { 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({ @Module({
imports: [WorkspaceQueryHookModule, WorkspaceQueryRunnerModule], imports: [
WorkspaceQueryHookModule,
WorkspaceQueryRunnerModule,
FeatureFlagModule,
],
providers: [GraphqlQueryRunnerService], providers: [GraphqlQueryRunnerService],
exports: [GraphqlQueryRunnerService], exports: [GraphqlQueryRunnerService],
}) })

View File

@ -14,6 +14,7 @@ import {
FindManyResolverArgs, FindManyResolverArgs,
FindOneResolverArgs, FindOneResolverArgs,
ResolverArgsType, ResolverArgsType,
SearchResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; } 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'; 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 { 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 { 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 { 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 { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { import {
CallWebhookJobsJob, CallWebhookJobsJob,
@ -36,6 +38,7 @@ import {
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; 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 { 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 { 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 { 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 { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; 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 { export class GraphqlQueryRunnerService {
constructor( constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceQueryHookService: WorkspaceQueryHookService, private readonly workspaceQueryHookService: WorkspaceQueryHookService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory, private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@ -178,6 +182,20 @@ export class GraphqlQueryRunnerService {
return results?.[0] as ObjectRecord; return results?.[0] as ObjectRecord;
} }
@LogExecutionTime()
async search<ObjectRecord extends IRecord = IRecord>(
args: SearchResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>> {
const graphqlQuerySearchResolverService =
new GraphqlQuerySearchResolverService(
this.twentyORMGlobalManager,
this.featureFlagService,
);
return graphqlQuerySearchResolverService.search(args, options);
}
@LogExecutionTime() @LogExecutionTime()
async createMany<ObjectRecord extends IRecord = IRecord>( async createMany<ObjectRecord extends IRecord = IRecord>(
args: CreateManyResolverArgs<Partial<ObjectRecord>>, args: CreateManyResolverArgs<Partial<ObjectRecord>>,

View File

@ -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<ObjectRecord extends IRecord = IRecord>(
args: SearchResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>> {
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(' | ');
}
}

View File

@ -2,18 +2,18 @@ import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/wor
import { ArgsAliasFactory } from './args-alias.factory'; import { ArgsAliasFactory } from './args-alias.factory';
import { ArgsStringFactory } from './args-string.factory'; import { ArgsStringFactory } from './args-string.factory';
import { RelationFieldAliasFactory } from './relation-field-alias.factory';
import { CreateManyQueryFactory } from './create-many-query.factory'; import { CreateManyQueryFactory } from './create-many-query.factory';
import { DeleteManyQueryFactory } from './delete-many-query.factory';
import { DeleteOneQueryFactory } from './delete-one-query.factory'; import { DeleteOneQueryFactory } from './delete-one-query.factory';
import { FieldAliasFactory } from './field-alias.factory'; import { FieldAliasFactory } from './field-alias.factory';
import { FieldsStringFactory } from './fields-string.factory'; import { FieldsStringFactory } from './fields-string.factory';
import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory';
import { FindManyQueryFactory } from './find-many-query.factory'; import { FindManyQueryFactory } from './find-many-query.factory';
import { FindOneQueryFactory } from './find-one-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 { 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 = [ export const workspaceQueryBuilderFactories = [
ArgsAliasFactory, ArgsAliasFactory,

View File

@ -1,6 +1,7 @@
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory'; 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 { 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 { 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 { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { CreateManyResolverFactory } from './create-many-resolver.factory'; import { CreateManyResolverFactory } from './create-many-resolver.factory';
@ -25,6 +26,7 @@ export const workspaceResolverBuilderFactories = [
DestroyOneResolverFactory, DestroyOneResolverFactory,
DestroyManyResolverFactory, DestroyManyResolverFactory,
RestoreManyResolverFactory, RestoreManyResolverFactory,
SearchResolverFactory,
]; ];
export const workspaceResolverBuilderMethodNames = { export const workspaceResolverBuilderMethodNames = {
@ -32,6 +34,7 @@ export const workspaceResolverBuilderMethodNames = {
FindManyResolverFactory.methodName, FindManyResolverFactory.methodName,
FindOneResolverFactory.methodName, FindOneResolverFactory.methodName,
FindDuplicatesResolverFactory.methodName, FindDuplicatesResolverFactory.methodName,
SearchResolverFactory.methodName,
], ],
mutations: [ mutations: [
CreateManyResolverFactory.methodName, CreateManyResolverFactory.methodName,

View File

@ -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<SearchResolverArgs> {
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);
}
};
}
}

View File

@ -48,6 +48,11 @@ export interface FindDuplicatesResolverArgs<
data?: Data[]; data?: Data[];
} }
export interface SearchResolverArgs {
searchInput?: string;
limit?: number;
}
export interface CreateOneResolverArgs< export interface CreateOneResolverArgs<
Data extends Partial<Record> = Partial<Record>, Data extends Partial<Record> = Partial<Record>,
> { > {
@ -123,4 +128,5 @@ export type ResolverArgs =
| UpdateManyResolverArgs | UpdateManyResolverArgs
| UpdateOneResolverArgs | UpdateOneResolverArgs
| DestroyManyResolverArgs | DestroyManyResolverArgs
| RestoreManyResolverArgs; | RestoreManyResolverArgs
| SearchResolverArgs;

View File

@ -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 { 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 { 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 { 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 { 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 { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
@ -42,6 +43,7 @@ export class WorkspaceResolverFactory {
private readonly deleteManyResolverFactory: DeleteManyResolverFactory, private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory, private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory, private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
private readonly searchResolverFactory: SearchResolverFactory,
) {} ) {}
async create( async create(
@ -65,6 +67,7 @@ export class WorkspaceResolverFactory {
['deleteMany', this.deleteManyResolverFactory], ['deleteMany', this.deleteManyResolverFactory],
['restoreMany', this.restoreManyResolverFactory], ['restoreMany', this.restoreManyResolverFactory],
['destroyMany', this.destroyManyResolverFactory], ['destroyMany', this.destroyManyResolverFactory],
['search', this.searchResolverFactory],
]); ]);
const resolvers: IResolvers = { const resolvers: IResolvers = {
Query: {}, Query: {},

View File

@ -1,14 +1,12 @@
import { Inject, Injectable, forwardRef } from '@nestjs/common'; 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 { 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 { 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 { 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 { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils';
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 { pascalCase } from 'src/utils/pascal-case'; import { pascalCase } from 'src/utils/pascal-case';
import { InputTypeFactory } from './input-type.factory'; import { InputTypeFactory } from './input-type.factory';
@ -55,7 +53,12 @@ export class InputTypeDefinitionFactory {
}); });
return { return {
...this.generateFields(objectMetadata, kind, options), ...generateFields(
objectMetadata,
kind,
options,
this.inputTypeFactory,
),
and: { and: {
type: andOrType, type: andOrType,
}, },
@ -73,7 +76,12 @@ export class InputTypeDefinitionFactory {
* Other input types are generated with fields only * Other input types are generated with fields only
*/ */
default: default:
return this.generateFields(objectMetadata, kind, options); return generateFields(
objectMetadata,
kind,
options,
this.inputTypeFactory,
);
} }
}, },
}); });
@ -84,46 +92,4 @@ export class InputTypeDefinitionFactory {
type: inputType, 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;
}
} }

View File

@ -1,14 +1,12 @@
import { Injectable } from '@nestjs/common'; 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 { 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 { 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 { 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'; import { OutputTypeFactory } from './output-type.factory';
@ -39,48 +37,13 @@ export class ObjectTypeDefinitionFactory {
type: new GraphQLObjectType({ type: new GraphQLObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`, name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
description: objectMetadata.description, 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<any, any> {
const fields: GraphQLFieldConfigMap<any, any> = {};
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;
}
} }

View File

@ -74,9 +74,7 @@ export class RootTypeFactory {
const args = getResolverArgs(methodName); const args = getResolverArgs(methodName);
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey( const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id, objectMetadata.id,
['findMany', 'findDuplicates'].includes(methodName) this.getObjectTypeDefinitionKindByMethodName(methodName),
? ObjectTypeDefinitionKind.Connection
: ObjectTypeDefinitionKind.Plain,
); );
const argsType = this.argsFactory.create( const argsType = this.argsFactory.create(
{ {
@ -124,4 +122,17 @@ export class RootTypeFactory {
return fieldConfigMap; return fieldConfigMap;
} }
private getObjectTypeDefinitionKindByMethodName(
methodName: WorkspaceResolverBuilderMethodNames,
): ObjectTypeDefinitionKind {
switch (methodName) {
case 'findMany':
case 'findDuplicates':
case 'search':
return ObjectTypeDefinitionKind.Connection;
default:
return ObjectTypeDefinitionKind.Plain;
}
}
} }

View File

@ -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<T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind> =
{
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>,
): T extends InputTypeDefinitionKind
? GraphQLInputFieldConfigMap
: GraphQLFieldConfigMap<any, any> => {
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,
);
};

View File

@ -137,6 +137,17 @@ export const getResolverArgs = (
isNullable: false, isNullable: false,
}, },
}; };
case 'search':
return {
searchInput: {
type: GraphQLString,
isNullable: true,
},
limit: {
type: GraphQLInt,
isNullable: true,
},
};
default: default:
throw new Error(`Unknown resolver type: ${type}`); throw new Error(`Unknown resolver type: ${type}`);
} }

View File

@ -10,4 +10,6 @@ export enum FeatureFlagKey {
IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED',
IsQueryRunnerTwentyORMEnabled = 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED', IsQueryRunnerTwentyORMEnabled = 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED',
IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED', IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED',
IsSearchEnabled = 'IS_SEARCH_ENABLED',
IsWorkspaceMigratedForSearch = 'IS_WORKSPACE_MIGRATED_FOR_SEARCH',
} }

View File

@ -0,0 +1,5 @@
export const SEARCH_VECTOR_FIELD = {
name: 'searchVector',
label: 'Search vector',
description: 'Field used for full-text search',
} as const;

View File

@ -47,6 +47,7 @@ export enum FieldMetadataType {
RICH_TEXT = 'RICH_TEXT', RICH_TEXT = 'RICH_TEXT',
ACTOR = 'ACTOR', ACTOR = 'ACTOR',
ARRAY = 'ARRAY', ARRAY = 'ARRAY',
TS_VECTOR = 'TS_VECTOR',
} }
@Entity('fieldMetadata') @Entity('fieldMetadata')

View File

@ -22,4 +22,6 @@ export interface FieldMetadataInterface<
fromRelationMetadata?: RelationMetadataEntity; fromRelationMetadata?: RelationMetadataEntity;
toRelationMetadata?: RelationMetadataEntity; toRelationMetadata?: RelationMetadataEntity;
isCustom?: boolean; isCustom?: boolean;
generatedType?: 'STORED' | 'VIRTUAL';
asExpression?: string;
} }

View File

@ -11,6 +11,11 @@ import { pascalCase } from 'src/utils/pascal-case';
type ComputeColumnNameOptions = { isForeignKey?: boolean }; type ComputeColumnNameOptions = { isForeignKey?: boolean };
export type FieldTypeAndNameMetadata = {
name: string;
type: FieldMetadataType;
};
export function computeColumnName( export function computeColumnName(
fieldName: string, fieldName: string,
options?: ComputeColumnNameOptions, options?: ComputeColumnNameOptions,
@ -48,13 +53,16 @@ export function computeCompositeColumnName(
export function computeCompositeColumnName< export function computeCompositeColumnName<
T extends FieldMetadataType | 'default', T extends FieldMetadataType | 'default',
>( >(
fieldMetadata: FieldMetadataInterface<T>, fieldMetadata: FieldTypeAndNameMetadata | FieldMetadataInterface<T>,
compositeProperty: CompositeProperty, compositeProperty: CompositeProperty,
): string; ): string;
export function computeCompositeColumnName< export function computeCompositeColumnName<
T extends FieldMetadataType | 'default', T extends FieldMetadataType | 'default',
>( >(
fieldMetadataOrFieldName: FieldMetadataInterface<T> | string, fieldMetadataOrFieldName:
| FieldTypeAndNameMetadata
| FieldMetadataInterface<T>
| string,
compositeProperty: CompositeProperty, compositeProperty: CompositeProperty,
): string { ): string {
const generateName = (name: string) => { const generateName = (name: string) => {

View File

@ -13,6 +13,11 @@ import {
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; 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'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export enum IndexType {
BTREE = 'BTREE',
GIN = 'GIN',
}
@Entity('indexMetadata') @Entity('indexMetadata')
export class IndexMetadataEntity { export class IndexMetadataEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -48,4 +53,15 @@ export class IndexMetadataEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date; updatedAt: Date;
@Column({ default: false })
isCustom: boolean;
@Column({
type: 'enum',
enum: IndexType,
nullable: true,
default: IndexType.BTREE,
})
indexType?: IndexType;
} }

View File

@ -1,10 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; 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 { 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 { 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 { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
@ -28,6 +32,8 @@ export class IndexMetadataService {
workspaceId: string, workspaceId: string,
objectMetadata: ObjectMetadataEntity, objectMetadata: ObjectMetadataEntity,
fieldMetadataToIndex: Partial<FieldMetadataEntity>[], fieldMetadataToIndex: Partial<FieldMetadataEntity>[],
isCustom: boolean,
indexType?: IndexType,
) { ) {
const tableName = computeObjectTargetTable(objectMetadata); const tableName = computeObjectTargetTable(objectMetadata);
@ -53,6 +59,8 @@ export class IndexMetadataService {
), ),
workspaceId, workspaceId,
objectMetadataId: objectMetadata.id, objectMetadataId: objectMetadata.id,
...(isDefined(indexType) ? { indexType: indexType } : {}),
isCustom: isCustom,
}); });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -74,6 +82,7 @@ export class IndexMetadataService {
action: WorkspaceMigrationIndexActionType.CREATE, action: WorkspaceMigrationIndexActionType.CREATE,
columns: columnNames, columns: columnNames,
name: indexName, name: indexName,
type: indexType,
}, },
], ],
} satisfies WorkspaceMigrationTableAction; } satisfies WorkspaceMigrationTableAction;

View File

@ -10,9 +10,11 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; 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 { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; 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 { 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 { 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 { 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'; import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
@ -44,6 +46,8 @@ import { UpdateObjectPayload } from './dtos/update-object.input';
WorkspaceMigrationRunnerModule, WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule, WorkspaceMetadataVersionModule,
RemoteTableRelationsModule, RemoteTableRelationsModule,
IndexMetadataModule,
FeatureFlagModule,
], ],
services: [ObjectMetadataService], services: [ObjectMetadataService],
resolvers: [ resolvers: [

View File

@ -5,19 +5,30 @@ import console from 'console';
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core'; import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'class-validator';
import { FindManyOptions, FindOneOptions, In, Repository } from 'typeorm'; import { FindManyOptions, FindOneOptions, In, Repository } from 'typeorm';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; 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 { 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 { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { import {
FieldMetadataEntity, FieldMetadataEntity,
FieldMetadataType, FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; } 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 { 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 { 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 { import {
ObjectMetadataException, ObjectMetadataException,
ObjectMetadataExceptionCode, 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 { 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 { 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 { 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 { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import { import {
WorkspaceMigrationColumnActionType, WorkspaceMigrationColumnActionType,
@ -58,6 +70,7 @@ import {
createForeignKeyDeterministicUuid, createForeignKeyDeterministicUuid,
createRelationDeterministicUuid, createRelationDeterministicUuid,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util'; } 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 { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@ -79,9 +92,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private readonly remoteTableRelationsService: RemoteTableRelationsService, private readonly remoteTableRelationsService: RemoteTableRelationsService,
private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory,
private readonly dataSourceService: DataSourceService, private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService, private readonly typeORMService: TypeORMService,
private readonly workspaceMigrationService: WorkspaceMigrationService, private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly indexMetadataService: IndexMetadataService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@ -350,6 +368,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadataInput, objectMetadataInput,
createdObjectMetadata, createdObjectMetadata,
); );
const isSearchEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsSearchEnabled,
objectMetadataInput.workspaceId,
);
if (isSearchEnabled) {
await this.createSearchVectorField(
objectMetadataInput,
createdObjectMetadata,
);
}
} else { } else {
await this.remoteTableRelationsService.createForeignKeysMetadataAndMigrations( await this.remoteTableRelationsService.createForeignKeysMetadataAndMigrations(
objectMetadataInput.workspaceId, objectMetadataInput.workspaceId,
@ -548,6 +578,70 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
); );
} }
private async createSearchVectorField(
objectMetadataInput: CreateObjectInput,
createdObjectMetadata: ObjectMetadataEntity,
) {
const searchVectorFieldMetadata = await this.fieldMetadataRepository.save({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector,
objectMetadataId: createdObjectMetadata.id,
workspaceId: objectMetadataInput.workspaceId,
isCustom: false,
isActive: false,
isSystem: true,
type: FieldMetadataType.TS_VECTOR,
name: SEARCH_VECTOR_FIELD.name,
label: SEARCH_VECTOR_FIELD.label,
description: SEARCH_VECTOR_FIELD.description,
isNullable: true,
});
const searchableFieldForCustomObject =
createdObjectMetadata.labelIdentifierFieldMetadataId
? createdObjectMetadata.fields.find(
(field) =>
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<FieldMetadataType.TS_VECTOR>),
},
],
);
await this.indexMetadataService.createIndex(
objectMetadataInput.workspaceId,
createdObjectMetadata,
[searchVectorFieldMetadata],
false,
IndexType.GIN,
);
}
private async createActivityTargetRelation( private async createActivityTargetRelation(
workspaceId: string, workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity, createdObjectMetadata: ObjectMetadataEntity,

View File

@ -153,6 +153,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
relationMetadataInput.workspaceId, relationMetadataInput.workspaceId,
toObjectMetadata, toObjectMetadata,
[foreignKeyFieldMetadata, deletedFieldMetadata], [foreignKeyFieldMetadata, deletedFieldMetadata],
false,
); );
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(

View File

@ -1,4 +1,4 @@
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/metadata.constants'; import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
export const exceedsDatabaseIdentifierMaximumLength = (string: string) => { export const exceedsDatabaseIdentifierMaximumLength = (string: string) => {
return string.length > IDENTIFIER_MAX_CHAR_LENGTH; return string.length > IDENTIFIER_MAX_CHAR_LENGTH;

View File

@ -1,8 +1,10 @@
import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory'; 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 { 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 { 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 = [ export const workspaceColumnActionFactories = [
TsVectorColumnActionFactory,
BasicColumnActionFactory, BasicColumnActionFactory,
EnumColumnActionFactory, EnumColumnActionFactory,
CompositeColumnActionFactory, CompositeColumnActionFactory,

View File

@ -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<TsVectorFieldMetadataType> {
protected readonly logger = new Logger(TsVectorColumnActionFactory.name);
handleCreateAction(
fieldMetadata: FieldMetadataInterface<TsVectorFieldMetadataType>,
): 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<TsVectorFieldMetadataType>,
_alteredFieldMetadata: FieldMetadataInterface<TsVectorFieldMetadataType>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter[] {
throw new WorkspaceMigrationException(
`TsVectorColumnActionFactory.handleAlterAction has not been implemented yet.`,
WorkspaceMigrationExceptionCode.INVALID_FIELD_METADATA,
);
}
}

View File

@ -38,6 +38,8 @@ export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
return 'enum'; return 'enum';
case FieldMetadataType.RAW_JSON: case FieldMetadataType.RAW_JSON:
return 'jsonb'; return 'jsonb';
case FieldMetadataType.TS_VECTOR:
return 'tsvector';
default: default:
throw new WorkspaceMigrationException( throw new WorkspaceMigrationException(
`Cannot convert ${fieldMetadataType} to column type.`, `Cannot convert ${fieldMetadataType} to column type.`,

View File

@ -5,6 +5,7 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } 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'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
export enum WorkspaceMigrationColumnActionType { export enum WorkspaceMigrationColumnActionType {
@ -30,12 +31,15 @@ export interface WorkspaceMigrationColumnDefinition {
isArray?: boolean; isArray?: boolean;
isNullable: boolean; isNullable: boolean;
defaultValue: any; defaultValue: any;
generatedType?: 'STORED' | 'VIRTUAL';
asExpression?: string;
} }
export interface WorkspaceMigrationIndexAction { export interface WorkspaceMigrationIndexAction {
action: WorkspaceMigrationIndexActionType; action: WorkspaceMigrationIndexActionType;
name: string; name: string;
columns: string[]; columns: string[];
type?: IndexType;
} }
export interface WorkspaceMigrationColumnCreate export interface WorkspaceMigrationColumnCreate

View File

@ -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 { 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 { 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 { 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 { import {
WorkspaceMigrationColumnAction, WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType, WorkspaceMigrationColumnActionType,
@ -30,6 +31,7 @@ export class WorkspaceMigrationFactory {
constructor( constructor(
private readonly basicColumnActionFactory: BasicColumnActionFactory, private readonly basicColumnActionFactory: BasicColumnActionFactory,
private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory,
private readonly enumColumnActionFactory: EnumColumnActionFactory, private readonly enumColumnActionFactory: EnumColumnActionFactory,
private readonly compositeColumnActionFactory: CompositeColumnActionFactory, private readonly compositeColumnActionFactory: CompositeColumnActionFactory,
) { ) {
@ -106,6 +108,10 @@ export class WorkspaceMigrationFactory {
FieldMetadataType.PHONES, FieldMetadataType.PHONES,
{ factory: this.compositeColumnActionFactory }, { factory: this.compositeColumnActionFactory },
], ],
[
FieldMetadataType.TS_VECTOR,
{ factory: this.tsVectorColumnActionFactory },
],
]); ]);
} }

View File

@ -4,8 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { workspaceColumnActionFactories } from 'src/engine/metadata-modules/workspace-migration/factories/factories'; import { workspaceColumnActionFactories } from 'src/engine/metadata-modules/workspace-migration/factories/factories';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; 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 { WorkspaceMigrationEntity } from './workspace-migration.entity';
import { WorkspaceMigrationService } from './workspace-migration.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([WorkspaceMigrationEntity], 'metadata')], imports: [TypeOrmModule.forFeature([WorkspaceMigrationEntity], 'metadata')],
@ -14,6 +14,10 @@ import { WorkspaceMigrationEntity } from './workspace-migration.entity';
WorkspaceMigrationFactory, WorkspaceMigrationFactory,
WorkspaceMigrationService, WorkspaceMigrationService,
], ],
exports: [WorkspaceMigrationFactory, WorkspaceMigrationService], exports: [
...workspaceColumnActionFactories,
WorkspaceMigrationFactory,
WorkspaceMigrationService,
],
}) })
export class WorkspaceMigrationModule {} export class WorkspaceMigrationModule {}

View File

@ -1,8 +1,11 @@
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { import {
ActorMetadata, ActorMetadata,
FieldActorSource, FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; } 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 { 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 { import {
RelationMetadataType, RelationMetadataType,
RelationOnDeleteAction, RelationOnDeleteAction,
@ -10,10 +13,12 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator'; import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.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 { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.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 { 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 { 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 { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@ -136,4 +141,22 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
timelineActivities: TimelineActivityWorkspaceEntity[]; 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;
} }

View File

@ -20,6 +20,8 @@ export interface WorkspaceFieldOptions<
options?: FieldMetadataOptions<T>; options?: FieldMetadataOptions<T>;
settings?: FieldMetadataSettings<T>; settings?: FieldMetadataSettings<T>;
isActive?: boolean; isActive?: boolean;
generatedType?: 'STORED' | 'VIRTUAL';
asExpression?: string;
} }
export function WorkspaceField<T extends FieldMetadataType>( export function WorkspaceField<T extends FieldMetadataType>(
@ -76,6 +78,8 @@ export function WorkspaceField<T extends FieldMetadataType>(
gate, gate,
isDeprecated, isDeprecated,
isActive: options.isActive, isActive: options.isActive,
asExpression: options.asExpression,
generatedType: options.generatedType,
}); });
}; };
} }

View File

@ -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 { 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 { 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 { 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'; import { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceIndex(): PropertyDecorator; export type WorkspaceIndexMetadata = {
export function WorkspaceIndex(columns: string[]): ClassDecorator; columns?: string[];
indexType?: IndexType;
};
export function WorkspaceIndex( export function WorkspaceIndex(
columns?: string[], metadata?: WorkspaceIndexMetadata,
): PropertyDecorator;
export function WorkspaceIndex(
metadata: WorkspaceIndexMetadata,
): ClassDecorator;
export function WorkspaceIndex(
metadata?: WorkspaceIndexMetadata,
): PropertyDecorator | ClassDecorator { ): PropertyDecorator | ClassDecorator {
return (target: any, propertyKey: string | symbol) => { 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'); 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( const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args', 'workspace:gate-metadata-args',
target, target,
@ -20,29 +38,46 @@ export function WorkspaceIndex(
); );
// TODO: handle composite field metadata types // 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({ metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([ name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.name), convertClassNameToObjectMetadataName(target.constructor.name),
...columns, ...columns,
])}`, ])}`,
columns, columns,
target: target, target: target.constructor,
...(isDefined(metadata?.indexType) ? { type: metadata.indexType } : {}),
gate, gate,
}); });
return;
} }
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.constructor.name),
...[propertyKey.toString(), 'deletedAt'],
])}`,
columns: [propertyKey.toString(), 'deletedAt'],
target: target.constructor,
gate,
});
}; };
} }

View File

@ -89,4 +89,14 @@ export interface WorkspaceFieldMetadataArgs {
* Is active field. * Is active field.
*/ */
readonly isActive?: boolean; readonly isActive?: boolean;
/**
* Is active field.
*/
readonly generatedType?: 'STORED' | 'VIRTUAL';
/**
* Is active field.
*/
readonly asExpression?: string;
} }

View File

@ -1,5 +1,7 @@
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; 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 { export interface WorkspaceIndexMetadataArgs {
/** /**
* Class to which index is applied. * Class to which index is applied.
@ -17,6 +19,11 @@ export interface WorkspaceIndexMetadataArgs {
*/ */
columns: string[]; columns: string[];
/*
* Index type. Defaults to Btree.
*/
type?: IndexType;
/** /**
* Field gate. * Field gate.
*/ */

View File

@ -664,7 +664,7 @@ export class WorkspaceRepository<
return formatData(data, objectMetadata) as T; return formatData(data, objectMetadata) as T;
} }
private async formatResult<T>( async formatResult<T>(
data: T, data: T,
objectMetadata?: ObjectMetadataMapItem, objectMetadata?: ObjectMetadataMapItem,
): Promise<T> { ): Promise<T> {

View File

@ -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']);
});
});

View File

@ -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'];
}
};

View File

@ -33,6 +33,8 @@ export const getResolverName = (
return `delete${pascalCase(objectMetadata.namePlural)}`; return `delete${pascalCase(objectMetadata.namePlural)}`;
case 'destroyMany': case 'destroyMany':
return `destroy${pascalCase(objectMetadata.namePlural)}`; return `destroy${pascalCase(objectMetadata.namePlural)}`;
case 'search':
return `search${pascalCase(objectMetadata.namePlural)}`;
default: default:
throw new Error(`Unknown resolver type: ${type}`); throw new Error(`Unknown resolver type: ${type}`);
} }

View File

@ -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 { 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 { 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 { import {
WorkspaceMigrationEntity, WorkspaceMigrationEntity,
WorkspaceMigrationIndexActionType, WorkspaceMigrationIndexActionType,
WorkspaceMigrationTableActionType, WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; 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() @Injectable()
export class WorkspaceMigrationIndexFactory { export class WorkspaceMigrationIndexFactory {
@ -94,6 +94,7 @@ export class WorkspaceMigrationIndexFactory {
return fieldMetadata.name; return fieldMetadata.name;
}), }),
type: indexMetadata.indexType,
})); }));
workspaceMigrations.push({ workspaceMigrations.push({

View File

@ -26,6 +26,7 @@ import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; 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 { 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 { 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 { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service';
import { customTableDefaultColumns } from './utils/custom-table-default-column.util'; import { customTableDefaultColumns } from './utils/custom-table-default-column.util';
@ -194,13 +195,21 @@ export class WorkspaceMigrationRunnerService {
for (const index of indexes) { for (const index of indexes) {
switch (index.action) { switch (index.action) {
case WorkspaceMigrationIndexActionType.CREATE: case WorkspaceMigrationIndexActionType.CREATE:
await queryRunner.createIndex( if (isDefined(index.type)) {
`${schemaName}.${tableName}`, const quotedColumns = index.columns.map((column) => `"${column}"`);
new TableIndex({
name: index.name, await queryRunner.query(`
columnNames: index.columns, 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; break;
case WorkspaceMigrationIndexActionType.DROP: case WorkspaceMigrationIndexActionType.DROP:
try { try {
@ -380,6 +389,8 @@ export class WorkspaceMigrationRunnerService {
enumName: enumName, enumName: enumName,
isArray: migrationColumn.isArray, isArray: migrationColumn.isArray,
isNullable: migrationColumn.isNullable, isNullable: migrationColumn.isNullable,
asExpression: migrationColumn.asExpression,
generatedType: migrationColumn.generatedType,
}), }),
); );
} }

View File

@ -24,6 +24,8 @@ const commonFieldPropertiesToIgnore = [
'settings', 'settings',
'joinColumn', 'joinColumn',
'gate', 'gate',
'asExpression',
'generatedType',
]; ];
const fieldPropertiesToStringify = ['defaultValue'] as const; const fieldPropertiesToStringify = ['defaultValue'] as const;

View File

@ -135,6 +135,7 @@ export const COMPANY_STANDARD_FIELD_IDS = {
favorites: '20202020-4d1d-41ac-b13b-621631298d55', favorites: '20202020-4d1d-41ac-b13b-621631298d55',
attachments: '20202020-c1b5-4120-b0f0-987ca401ed53', attachments: '20202020-c1b5-4120-b0f0-987ca401ed53',
timelineActivities: '20202020-0414-4daf-9c0d-64fe7b27f89f', timelineActivities: '20202020-0414-4daf-9c0d-64fe7b27f89f',
searchVector: '85c71601-72f9-4b7b-b343-d46100b2c74d',
}; };
export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = { export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = {
@ -300,6 +301,7 @@ export const OPPORTUNITY_STANDARD_FIELD_IDS = {
noteTargets: '20202020-dd3f-42d5-a382-db58aabf43d3', noteTargets: '20202020-dd3f-42d5-a382-db58aabf43d3',
attachments: '20202020-87c7-4118-83d6-2f4031005209', attachments: '20202020-87c7-4118-83d6-2f4031005209',
timelineActivities: '20202020-30e2-421f-96c7-19c69d1cf631', timelineActivities: '20202020-30e2-421f-96c7-19c69d1cf631',
searchVector: '428a0da5-4b2e-4ce3-b695-89a8b384e6e3',
}; };
export const PERSON_STANDARD_FIELD_IDS = { export const PERSON_STANDARD_FIELD_IDS = {
@ -325,6 +327,7 @@ export const PERSON_STANDARD_FIELD_IDS = {
messageParticipants: '20202020-498e-4c61-8158-fa04f0638334', messageParticipants: '20202020-498e-4c61-8158-fa04f0638334',
calendarEventParticipants: '20202020-52ee-45e9-a702-b64b3753e3a9', calendarEventParticipants: '20202020-52ee-45e9-a702-b64b3753e3a9',
timelineActivities: '20202020-a43e-4873-9c23-e522de906ce5', timelineActivities: '20202020-a43e-4873-9c23-e522de906ce5',
searchVector: '57d1d7ad-fa10-44fc-82f3-ad0959ec2534',
}; };
export const TASK_STANDARD_FIELD_IDS = { export const TASK_STANDARD_FIELD_IDS = {
@ -463,4 +466,5 @@ export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = {
favorites: '20202020-a4a7-4686-b296-1c6c3482ee21', favorites: '20202020-a4a7-4686-b296-1c6c3482ee21',
attachments: '20202020-8d59-46ca-b7b2-73d167712134', attachments: '20202020-8d59-46ca-b7b2-73d167712134',
timelineActivities: '20202020-f1ef-4ba4-8f33-1a4577afa477', timelineActivities: '20202020-f1ef-4ba4-8f33-1a4577afa477',
searchVector: '70e56537-18ef-4811-b1c7-0a444006b815',
}; };

View File

@ -166,6 +166,8 @@ export class StandardFieldFactory {
isCustom: workspaceFieldMetadataArgs.isDeprecated ? true : false, isCustom: workspaceFieldMetadataArgs.isDeprecated ? true : false,
isSystem: workspaceFieldMetadataArgs.isSystem ?? false, isSystem: workspaceFieldMetadataArgs.isSystem ?? false,
isActive: workspaceFieldMetadataArgs.isActive ?? true, isActive: workspaceFieldMetadataArgs.isActive ?? true,
asExpression: workspaceFieldMetadataArgs.asExpression,
generatedType: workspaceFieldMetadataArgs.generatedType,
}, },
]; ];
} }

View File

@ -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 { 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 { 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-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 { 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'; import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
@Injectable() @Injectable()
@ -15,23 +18,37 @@ export class StandardIndexFactory {
create( create(
standardObjectMetadataDefinitions: (typeof BaseWorkspaceEntity)[], standardObjectMetadataDefinitions: (typeof BaseWorkspaceEntity)[],
context: WorkspaceSyncContext, context: WorkspaceSyncContext,
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>, originalStandardObjectMetadataMap: Record<string, ObjectMetadataEntity>,
originalCustomObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap, workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<IndexMetadataEntity>[] { ): Partial<IndexMetadataEntity>[] {
return standardObjectMetadataDefinitions.flatMap((standardObjectMetadata) => const standardIndexOnStandardObjects =
this.createIndexMetadata( standardObjectMetadataDefinitions.flatMap((standardObjectMetadata) =>
standardObjectMetadata, this.createStandardIndexMetadataForStandardObject(
standardObjectMetadata,
context,
originalStandardObjectMetadataMap,
workspaceFeatureFlagsMap,
),
);
const standardIndexesOnCustomObjects =
this.createStandardIndexMetadataForCustomObject(
context, context,
originalObjectMetadataMap, originalCustomObjectMetadataMap,
workspaceFeatureFlagsMap, workspaceFeatureFlagsMap,
), );
);
return [
standardIndexOnStandardObjects,
standardIndexesOnCustomObjects,
].flat();
} }
private createIndexMetadata( private createStandardIndexMetadataForStandardObject(
target: typeof BaseWorkspaceEntity, target: typeof BaseWorkspaceEntity,
context: WorkspaceSyncContext, context: WorkspaceSyncContext,
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>, originalStandardObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap, workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<IndexMetadataEntity>[] { ): Partial<IndexMetadataEntity>[] {
const workspaceEntity = metadataArgsStorage.filterEntities(target); const workspaceEntity = metadataArgsStorage.filterEntities(target);
@ -58,7 +75,7 @@ export class StandardIndexFactory {
return workspaceIndexMetadataArgsCollection.map( return workspaceIndexMetadataArgsCollection.map(
(workspaceIndexMetadataArgs) => { (workspaceIndexMetadataArgs) => {
const objectMetadata = const objectMetadata =
originalObjectMetadataMap[workspaceEntity.nameSingular]; originalStandardObjectMetadataMap[workspaceEntity.nameSingular];
if (!objectMetadata) { if (!objectMetadata) {
throw new Error( throw new Error(
@ -71,10 +88,55 @@ export class StandardIndexFactory {
objectMetadataId: objectMetadata.id, objectMetadataId: objectMetadata.id,
name: workspaceIndexMetadataArgs.name, name: workspaceIndexMetadataArgs.name,
columns: workspaceIndexMetadataArgs.columns, columns: workspaceIndexMetadataArgs.columns,
isCustom: false,
indexType: workspaceIndexMetadataArgs.type,
}; };
return indexMetadata; return indexMetadata;
}, },
); );
} }
private createStandardIndexMetadataForCustomObject(
context: WorkspaceSyncContext,
originalCustomObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<IndexMetadataEntity>[] {
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;
},
);
},
);
}
} }

View File

@ -16,6 +16,8 @@ export type PartialFieldMetadata = Omit<
workspaceId: string; workspaceId: string;
objectMetadataId?: string; objectMetadataId?: string;
isActive?: boolean; isActive?: boolean;
asExpression?: string;
generatedType?: 'STORED' | 'VIRTUAL';
}; };
export type PartialComputedFieldMetadata = { export type PartialComputedFieldMetadata = {

View File

@ -10,6 +10,7 @@ import {
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; } 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 { 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity';
@ -143,13 +144,27 @@ export class WorkspaceSyncFieldMetadataService {
] of standardObjectStandardFieldMetadataMap) { ] of standardObjectStandardFieldMetadataMap) {
const originalObjectMetadata = const originalObjectMetadata =
originalObjectMetadataMap[standardObjectId]; originalObjectMetadataMap[standardObjectId];
const computedStandardFieldMetadataCollection = computeStandardFields(
let computedStandardFieldMetadataCollection = computeStandardFields(
standardFieldMetadataCollection, standardFieldMetadataCollection,
originalObjectMetadata, originalObjectMetadata,
// We need to provide this for generated relations with custom objects // We need to provide this for generated relations with custom objects
customObjectMetadataCollection, 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( const fieldComparatorResults = this.workspaceFieldComparator.compare(
originalObjectMetadata.id, originalObjectMetadata.id,
originalObjectMetadata.fields, originalObjectMetadata.fields,
@ -177,11 +192,24 @@ export class WorkspaceSyncFieldMetadataService {
// Loop over all custom objects from the DB and compare their fields with standard fields // Loop over all custom objects from the DB and compare their fields with standard fields
for (const customObjectMetadata of customObjectMetadataCollection) { for (const customObjectMetadata of customObjectMetadataCollection) {
// Also, maybe it's better to refactor a bit and move generation part into a separate module ? // Also, maybe it's better to refactor a bit and move generation part into a separate module ?
const standardFieldMetadataCollection = computeStandardFields( let standardFieldMetadataCollection = computeStandardFields(
customObjectStandardFieldMetadataCollection, customObjectStandardFieldMetadataCollection,
customObjectMetadata, 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 * COMPARE FIELD METADATA
*/ */

View File

@ -1,22 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'; 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 { 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 { 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 {
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage'; IndexMetadataEntity,
import { StandardIndexFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory'; IndexType,
} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-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 { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
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 { WorkspaceMigrationIndexFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory'; 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() @Injectable()
export class WorkspaceSyncIndexMetadataService { export class WorkspaceSyncIndexMetadataService {
@ -47,35 +50,60 @@ export class WorkspaceSyncIndexMetadataService {
workspaceId: context.workspaceId, workspaceId: context.workspaceId,
// We're only interested in standard fields // We're only interested in standard fields
fields: { isCustom: false }, fields: { isCustom: false },
isCustom: false,
}, },
relations: ['dataSource', 'fields', 'indexes'], relations: ['dataSource', 'fields', 'indexes'],
}); });
// Create map of object metadata & field metadata by unique identifier // Create map of object metadata & field metadata by unique identifier
const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier( const originalStandardObjectMetadataMap =
originalObjectMetadataCollection, mapObjectMetadataByUniqueIdentifier(
// Relation are based on the singular name 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, (objectMetadata) => objectMetadata.nameSingular,
); );
const indexMetadataRepository = manager.getRepository(IndexMetadataEntity); const indexMetadataRepository = manager.getRepository(IndexMetadataEntity);
const originalIndexMetadataCollection = await indexMetadataRepository.find({ let originalIndexMetadataCollection = await indexMetadataRepository.find({
where: { where: {
workspaceId: context.workspaceId, workspaceId: context.workspaceId,
objectMetadataId: Any(
Object.values(originalObjectMetadataCollection).map(
(object) => object.id,
),
),
isCustom: false,
}, },
relations: ['indexFieldMetadatas.fieldMetadata'], relations: ['indexFieldMetadatas.fieldMetadata'],
}); });
// Generate index metadata from models // Generate index metadata from models
const standardIndexMetadataCollection = this.standardIndexFactory.create( let standardIndexMetadataCollection = this.standardIndexFactory.create(
standardObjectMetadataDefinitions, standardObjectMetadataDefinitions,
context, context,
originalObjectMetadataMap, originalStandardObjectMetadataMap,
originalCustomObjectMetadataMap,
workspaceFeatureFlagsMap, 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( const indexComparatorResults = this.workspaceIndexComparator.compare(
originalIndexMetadataCollection, originalIndexMetadataCollection,
standardIndexMetadataCollection, standardIndexMetadataCollection,

View File

@ -54,8 +54,6 @@ export const standardObjectMetadataDefinitions = [
CompanyWorkspaceEntity, CompanyWorkspaceEntity,
ConnectedAccountWorkspaceEntity, ConnectedAccountWorkspaceEntity,
FavoriteWorkspaceEntity, FavoriteWorkspaceEntity,
OpportunityWorkspaceEntity,
PersonWorkspaceEntity,
TimelineActivityWorkspaceEntity, TimelineActivityWorkspaceEntity,
ViewFieldWorkspaceEntity, ViewFieldWorkspaceEntity,
ViewFilterWorkspaceEntity, ViewFilterWorkspaceEntity,
@ -79,10 +77,4 @@ export const standardObjectMetadataDefinitions = [
PersonWorkspaceEntity, PersonWorkspaceEntity,
TaskWorkspaceEntity, TaskWorkspaceEntity,
TaskTargetWorkspaceEntity, TaskTargetWorkspaceEntity,
TimelineActivityWorkspaceEntity,
ViewFieldWorkspaceEntity,
ViewFilterWorkspaceEntity,
ViewSortWorkspaceEntity,
ViewWorkspaceEntity,
WebhookWorkspaceEntity,
]; ];

View File

@ -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');
});
});

View File

@ -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}, '')`;
}
};

View File

@ -1,5 +1,6 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; 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 { import {
ActorMetadata, ActorMetadata,
FieldActorSource, 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 { 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 { 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 { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { import {
RelationMetadataType, RelationMetadataType,
RelationOnDeleteAction, RelationOnDeleteAction,
@ -15,6 +17,7 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.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 { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.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 { 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 { 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 { 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 { 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 { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.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 { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.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({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.company, standardId: STANDARD_OBJECT_IDS.company,
namePlural: 'companies', namePlural: 'companies',
@ -49,7 +56,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
description: 'The company name', description: 'The company name',
icon: 'IconBuildingSkyscraper', icon: 'IconBuildingSkyscraper',
}) })
name: string; [NAME_FIELD_NAME]: string;
@WorkspaceField({ @WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.domainName, 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', 'The company website URL. We use this url to fetch the company icon',
icon: 'IconLink', icon: 'IconLink',
}) })
domainName?: LinksMetadata; [DOMAIN_NAME_FIELD_NAME]?: LinksMetadata;
@WorkspaceField({ @WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.employees, standardId: COMPANY_STANDARD_FIELD_IDS.employees,
@ -273,4 +280,21 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsDeprecated() @WorkspaceIsDeprecated()
@WorkspaceIsNullable() @WorkspaceIsNullable()
addressOld: string; 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;
} }

View File

@ -1,11 +1,13 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; 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 { import {
ActorMetadata, ActorMetadata,
FieldActorSource, FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; } 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 { 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 { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { import {
RelationMetadataType, RelationMetadataType,
RelationOnDeleteAction, 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 { 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 { 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 { 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 { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.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 { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
const NAME_FIELD_NAME = 'name';
@WorkspaceEntity({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.opportunity, standardId: STANDARD_OBJECT_IDS.opportunity,
namePlural: 'opportunities', namePlural: 'opportunities',
@ -232,4 +237,20 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
}) })
@WorkspaceIsDeprecated() @WorkspaceIsDeprecated()
probability: string; 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;
} }

View File

@ -1,5 +1,6 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; 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 { import {
ActorMetadata, ActorMetadata,
FieldActorSource, 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 { 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 { 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 { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { import {
RelationMetadataType, RelationMetadataType,
RelationOnDeleteAction, RelationOnDeleteAction,
@ -16,6 +18,7 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.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 { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.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 { 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 { 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 { 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 { 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 { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.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'; 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 { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.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({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.person, standardId: STANDARD_OBJECT_IDS.person,
namePlural: 'people', namePlural: 'people',
@ -53,7 +61,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
icon: 'IconUser', icon: 'IconUser',
}) })
@WorkspaceIsNullable() @WorkspaceIsNullable()
name: FullNameMetadata | null; [NAME_FIELD_NAME]: FullNameMetadata | null;
@WorkspaceField({ @WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.email, standardId: PERSON_STANDARD_FIELD_IDS.email,
@ -72,7 +80,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Contacts Emails', description: 'Contacts Emails',
icon: 'IconMail', icon: 'IconMail',
}) })
emails: EmailsMetadata; [EMAILS_FIELD_NAME]: EmailsMetadata;
@WorkspaceField({ @WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink, standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink,
@ -101,7 +109,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Contacts job title', description: 'Contacts job title',
icon: 'IconBriefcase', icon: 'IconBriefcase',
}) })
jobTitle: string; [JOB_TITLE_FIELD_NAME]: string;
@WorkspaceField({ @WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.phone, standardId: PERSON_STANDARD_FIELD_IDS.phone,
@ -290,4 +298,22 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>; timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
@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;
} }