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:
Etienne
2025-03-06 09:11:25 +01:00
committed by GitHub
parent 201b1decb9
commit 54865d91a3
7 changed files with 275 additions and 36 deletions

View File

@ -14,6 +14,7 @@ export type Scalars = {
Int: number; Int: number;
Float: number; Float: number;
ConnectionCursor: any; ConnectionCursor: any;
Date: any;
DateTime: string; DateTime: string;
JSON: any; JSON: any;
JSONObject: any; JSONObject: any;
@ -356,6 +357,17 @@ export type CustomDomainValidRecords = {
records: Array<CustomDomainRecord>; 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 = { export type DeleteApprovedAccessDomainInput = {
id: Scalars['String']; id: Scalars['String'];
}; };
@ -579,6 +591,11 @@ export enum FileFolder {
WorkspaceLogo = 'WorkspaceLogo' WorkspaceLogo = 'WorkspaceLogo'
} }
export enum FilterIs {
NotNull = 'NotNull',
Null = 'Null'
}
export type FindAvailableSsoidpOutput = { export type FindAvailableSsoidpOutput = {
__typename?: 'FindAvailableSSOIDPOutput'; __typename?: 'FindAvailableSSOIDPOutput';
id: Scalars['String']; id: Scalars['String'];
@ -631,6 +648,17 @@ export enum HealthIndicatorId {
worker = 'worker' 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 { export enum IdentityProviderType {
OIDC = 'OIDC', OIDC = 'OIDC',
SAML = 'SAML' SAML = 'SAML'
@ -1205,6 +1233,16 @@ export type ObjectIndexMetadatasConnection = {
pageInfo: PageInfo; 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 */ /** Onboarding status */
export enum OnboardingStatus { export enum OnboardingStatus {
COMPLETED = 'COMPLETED', COMPLETED = 'COMPLETED',
@ -1396,6 +1434,8 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
export type QueryGlobalSearchArgs = { export type QueryGlobalSearchArgs = {
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>; excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
filter?: InputMaybe<ObjectRecordFilterInput>;
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
limit: Scalars['Int']; limit: Scalars['Int'];
searchInput: Scalars['String']; searchInput: Scalars['String'];
}; };

View File

@ -20,10 +20,11 @@ describe('GlobalSearchService', () => {
describe('filterObjectMetadataItems', () => { describe('filterObjectMetadataItems', () => {
it('should return searchable object metadata items', () => { it('should return searchable object metadata items', () => {
const objectMetadataItems = service.filterObjectMetadataItems( const objectMetadataItems = service.filterObjectMetadataItems({
mockObjectMetadataItemsWithFieldMaps, objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
[], includedObjectNameSingulars: [],
); excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([ expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0], mockObjectMetadataItemsWithFieldMaps[0],
@ -32,16 +33,28 @@ describe('GlobalSearchService', () => {
]); ]);
}); });
it('should return searchable object metadata items without excluded ones', () => { it('should return searchable object metadata items without excluded ones', () => {
const objectMetadataItems = service.filterObjectMetadataItems( const objectMetadataItems = service.filterObjectMetadataItems({
mockObjectMetadataItemsWithFieldMaps, objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
['company'], includedObjectNameSingulars: [],
); excludedObjectNameSingulars: ['company'],
});
expect(objectMetadataItems).toEqual([ expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0], mockObjectMetadataItemsWithFieldMaps[0],
mockObjectMetadataItemsWithFieldMaps[2], 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', () => { describe('getLabelIdentifierColumns', () => {

View File

@ -2,6 +2,8 @@ import { ArgsType, Field, Int } from '@nestjs/graphql';
import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; import { IsArray, IsInt, IsOptional, IsString } from 'class-validator';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/global-search/dtos/object-record-filter-input';
@ArgsType() @ArgsType()
export class GlobalSearchArgs { export class GlobalSearchArgs {
@Field(() => String) @Field(() => String)
@ -12,6 +14,15 @@ export class GlobalSearchArgs {
@IsInt() @IsInt()
limit: number; limit: number;
@IsArray()
@Field(() => [String], { nullable: true })
@IsOptional()
includedObjectNameSingulars?: string[];
@IsOptional()
@Field(() => ObjectRecordFilterInput, { nullable: true })
filter?: ObjectRecordFilterInput;
@IsArray() @IsArray()
@Field(() => [String], { nullable: true }) @Field(() => [String], { nullable: true })
@IsOptional() @IsOptional()

View File

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

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; 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 { 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 { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({ @Module({
imports: [WorkspaceCacheStorageModule], imports: [WorkspaceCacheStorageModule, FeatureFlagModule],
providers: [GlobalSearchResolver, GlobalSearchService], providers: [GlobalSearchResolver, GlobalSearchService],
}) })
export class GlobalSearchModule {} export class GlobalSearchModule {}

View File

@ -3,6 +3,9 @@ import { Args, Query, Resolver } from '@nestjs/graphql';
import chunk from 'lodash.chunk'; 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 { 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 { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto';
import { import {
@ -27,13 +30,20 @@ export class GlobalSearchResolver {
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly twentyORMManager: TwentyORMManager, private readonly twentyORMManager: TwentyORMManager,
private readonly globalSearchService: GlobalSearchService, private readonly globalSearchService: GlobalSearchService,
private readonly featureFlagService: FeatureFlagService,
) {} ) {}
@Query(() => [GlobalSearchRecordDTO]) @Query(() => [GlobalSearchRecordDTO])
async globalSearch( async globalSearch(
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Args() @Args()
{ searchInput, limit, excludedObjectNameSingulars }: GlobalSearchArgs, {
searchInput,
limit,
filter,
includedObjectNameSingulars,
excludedObjectNameSingulars,
}: GlobalSearchArgs,
) { ) {
const currentCacheVersion = const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); 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( const objectMetadataItemWithFieldMaps = Object.values(
objectMetadataMaps.byId, objectMetadataMaps.byId,
); );
const filteredObjectMetadataItems = const filteredObjectMetadataItems =
this.globalSearchService.filterObjectMetadataItems( this.globalSearchService.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
excludedObjectNameSingulars, includedObjectNameSingulars: includedObjectNameSingulars ?? [],
); excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] = const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
[]; [];
@ -83,18 +97,18 @@ export class GlobalSearchResolver {
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
); );
repository.createQueryBuilder();
return { return {
objectMetadataItem, objectMetadataItem,
records: records:
await this.globalSearchService.buildSearchQueryAndGetRecords( await this.globalSearchService.buildSearchQueryAndGetRecords({
repository, entityManager: repository,
objectMetadataItem, objectMetadataItem,
formatSearchTerms(searchInput, 'and'), featureFlagMap,
formatSearchTerms(searchInput, 'or'), searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit, limit,
), filter: filter ?? ({} as ObjectRecordFilter),
}),
}; };
}), }),
); );

View File

@ -1,12 +1,16 @@
import { Entity } from '@microsoft/microsoft-graph-types'; import { Injectable } from '@nestjs/common';
import { FieldMetadataType, getLogoUrlFromDomainName } from 'twenty-shared'; 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 { 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 { 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 { 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 { 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 { import {
GlobalSearchException, GlobalSearchException,
GlobalSearchExceptionCode, GlobalSearchExceptionCode,
@ -14,30 +18,70 @@ import {
import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/global-search/types/records-with-object-metadata-item'; 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 { 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 { 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'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@Injectable()
export class GlobalSearchService { export class GlobalSearchService {
filterObjectMetadataItems( filterObjectMetadataItems({
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[], objectMetadataItemWithFieldMaps,
excludedObjectNameSingulars: string[] | undefined, includedObjectNameSingulars,
) { excludedObjectNameSingulars,
}: {
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
includedObjectNameSingulars: string[];
excludedObjectNameSingulars: string[];
}) {
return objectMetadataItemWithFieldMaps.filter( return objectMetadataItemWithFieldMaps.filter(
({ nameSingular, isSearchable }) => { ({ nameSingular, isSearchable }) => {
return ( if (!isSearchable) {
!excludedObjectNameSingulars?.includes(nameSingular) && isSearchable return false;
); }
if (excludedObjectNameSingulars.includes(nameSingular)) {
return false;
}
if (includedObjectNameSingulars.length > 0) {
return includedObjectNameSingulars.includes(nameSingular);
}
return true;
}, },
); );
} }
async buildSearchQueryAndGetRecords( async buildSearchQueryAndGetRecords<Entity extends ObjectLiteral>({
entityManager: WorkspaceRepository<Entity>, entityManager,
objectMetadataItem: ObjectMetadataItemWithFieldMaps, objectMetadataItem,
searchTerms: string, featureFlagMap,
searchTermsOr: string, searchTerms,
limit: number, searchTermsOr,
) { limit,
filter,
}: {
entityManager: WorkspaceRepository<Entity>;
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
featureFlagMap: FeatureFlagMap;
searchTerms: string;
searchTermsOr: string;
limit: number;
filter: ObjectRecordFilterInput;
}) {
const queryBuilder = entityManager.createQueryBuilder(); 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 = const imageIdentifierField =
this.getImageIdentifierColumn(objectMetadataItem); this.getImageIdentifierColumn(objectMetadataItem);