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',
Select = 'SELECT',
Text = 'TEXT',
TsVector = 'TS_VECTOR',
Uuid = 'UUID'
}

View File

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

View File

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

View File

@ -1,16 +1,29 @@
import { useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const filterTsVectorFields = (
objectMetadataItems: ObjectMetadataItem[],
): ObjectMetadataItem[] => {
return objectMetadataItems.map((item) => ({
...item,
fields: item.fields.filter(
(field) => field.type !== FieldMetadataType.TsVector,
),
}));
};
export const ObjectMetadataItemsLoadEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@ -21,26 +34,32 @@ export const ObjectMetadataItemsLoadEffect = () => {
skip: !isLoggedIn,
});
const [objectMetadataItems, setObjectMetadataItems] = useRecoilState(
objectMetadataItemsState,
const updateObjectMetadataItems = useRecoilCallback(
({ set, snapshot }) =>
() => {
const filteredFields = filterTsVectorFields(newObjectMetadataItems);
const toSetObjectMetadataItems =
isUndefinedOrNull(currentUser) ||
currentWorkspace?.activationStatus !==
WorkspaceActivationStatus.Active
? generatedMockObjectMetadataItems
: filteredFields;
if (
!isDeeplyEqual(
snapshot.getLoadable(objectMetadataItemsState).getValue(),
toSetObjectMetadataItems,
)
) {
set(objectMetadataItemsState, toSetObjectMetadataItems);
}
},
[currentUser, currentWorkspace?.activationStatus, newObjectMetadataItems],
);
useEffect(() => {
const toSetObjectMetadataItems =
isUndefinedOrNull(currentUser) ||
currentWorkspace?.activationStatus !== WorkspaceActivationStatus.Active
? generatedMockObjectMetadataItems
: newObjectMetadataItems;
if (!isDeeplyEqual(objectMetadataItems, toSetObjectMetadataItems)) {
setObjectMetadataItems(toSetObjectMetadataItems);
}
}, [
currentUser,
currentWorkspace?.activationStatus,
newObjectMetadataItems,
objectMetadataItems,
setObjectMetadataItems,
]);
updateObjectMetadataItems();
}, [updateObjectMetadataItems]);
return <></>;
};

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<
FieldMetadataType,
FieldMetadataType.Position
FieldMetadataType.Position | FieldMetadataType.TsVector
>;

View File

@ -9,4 +9,7 @@ export type FeatureFlagKey =
| 'IS_FREE_ACCESS_ENABLED'
| 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'
| 'IS_WORKFLOW_ENABLED'
| 'IS_WORKSPACE_FAVORITE_ENABLED';
| 'IS_WORKSPACE_FAVORITE_ENABLED'
| 'IS_SEARCH_ENABLED'
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH';

View File

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

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 { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@Module({
imports: [WorkspaceQueryHookModule, WorkspaceQueryRunnerModule],
imports: [
WorkspaceQueryHookModule,
WorkspaceQueryRunnerModule,
FeatureFlagModule,
],
providers: [GraphqlQueryRunnerService],
exports: [GraphqlQueryRunnerService],
})

View File

@ -14,6 +14,7 @@ import {
FindManyResolverArgs,
FindOneResolverArgs,
ResolverArgsType,
SearchResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
@ -21,6 +22,7 @@ import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/gr
import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import {
CallWebhookJobsJob,
@ -36,6 +38,7 @@ import {
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@ -48,6 +51,7 @@ import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/worksp
export class GraphqlQueryRunnerService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceQueryHookService: WorkspaceQueryHookService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@ -178,6 +182,20 @@ export class GraphqlQueryRunnerService {
return results?.[0] as ObjectRecord;
}
@LogExecutionTime()
async search<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()
async createMany<ObjectRecord extends IRecord = IRecord>(
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 { ArgsStringFactory } from './args-string.factory';
import { RelationFieldAliasFactory } from './relation-field-alias.factory';
import { CreateManyQueryFactory } from './create-many-query.factory';
import { DeleteManyQueryFactory } from './delete-many-query.factory';
import { DeleteOneQueryFactory } from './delete-one-query.factory';
import { FieldAliasFactory } from './field-alias.factory';
import { FieldsStringFactory } from './fields-string.factory';
import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory';
import { FindManyQueryFactory } from './find-many-query.factory';
import { FindOneQueryFactory } from './find-one-query.factory';
import { UpdateOneQueryFactory } from './update-one-query.factory';
import { UpdateManyQueryFactory } from './update-many-query.factory';
import { DeleteManyQueryFactory } from './delete-many-query.factory';
import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory';
import { RecordPositionQueryFactory } from './record-position-query.factory';
import { RelationFieldAliasFactory } from './relation-field-alias.factory';
import { UpdateManyQueryFactory } from './update-many-query.factory';
import { UpdateOneQueryFactory } from './update-one-query.factory';
export const workspaceQueryBuilderFactories = [
ArgsAliasFactory,

View File

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

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[];
}
export interface SearchResolverArgs {
searchInput?: string;
limit?: number;
}
export interface CreateOneResolverArgs<
Data extends Partial<Record> = Partial<Record>,
> {
@ -123,4 +128,5 @@ export type ResolverArgs =
| UpdateManyResolverArgs
| UpdateOneResolverArgs
| 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 { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory';
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
@ -42,6 +43,7 @@ export class WorkspaceResolverFactory {
private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
private readonly searchResolverFactory: SearchResolverFactory,
) {}
async create(
@ -65,6 +67,7 @@ export class WorkspaceResolverFactory {
['deleteMany', this.deleteManyResolverFactory],
['restoreMany', this.restoreManyResolverFactory],
['destroyMany', this.destroyManyResolverFactory],
['search', this.searchResolverFactory],
]);
const resolvers: IResolvers = {
Query: {},

View File

@ -1,14 +1,12 @@
import { Inject, Injectable, forwardRef } from '@nestjs/common';
import { GraphQLInputFieldConfigMap, GraphQLInputObjectType } from 'graphql';
import { GraphQLInputObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils';
import { pascalCase } from 'src/utils/pascal-case';
import { InputTypeFactory } from './input-type.factory';
@ -55,7 +53,12 @@ export class InputTypeDefinitionFactory {
});
return {
...this.generateFields(objectMetadata, kind, options),
...generateFields(
objectMetadata,
kind,
options,
this.inputTypeFactory,
),
and: {
type: andOrType,
},
@ -73,7 +76,12 @@ export class InputTypeDefinitionFactory {
* Other input types are generated with fields only
*/
default:
return this.generateFields(objectMetadata, kind, options);
return generateFields(
objectMetadata,
kind,
options,
this.inputTypeFactory,
);
}
},
});
@ -84,46 +92,4 @@ export class InputTypeDefinitionFactory {
type: inputType,
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
kind: InputTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): GraphQLInputFieldConfigMap {
const fields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of objectMetadata.fields) {
// Relation field types are generated during extension of object type definition
if (isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}
const target = isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
const isIdField = fieldMetadata.name === 'id';
const type = this.inputTypeFactory.create(
target,
fieldMetadata.type,
kind,
options,
{
nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings,
isIdField,
},
);
fields[fieldMetadata.name] = {
type,
description: fieldMetadata.description,
};
}
return fields;
}
}

View File

@ -1,14 +1,12 @@
import { Injectable } from '@nestjs/common';
import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql';
import { GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils';
import { pascalCase } from 'src/utils/pascal-case';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { OutputTypeFactory } from './output-type.factory';
@ -39,48 +37,13 @@ export class ObjectTypeDefinitionFactory {
type: new GraphQLObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
description: objectMetadata.description,
fields: this.generateFields(objectMetadata, kind, options),
fields: generateFields(
objectMetadata,
kind,
options,
this.outputTypeFactory,
),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
kind: ObjectTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigMap<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 objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
['findMany', 'findDuplicates'].includes(methodName)
? ObjectTypeDefinitionKind.Connection
: ObjectTypeDefinitionKind.Plain,
this.getObjectTypeDefinitionKindByMethodName(methodName),
);
const argsType = this.argsFactory.create(
{
@ -124,4 +122,17 @@ export class RootTypeFactory {
return fieldConfigMap;
}
private getObjectTypeDefinitionKindByMethodName(
methodName: WorkspaceResolverBuilderMethodNames,
): ObjectTypeDefinitionKind {
switch (methodName) {
case 'findMany':
case 'findDuplicates':
case 'search':
return ObjectTypeDefinitionKind.Connection;
default:
return ObjectTypeDefinitionKind.Plain;
}
}
}

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,
},
};
case 'search':
return {
searchInput: {
type: GraphQLString,
isNullable: true,
},
limit: {
type: GraphQLInt,
isNullable: true,
},
};
default:
throw new Error(`Unknown resolver type: ${type}`);
}

View File

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

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',
ACTOR = 'ACTOR',
ARRAY = 'ARRAY',
TS_VECTOR = 'TS_VECTOR',
}
@Entity('fieldMetadata')

View File

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

View File

@ -11,6 +11,11 @@ import { pascalCase } from 'src/utils/pascal-case';
type ComputeColumnNameOptions = { isForeignKey?: boolean };
export type FieldTypeAndNameMetadata = {
name: string;
type: FieldMetadataType;
};
export function computeColumnName(
fieldName: string,
options?: ComputeColumnNameOptions,
@ -48,13 +53,16 @@ export function computeCompositeColumnName(
export function computeCompositeColumnName<
T extends FieldMetadataType | 'default',
>(
fieldMetadata: FieldMetadataInterface<T>,
fieldMetadata: FieldTypeAndNameMetadata | FieldMetadataInterface<T>,
compositeProperty: CompositeProperty,
): string;
export function computeCompositeColumnName<
T extends FieldMetadataType | 'default',
>(
fieldMetadataOrFieldName: FieldMetadataInterface<T> | string,
fieldMetadataOrFieldName:
| FieldTypeAndNameMetadata
| FieldMetadataInterface<T>
| string,
compositeProperty: CompositeProperty,
): 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export enum IndexType {
BTREE = 'BTREE',
GIN = 'GIN',
}
@Entity('indexMetadata')
export class IndexMetadataEntity {
@PrimaryGeneratedColumn('uuid')
@ -48,4 +53,15 @@ export class IndexMetadataEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ default: false })
isCustom: boolean;
@Column({
type: 'enum',
enum: IndexType,
nullable: true,
default: IndexType.BTREE,
})
indexType?: IndexType;
}

View File

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

View File

@ -10,9 +10,11 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor';
import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
@ -44,6 +46,8 @@ import { UpdateObjectPayload } from './dtos/update-object.input';
WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule,
RemoteTableRelationsModule,
IndexMetadataModule,
FeatureFlagModule,
],
services: [ObjectMetadataService],
resolvers: [

View File

@ -5,19 +5,30 @@ import console from 'console';
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'class-validator';
import { FindManyOptions, FindOneOptions, In, Repository } from 'typeorm';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import {
computeColumnName,
FieldTypeAndNameMetadata,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
@ -33,6 +44,7 @@ import { RelationToDelete } from 'src/engine/metadata-modules/relation-metadata/
import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service';
import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
@ -58,6 +70,7 @@ import {
createForeignKeyDeterministicUuid,
createRelationDeterministicUuid,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@ -79,9 +92,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private readonly remoteTableRelationsService: RemoteTableRelationsService,
private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly indexMetadataService: IndexMetadataService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@ -350,6 +368,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadataInput,
createdObjectMetadata,
);
const isSearchEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsSearchEnabled,
objectMetadataInput.workspaceId,
);
if (isSearchEnabled) {
await this.createSearchVectorField(
objectMetadataInput,
createdObjectMetadata,
);
}
} else {
await this.remoteTableRelationsService.createForeignKeysMetadataAndMigrations(
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(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,

View File

@ -153,6 +153,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
relationMetadataInput.workspaceId,
toObjectMetadata,
[foreignKeyFieldMetadata, deletedFieldMetadata],
false,
);
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) => {
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 { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
export const workspaceColumnActionFactories = [
TsVectorColumnActionFactory,
BasicColumnActionFactory,
EnumColumnActionFactory,
CompositeColumnActionFactory,

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';
case FieldMetadataType.RAW_JSON:
return 'jsonb';
case FieldMetadataType.TS_VECTOR:
return 'tsvector';
default:
throw new WorkspaceMigrationException(
`Cannot convert ${fieldMetadataType} to column type.`,

View File

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

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 { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
import {
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType,
@ -30,6 +31,7 @@ export class WorkspaceMigrationFactory {
constructor(
private readonly basicColumnActionFactory: BasicColumnActionFactory,
private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory,
private readonly enumColumnActionFactory: EnumColumnActionFactory,
private readonly compositeColumnActionFactory: CompositeColumnActionFactory,
) {
@ -106,6 +108,10 @@ export class WorkspaceMigrationFactory {
FieldMetadataType.PHONES,
{ factory: this.compositeColumnActionFactory },
],
[
FieldMetadataType.TS_VECTOR,
{ factory: this.tsVectorColumnActionFactory },
],
]);
}

View File

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

View File

@ -1,8 +1,11 @@
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import {
RelationMetadataType,
RelationOnDeleteAction,
@ -10,10 +13,12 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@ -136,4 +141,22 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
@WorkspaceIsSystem()
timelineActivities: TimelineActivityWorkspaceEntity[];
@WorkspaceField({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector,
type: FieldMetadataType.TS_VECTOR,
label: SEARCH_VECTOR_FIELD.label,
description: SEARCH_VECTOR_FIELD.description,
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{
name: DEFAULT_LABEL_IDENTIFIER_FIELD_NAME,
type: FieldMetadataType.TEXT,
},
]),
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
@WorkspaceIndex({ indexType: IndexType.GIN })
[SEARCH_VECTOR_FIELD.name]: any;
}

View File

@ -20,6 +20,8 @@ export interface WorkspaceFieldOptions<
options?: FieldMetadataOptions<T>;
settings?: FieldMetadataSettings<T>;
isActive?: boolean;
generatedType?: 'STORED' | 'VIRTUAL';
asExpression?: string;
}
export function WorkspaceField<T extends FieldMetadataType>(
@ -76,6 +78,8 @@ export function WorkspaceField<T extends FieldMetadataType>(
gate,
isDeprecated,
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 { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
import { isDefined } from 'src/utils/is-defined';
import { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceIndex(): PropertyDecorator;
export function WorkspaceIndex(columns: string[]): ClassDecorator;
export type WorkspaceIndexMetadata = {
columns?: string[];
indexType?: IndexType;
};
export function WorkspaceIndex(
columns?: string[],
metadata?: WorkspaceIndexMetadata,
): PropertyDecorator;
export function WorkspaceIndex(
metadata: WorkspaceIndexMetadata,
): ClassDecorator;
export function WorkspaceIndex(
metadata?: WorkspaceIndexMetadata,
): PropertyDecorator | ClassDecorator {
return (target: any, propertyKey: string | symbol) => {
if (propertyKey === undefined && columns === undefined) {
if (propertyKey === undefined && metadata === undefined) {
throw new Error('Class level WorkspaceIndex should be used with columns');
}
if (propertyKey !== undefined && metadata?.columns !== undefined) {
throw new Error(
'Property level WorkspaceIndex should not be used with columns',
);
}
const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args',
target,
@ -20,29 +38,46 @@ export function WorkspaceIndex(
);
// TODO: handle composite field metadata types
if (isDefined(metadata?.columns)) {
const columns = metadata.columns;
if (columns.length > 0) {
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.name),
...columns,
])}`,
columns,
target: target,
gate,
...(isDefined(metadata?.indexType)
? { type: metadata.indexType }
: {}),
});
return;
}
}
if (isDefined(propertyKey)) {
const additionalDefaultColumnsForIndex = getColumnsForIndex(
metadata?.indexType,
);
const columns = [
propertyKey.toString(),
...additionalDefaultColumnsForIndex,
];
if (Array.isArray(columns) && columns.length > 0) {
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.name),
convertClassNameToObjectMetadataName(target.constructor.name),
...columns,
])}`,
columns,
target: target,
target: target.constructor,
...(isDefined(metadata?.indexType) ? { type: metadata.indexType } : {}),
gate,
});
return;
}
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.constructor.name),
...[propertyKey.toString(), 'deletedAt'],
])}`,
columns: [propertyKey.toString(), 'deletedAt'],
target: target.constructor,
gate,
});
};
}

View File

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

View File

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

View File

@ -664,7 +664,7 @@ export class WorkspaceRepository<
return formatData(data, objectMetadata) as T;
}
private async formatResult<T>(
async formatResult<T>(
data: T,
objectMetadata?: ObjectMetadataMapItem,
): 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)}`;
case 'destroyMany':
return `destroy${pascalCase(objectMetadata.namePlural)}`;
case 'search':
return `search${pascalCase(objectMetadata.namePlural)}`;
default:
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 { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationEntity,
WorkspaceMigrationIndexActionType,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
@Injectable()
export class WorkspaceMigrationIndexFactory {
@ -94,6 +94,7 @@ export class WorkspaceMigrationIndexFactory {
return fieldMetadata.name;
}),
type: indexMetadata.indexType,
}));
workspaceMigrations.push({

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 { WorkspaceMigrationEnumService } from 'src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service';
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util';
import { isDefined } from 'src/utils/is-defined';
import { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service';
import { customTableDefaultColumns } from './utils/custom-table-default-column.util';
@ -194,13 +195,21 @@ export class WorkspaceMigrationRunnerService {
for (const index of indexes) {
switch (index.action) {
case WorkspaceMigrationIndexActionType.CREATE:
await queryRunner.createIndex(
`${schemaName}.${tableName}`,
new TableIndex({
name: index.name,
columnNames: index.columns,
}),
);
if (isDefined(index.type)) {
const quotedColumns = index.columns.map((column) => `"${column}"`);
await queryRunner.query(`
CREATE INDEX "${index.name}" ON "${schemaName}"."${tableName}" USING ${index.type} (${quotedColumns.join(', ')})
`);
} else {
await queryRunner.createIndex(
`${schemaName}.${tableName}`,
new TableIndex({
name: index.name,
columnNames: index.columns,
}),
);
}
break;
case WorkspaceMigrationIndexActionType.DROP:
try {
@ -380,6 +389,8 @@ export class WorkspaceMigrationRunnerService {
enumName: enumName,
isArray: migrationColumn.isArray,
isNullable: migrationColumn.isNullable,
asExpression: migrationColumn.asExpression,
generatedType: migrationColumn.generatedType,
}),
);
}

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,22 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { Any, EntityManager } from 'typeorm';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { StandardIndexFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory';
import {
IndexMetadataEntity,
IndexType,
} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { mapObjectMetadataByUniqueIdentifier } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/sync-metadata.util';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { WorkspaceIndexComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-index.comparator';
import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationIndexFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory';
import { WorkspaceIndexComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-index.comparator';
import { StandardIndexFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory';
import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { mapObjectMetadataByUniqueIdentifier } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/sync-metadata.util';
@Injectable()
export class WorkspaceSyncIndexMetadataService {
@ -47,35 +50,60 @@ export class WorkspaceSyncIndexMetadataService {
workspaceId: context.workspaceId,
// We're only interested in standard fields
fields: { isCustom: false },
isCustom: false,
},
relations: ['dataSource', 'fields', 'indexes'],
});
// Create map of object metadata & field metadata by unique identifier
const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier(
originalObjectMetadataCollection,
// Relation are based on the singular name
const originalStandardObjectMetadataMap =
mapObjectMetadataByUniqueIdentifier(
originalObjectMetadataCollection.filter(
(objectMetadata) => !objectMetadata.isCustom,
),
// Relation are based on the singular name
(objectMetadata) => objectMetadata.nameSingular,
);
const originalCustomObjectMetadataMap = mapObjectMetadataByUniqueIdentifier(
originalObjectMetadataCollection.filter(
(objectMetadata) => objectMetadata.isCustom,
),
(objectMetadata) => objectMetadata.nameSingular,
);
const indexMetadataRepository = manager.getRepository(IndexMetadataEntity);
const originalIndexMetadataCollection = await indexMetadataRepository.find({
let originalIndexMetadataCollection = await indexMetadataRepository.find({
where: {
workspaceId: context.workspaceId,
objectMetadataId: Any(
Object.values(originalObjectMetadataCollection).map(
(object) => object.id,
),
),
isCustom: false,
},
relations: ['indexFieldMetadatas.fieldMetadata'],
});
// Generate index metadata from models
const standardIndexMetadataCollection = this.standardIndexFactory.create(
let standardIndexMetadataCollection = this.standardIndexFactory.create(
standardObjectMetadataDefinitions,
context,
originalObjectMetadataMap,
originalStandardObjectMetadataMap,
originalCustomObjectMetadataMap,
workspaceFeatureFlagsMap,
);
if (!workspaceFeatureFlagsMap.IS_SEARCH_ENABLED) {
originalIndexMetadataCollection = originalIndexMetadataCollection.filter(
(index) => index.indexType !== IndexType.GIN,
);
standardIndexMetadataCollection = standardIndexMetadataCollection.filter(
(index) => index.indexType !== IndexType.GIN,
);
}
const indexComparatorResults = this.workspaceIndexComparator.compare(
originalIndexMetadataCollection,
standardIndexMetadataCollection,

View File

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

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 { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import {
ActorMetadata,
FieldActorSource,
@ -8,6 +9,7 @@ import { AddressMetadata } from 'src/engine/metadata-modules/field-metadata/comp
import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import {
RelationMetadataType,
RelationOnDeleteAction,
@ -15,6 +17,7 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
@ -22,6 +25,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { COMPANY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@ -32,6 +36,9 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const NAME_FIELD_NAME = 'name';
const DOMAIN_NAME_FIELD_NAME = 'domainName';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.company,
namePlural: 'companies',
@ -49,7 +56,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
description: 'The company name',
icon: 'IconBuildingSkyscraper',
})
name: string;
[NAME_FIELD_NAME]: string;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.domainName,
@ -59,7 +66,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
'The company website URL. We use this url to fetch the company icon',
icon: 'IconLink',
})
domainName?: LinksMetadata;
[DOMAIN_NAME_FIELD_NAME]?: LinksMetadata;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.employees,
@ -273,4 +280,21 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsDeprecated()
@WorkspaceIsNullable()
addressOld: string;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.searchVector,
type: FieldMetadataType.TS_VECTOR,
label: SEARCH_VECTOR_FIELD.label,
description: SEARCH_VECTOR_FIELD.description,
icon: 'IconUser',
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT },
{ name: DOMAIN_NAME_FIELD_NAME, type: FieldMetadataType.LINKS },
]),
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
@WorkspaceIndex({ indexType: IndexType.GIN })
[SEARCH_VECTOR_FIELD.name]: any;
}

View File

@ -1,11 +1,13 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import {
RelationMetadataType,
RelationOnDeleteAction,
@ -22,6 +24,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { OPPORTUNITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
@ -31,6 +34,8 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
const NAME_FIELD_NAME = 'name';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.opportunity,
namePlural: 'opportunities',
@ -232,4 +237,20 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsDeprecated()
probability: string;
@WorkspaceField({
standardId: OPPORTUNITY_STANDARD_FIELD_IDS.searchVector,
type: FieldMetadataType.TS_VECTOR,
label: SEARCH_VECTOR_FIELD.label,
description: SEARCH_VECTOR_FIELD.description,
icon: 'IconUser',
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT },
]),
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
@WorkspaceIndex({ indexType: IndexType.GIN })
[SEARCH_VECTOR_FIELD.name]: any;
}

View File

@ -1,5 +1,6 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import {
ActorMetadata,
FieldActorSource,
@ -9,6 +10,7 @@ import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/com
import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import {
RelationMetadataType,
RelationOnDeleteAction,
@ -16,6 +18,7 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
@ -23,6 +26,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
@ -34,6 +38,10 @@ import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-obj
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
const NAME_FIELD_NAME = 'name';
const EMAILS_FIELD_NAME = 'emails';
const JOB_TITLE_FIELD_NAME = 'jobTitle';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.person,
namePlural: 'people',
@ -53,7 +61,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
icon: 'IconUser',
})
@WorkspaceIsNullable()
name: FullNameMetadata | null;
[NAME_FIELD_NAME]: FullNameMetadata | null;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.email,
@ -72,7 +80,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Contacts Emails',
icon: 'IconMail',
})
emails: EmailsMetadata;
[EMAILS_FIELD_NAME]: EmailsMetadata;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink,
@ -101,7 +109,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Contacts job title',
icon: 'IconBriefcase',
})
jobTitle: string;
[JOB_TITLE_FIELD_NAME]: string;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.phone,
@ -290,4 +298,22 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
@WorkspaceIsSystem()
timelineActivities: Relation<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;
}