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

View File

@ -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', () => {

View File

@ -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()

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 { 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 {}

View File

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

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 { 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);