update globalSearch resolver (#10680)
### Context In order to deprecate search[Object] resolvers, we need to update globalSearch resolver to bring it to the same level of functionality ### Solution - Add includedObject args to search in pre-selected tables - Add record filtering ### Tested on gql api ✅ - Simple search with search term - Search with excluded objects, with included objects, with both and both with search term - Search with id filtering and all args combined - Search with deletedAt filtering and all args combined - from front, search in command menu back end part of https://github.com/twentyhq/core-team-issues/issues/495
This commit is contained in:
@ -14,6 +14,7 @@ export type Scalars = {
|
||||
Int: number;
|
||||
Float: number;
|
||||
ConnectionCursor: any;
|
||||
Date: any;
|
||||
DateTime: string;
|
||||
JSON: any;
|
||||
JSONObject: any;
|
||||
@ -356,6 +357,17 @@ export type CustomDomainValidRecords = {
|
||||
records: Array<CustomDomainRecord>;
|
||||
};
|
||||
|
||||
export type DateFilter = {
|
||||
eq?: InputMaybe<Scalars['Date']>;
|
||||
gt?: InputMaybe<Scalars['Date']>;
|
||||
gte?: InputMaybe<Scalars['Date']>;
|
||||
in?: InputMaybe<Array<Scalars['Date']>>;
|
||||
is?: InputMaybe<FilterIs>;
|
||||
lt?: InputMaybe<Scalars['Date']>;
|
||||
lte?: InputMaybe<Scalars['Date']>;
|
||||
neq?: InputMaybe<Scalars['Date']>;
|
||||
};
|
||||
|
||||
export type DeleteApprovedAccessDomainInput = {
|
||||
id: Scalars['String'];
|
||||
};
|
||||
@ -579,6 +591,11 @@ export enum FileFolder {
|
||||
WorkspaceLogo = 'WorkspaceLogo'
|
||||
}
|
||||
|
||||
export enum FilterIs {
|
||||
NotNull = 'NotNull',
|
||||
Null = 'Null'
|
||||
}
|
||||
|
||||
export type FindAvailableSsoidpOutput = {
|
||||
__typename?: 'FindAvailableSSOIDPOutput';
|
||||
id: Scalars['String'];
|
||||
@ -631,6 +648,17 @@ export enum HealthIndicatorId {
|
||||
worker = 'worker'
|
||||
}
|
||||
|
||||
export type IdFilter = {
|
||||
eq?: InputMaybe<Scalars['ID']>;
|
||||
gt?: InputMaybe<Scalars['ID']>;
|
||||
gte?: InputMaybe<Scalars['ID']>;
|
||||
in?: InputMaybe<Array<Scalars['ID']>>;
|
||||
is?: InputMaybe<FilterIs>;
|
||||
lt?: InputMaybe<Scalars['ID']>;
|
||||
lte?: InputMaybe<Scalars['ID']>;
|
||||
neq?: InputMaybe<Scalars['ID']>;
|
||||
};
|
||||
|
||||
export enum IdentityProviderType {
|
||||
OIDC = 'OIDC',
|
||||
SAML = 'SAML'
|
||||
@ -1205,6 +1233,16 @@ export type ObjectIndexMetadatasConnection = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type ObjectRecordFilterInput = {
|
||||
and?: InputMaybe<Array<ObjectRecordFilterInput>>;
|
||||
createdAt?: InputMaybe<DateFilter>;
|
||||
deletedAt?: InputMaybe<DateFilter>;
|
||||
id?: InputMaybe<IdFilter>;
|
||||
not?: InputMaybe<ObjectRecordFilterInput>;
|
||||
or?: InputMaybe<Array<ObjectRecordFilterInput>>;
|
||||
updatedAt?: InputMaybe<DateFilter>;
|
||||
};
|
||||
|
||||
/** Onboarding status */
|
||||
export enum OnboardingStatus {
|
||||
COMPLETED = 'COMPLETED',
|
||||
@ -1396,6 +1434,8 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
|
||||
|
||||
export type QueryGlobalSearchArgs = {
|
||||
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
|
||||
filter?: InputMaybe<ObjectRecordFilterInput>;
|
||||
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
|
||||
limit: Scalars['Int'];
|
||||
searchInput: Scalars['String'];
|
||||
};
|
||||
|
||||
@ -20,10 +20,11 @@ describe('GlobalSearchService', () => {
|
||||
|
||||
describe('filterObjectMetadataItems', () => {
|
||||
it('should return searchable object metadata items', () => {
|
||||
const objectMetadataItems = service.filterObjectMetadataItems(
|
||||
mockObjectMetadataItemsWithFieldMaps,
|
||||
[],
|
||||
);
|
||||
const objectMetadataItems = service.filterObjectMetadataItems({
|
||||
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
|
||||
includedObjectNameSingulars: [],
|
||||
excludedObjectNameSingulars: [],
|
||||
});
|
||||
|
||||
expect(objectMetadataItems).toEqual([
|
||||
mockObjectMetadataItemsWithFieldMaps[0],
|
||||
@ -32,16 +33,28 @@ describe('GlobalSearchService', () => {
|
||||
]);
|
||||
});
|
||||
it('should return searchable object metadata items without excluded ones', () => {
|
||||
const objectMetadataItems = service.filterObjectMetadataItems(
|
||||
mockObjectMetadataItemsWithFieldMaps,
|
||||
['company'],
|
||||
);
|
||||
const objectMetadataItems = service.filterObjectMetadataItems({
|
||||
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
|
||||
includedObjectNameSingulars: [],
|
||||
excludedObjectNameSingulars: ['company'],
|
||||
});
|
||||
|
||||
expect(objectMetadataItems).toEqual([
|
||||
mockObjectMetadataItemsWithFieldMaps[0],
|
||||
mockObjectMetadataItemsWithFieldMaps[2],
|
||||
]);
|
||||
});
|
||||
it('should return searchable object metadata items with included ones only', () => {
|
||||
const objectMetadataItems = service.filterObjectMetadataItems({
|
||||
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
|
||||
includedObjectNameSingulars: ['company'],
|
||||
excludedObjectNameSingulars: [],
|
||||
});
|
||||
|
||||
expect(objectMetadataItems).toEqual([
|
||||
mockObjectMetadataItemsWithFieldMaps[1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLabelIdentifierColumns', () => {
|
||||
|
||||
@ -2,6 +2,8 @@ import { ArgsType, Field, Int } from '@nestjs/graphql';
|
||||
|
||||
import { IsArray, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { ObjectRecordFilterInput } from 'src/engine/core-modules/global-search/dtos/object-record-filter-input';
|
||||
|
||||
@ArgsType()
|
||||
export class GlobalSearchArgs {
|
||||
@Field(() => String)
|
||||
@ -12,6 +14,15 @@ export class GlobalSearchArgs {
|
||||
@IsInt()
|
||||
limit: number;
|
||||
|
||||
@IsArray()
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
includedObjectNameSingulars?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => ObjectRecordFilterInput, { nullable: true })
|
||||
filter?: ObjectRecordFilterInput;
|
||||
|
||||
@IsArray()
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
import { Field, ID, InputType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IsArray, IsOptional } from 'class-validator';
|
||||
|
||||
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { DateScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class ObjectRecordFilterInput implements Partial<ObjectRecordFilter> {
|
||||
@Field(() => [ObjectRecordFilterInput], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
and?: ObjectRecordFilterInput[];
|
||||
|
||||
@Field(() => ObjectRecordFilterInput, { nullable: true })
|
||||
@IsOptional()
|
||||
not?: ObjectRecordFilterInput;
|
||||
|
||||
@Field(() => [ObjectRecordFilterInput], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
or?: ObjectRecordFilterInput[];
|
||||
|
||||
@Field(() => IDFilterType, { nullable: true })
|
||||
@IsOptional()
|
||||
id?: IDFilterType | null;
|
||||
|
||||
@Field(() => DateFilterType, { nullable: true })
|
||||
createdAt?: DateFilterType | null;
|
||||
|
||||
@Field(() => DateFilterType, { nullable: true })
|
||||
updatedAt?: DateFilterType | null;
|
||||
|
||||
@Field(() => DateFilterType, { nullable: true })
|
||||
deletedAt?: DateFilterType | null;
|
||||
}
|
||||
|
||||
@InputType('IDFilter')
|
||||
class IDFilterType {
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
eq?: string;
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
gt?: string;
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
gte?: string;
|
||||
|
||||
@Field(() => [ID], { nullable: true })
|
||||
@IsOptional()
|
||||
in?: string[];
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
lt?: string;
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
lte?: string;
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
neq?: string;
|
||||
|
||||
@Field(() => FilterIs, { nullable: true })
|
||||
@IsOptional()
|
||||
is?: FilterIs;
|
||||
}
|
||||
|
||||
@InputType('DateFilter')
|
||||
class DateFilterType {
|
||||
@Field(() => DateScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
eq?: Date;
|
||||
|
||||
@Field(() => DateScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
gt?: Date;
|
||||
|
||||
@Field(() => DateScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
gte?: Date;
|
||||
|
||||
@Field(() => [DateScalarType], { nullable: true })
|
||||
@IsOptional()
|
||||
in?: Date[];
|
||||
|
||||
@Field(() => DateScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
lt?: Date;
|
||||
|
||||
@Field(() => DateScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
lte?: Date;
|
||||
|
||||
@Field(() => DateScalarType, { nullable: true })
|
||||
@IsOptional()
|
||||
neq?: Date;
|
||||
|
||||
@Field(() => FilterIs, { nullable: true })
|
||||
@IsOptional()
|
||||
is?: FilterIs;
|
||||
}
|
||||
|
||||
enum FilterIs {
|
||||
NotNull = 'NOT_NULL',
|
||||
Null = 'NULL',
|
||||
}
|
||||
|
||||
registerEnumType(FilterIs, {
|
||||
name: 'FilterIs',
|
||||
});
|
||||
@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { GlobalSearchResolver } from 'src/engine/core-modules/global-search/global-search.resolver';
|
||||
import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceCacheStorageModule],
|
||||
imports: [WorkspaceCacheStorageModule, FeatureFlagModule],
|
||||
providers: [GlobalSearchResolver, GlobalSearchService],
|
||||
})
|
||||
export class GlobalSearchModule {}
|
||||
|
||||
@ -3,6 +3,9 @@ import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import chunk from 'lodash.chunk';
|
||||
|
||||
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { GlobalSearchArgs } from 'src/engine/core-modules/global-search/dtos/global-search-args';
|
||||
import { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto';
|
||||
import {
|
||||
@ -27,13 +30,20 @@ export class GlobalSearchResolver {
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly twentyORMManager: TwentyORMManager,
|
||||
private readonly globalSearchService: GlobalSearchService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Query(() => [GlobalSearchRecordDTO])
|
||||
async globalSearch(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args()
|
||||
{ searchInput, limit, excludedObjectNameSingulars }: GlobalSearchArgs,
|
||||
{
|
||||
searchInput,
|
||||
limit,
|
||||
filter,
|
||||
includedObjectNameSingulars,
|
||||
excludedObjectNameSingulars,
|
||||
}: GlobalSearchArgs,
|
||||
) {
|
||||
const currentCacheVersion =
|
||||
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
|
||||
@ -58,15 +68,19 @@ export class GlobalSearchResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const featureFlagMap =
|
||||
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
|
||||
|
||||
const objectMetadataItemWithFieldMaps = Object.values(
|
||||
objectMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const filteredObjectMetadataItems =
|
||||
this.globalSearchService.filterObjectMetadataItems(
|
||||
this.globalSearchService.filterObjectMetadataItems({
|
||||
objectMetadataItemWithFieldMaps,
|
||||
excludedObjectNameSingulars,
|
||||
);
|
||||
includedObjectNameSingulars: includedObjectNameSingulars ?? [],
|
||||
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
|
||||
});
|
||||
|
||||
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
|
||||
[];
|
||||
@ -83,18 +97,18 @@ export class GlobalSearchResolver {
|
||||
objectMetadataItem.nameSingular,
|
||||
);
|
||||
|
||||
repository.createQueryBuilder();
|
||||
|
||||
return {
|
||||
objectMetadataItem,
|
||||
records:
|
||||
await this.globalSearchService.buildSearchQueryAndGetRecords(
|
||||
repository,
|
||||
await this.globalSearchService.buildSearchQueryAndGetRecords({
|
||||
entityManager: repository,
|
||||
objectMetadataItem,
|
||||
formatSearchTerms(searchInput, 'and'),
|
||||
formatSearchTerms(searchInput, 'or'),
|
||||
featureFlagMap,
|
||||
searchTerms: formatSearchTerms(searchInput, 'and'),
|
||||
searchTermsOr: formatSearchTerms(searchInput, 'or'),
|
||||
limit,
|
||||
),
|
||||
filter: filter ?? ({} as ObjectRecordFilter),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { Entity } from '@microsoft/microsoft-graph-types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType, getLogoUrlFromDomainName } from 'twenty-shared';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Brackets, ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS } from 'src/engine/core-modules/global-search/constants/results-limit-by-object-without-search-terms';
|
||||
import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/global-search/constants/standard-objects-by-priority-rank';
|
||||
import { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto';
|
||||
import { ObjectRecordFilterInput } from 'src/engine/core-modules/global-search/dtos/object-record-filter-input';
|
||||
import {
|
||||
GlobalSearchException,
|
||||
GlobalSearchExceptionCode,
|
||||
@ -14,30 +18,70 @@ import {
|
||||
import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/global-search/types/records-with-object-metadata-item';
|
||||
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalSearchService {
|
||||
filterObjectMetadataItems(
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[],
|
||||
excludedObjectNameSingulars: string[] | undefined,
|
||||
) {
|
||||
filterObjectMetadataItems({
|
||||
objectMetadataItemWithFieldMaps,
|
||||
includedObjectNameSingulars,
|
||||
excludedObjectNameSingulars,
|
||||
}: {
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
|
||||
includedObjectNameSingulars: string[];
|
||||
excludedObjectNameSingulars: string[];
|
||||
}) {
|
||||
return objectMetadataItemWithFieldMaps.filter(
|
||||
({ nameSingular, isSearchable }) => {
|
||||
return (
|
||||
!excludedObjectNameSingulars?.includes(nameSingular) && isSearchable
|
||||
);
|
||||
if (!isSearchable) {
|
||||
return false;
|
||||
}
|
||||
if (excludedObjectNameSingulars.includes(nameSingular)) {
|
||||
return false;
|
||||
}
|
||||
if (includedObjectNameSingulars.length > 0) {
|
||||
return includedObjectNameSingulars.includes(nameSingular);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async buildSearchQueryAndGetRecords(
|
||||
entityManager: WorkspaceRepository<Entity>,
|
||||
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
|
||||
searchTerms: string,
|
||||
searchTermsOr: string,
|
||||
limit: number,
|
||||
) {
|
||||
async buildSearchQueryAndGetRecords<Entity extends ObjectLiteral>({
|
||||
entityManager,
|
||||
objectMetadataItem,
|
||||
featureFlagMap,
|
||||
searchTerms,
|
||||
searchTermsOr,
|
||||
limit,
|
||||
filter,
|
||||
}: {
|
||||
entityManager: WorkspaceRepository<Entity>;
|
||||
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
|
||||
featureFlagMap: FeatureFlagMap;
|
||||
searchTerms: string;
|
||||
searchTermsOr: string;
|
||||
limit: number;
|
||||
filter: ObjectRecordFilterInput;
|
||||
}) {
|
||||
const queryBuilder = entityManager.createQueryBuilder();
|
||||
|
||||
const queryParser = new GraphqlQueryParser(
|
||||
objectMetadataItem.fieldsByName,
|
||||
generateObjectMetadataMaps([objectMetadataItem]),
|
||||
featureFlagMap,
|
||||
);
|
||||
|
||||
queryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
objectMetadataItem.nameSingular,
|
||||
filter,
|
||||
);
|
||||
|
||||
queryParser.applyDeletedAtToBuilder(queryBuilder, filter);
|
||||
|
||||
const imageIdentifierField =
|
||||
this.getImageIdentifierColumn(objectMetadataItem);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user