Search (#7237)
Steps to test 1. Run metadata migrations 2. Run sync-metadata on your workspace 3. Enable the following feature flags: IS_SEARCH_ENABLED IS_QUERY_RUNNER_TWENTY_ORM_ENABLED IS_WORKSPACE_MIGRATED_FOR_SEARCH 4. Type Cmd + K and search anything
This commit is contained in:
@ -369,6 +369,7 @@ export enum FieldMetadataType {
|
||||
RichText = 'RICH_TEXT',
|
||||
Select = 'SELECT',
|
||||
Text = 'TEXT',
|
||||
TsVector = 'TS_VECTOR',
|
||||
Uuid = 'UUID'
|
||||
}
|
||||
|
||||
|
||||
@ -280,6 +280,7 @@ export enum FieldMetadataType {
|
||||
RichText = 'RICH_TEXT',
|
||||
Select = 'SELECT',
|
||||
Text = 'TEXT',
|
||||
TsVector = 'TS_VECTOR',
|
||||
Uuid = 'UUID'
|
||||
}
|
||||
|
||||
|
||||
@ -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 } }) => ({
|
||||
|
||||
@ -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 <></>;
|
||||
};
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
|
||||
|
||||
export type RecordGqlOperationSearchResult = {
|
||||
[objectNamePlural: string]: RecordGqlConnection;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,4 @@
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getSearchRecordsQueryResponseField = (objectNamePlural: string) =>
|
||||
`search${capitalize(objectNamePlural)}`;
|
||||
@ -0,0 +1,8 @@
|
||||
export type Opportunity = {
|
||||
__typename: 'Opportunity';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
@ -2,5 +2,5 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export type SettingsSupportedFieldType = Exclude<
|
||||
FieldMetadataType,
|
||||
FieldMetadataType.Position
|
||||
FieldMetadataType.Position | FieldMetadataType.TsVector
|
||||
>;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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>>,
|
||||
|
||||
@ -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(' | ');
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
};
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
export const SEARCH_VECTOR_FIELD = {
|
||||
name: 'searchVector',
|
||||
label: 'Search vector',
|
||||
description: 'Field used for full-text search',
|
||||
} as const;
|
||||
@ -47,6 +47,7 @@ export enum FieldMetadataType {
|
||||
RICH_TEXT = 'RICH_TEXT',
|
||||
ACTOR = 'ACTOR',
|
||||
ARRAY = 'ARRAY',
|
||||
TS_VECTOR = 'TS_VECTOR',
|
||||
}
|
||||
|
||||
@Entity('fieldMetadata')
|
||||
|
||||
@ -22,4 +22,6 @@ export interface FieldMetadataInterface<
|
||||
fromRelationMetadata?: RelationMetadataEntity;
|
||||
toRelationMetadata?: RelationMetadataEntity;
|
||||
isCustom?: boolean;
|
||||
generatedType?: 'STORED' | 'VIRTUAL';
|
||||
asExpression?: string;
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -153,6 +153,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
|
||||
relationMetadataInput.workspaceId,
|
||||
toObjectMetadata,
|
||||
[foreignKeyFieldMetadata, deletedFieldMetadata],
|
||||
false,
|
||||
);
|
||||
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
@ -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'];
|
||||
}
|
||||
};
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ const commonFieldPropertiesToIgnore = [
|
||||
'settings',
|
||||
'joinColumn',
|
||||
'gate',
|
||||
'asExpression',
|
||||
'generatedType',
|
||||
];
|
||||
|
||||
const fieldPropertiesToStringify = ['defaultValue'] as const;
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ export type PartialFieldMetadata = Omit<
|
||||
workspaceId: string;
|
||||
objectMetadataId?: string;
|
||||
isActive?: boolean;
|
||||
asExpression?: string;
|
||||
generatedType?: 'STORED' | 'VIRTUAL';
|
||||
};
|
||||
|
||||
export type PartialComputedFieldMetadata = {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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}, '')`;
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
|
||||
import {
|
||||
ActorMetadata,
|
||||
FieldActorSource,
|
||||
@ -9,6 +10,7 @@ import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/com
|
||||
import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
|
||||
import { PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
|
||||
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
|
||||
import {
|
||||
RelationMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
@ -16,6 +18,7 @@ import {
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
|
||||
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
|
||||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||
@ -23,6 +26,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-
|
||||
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
|
||||
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
|
||||
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
|
||||
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
|
||||
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
|
||||
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
|
||||
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
@ -34,6 +38,10 @@ import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-obj
|
||||
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
|
||||
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
|
||||
|
||||
const NAME_FIELD_NAME = 'name';
|
||||
const EMAILS_FIELD_NAME = 'emails';
|
||||
const JOB_TITLE_FIELD_NAME = 'jobTitle';
|
||||
|
||||
@WorkspaceEntity({
|
||||
standardId: STANDARD_OBJECT_IDS.person,
|
||||
namePlural: 'people',
|
||||
@ -53,7 +61,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
icon: 'IconUser',
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
name: FullNameMetadata | null;
|
||||
[NAME_FIELD_NAME]: FullNameMetadata | null;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: PERSON_STANDARD_FIELD_IDS.email,
|
||||
@ -72,7 +80,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
description: 'Contact’s Emails',
|
||||
icon: 'IconMail',
|
||||
})
|
||||
emails: EmailsMetadata;
|
||||
[EMAILS_FIELD_NAME]: EmailsMetadata;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink,
|
||||
@ -101,7 +109,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
description: 'Contact’s job title',
|
||||
icon: 'IconBriefcase',
|
||||
})
|
||||
jobTitle: string;
|
||||
[JOB_TITLE_FIELD_NAME]: string;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: PERSON_STANDARD_FIELD_IDS.phone,
|
||||
@ -290,4 +298,22 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
timelineActivities: Relation<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user