add new globalSearch resolver + update useSearchRecords hook (#10457)

# Context

To enable search records sorting by ts_rank_cd / ts_rank, we have
decided to add a new search resolver serving `GlobalSearchRecordDTO`.

-----

- [x] Test to add - work in progress


closes https://github.com/twentyhq/core-team-issues/issues/357
This commit is contained in:
Etienne
2025-02-25 17:43:35 +01:00
committed by GitHub
parent 3f25d13999
commit 90a390ee33
27 changed files with 1126 additions and 256 deletions

View File

@ -19,6 +19,7 @@ import { SearchResolverArgs } from 'src/engine/api/graphql/workspace-resolver-bu
import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { formatSearchTerms } from 'src/engine/core-modules/global-search/utils/format-search-terms';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@ -52,11 +53,11 @@ export class GraphqlQuerySearchResolverService extends GraphqlQueryBaseResolverS
});
}
const searchTerms = this.formatSearchTerms(
const searchTerms = formatSearchTerms(
executionArgs.args.searchInput,
'and',
);
const searchTermsOr = this.formatSearchTerms(
const searchTermsOr = formatSearchTerms(
executionArgs.args.searchInput,
'or',
);
@ -150,23 +151,6 @@ export class GraphqlQuerySearchResolverService extends GraphqlQueryBaseResolverS
});
}
private formatSearchTerms(
searchTerm: string,
operator: 'and' | 'or' = 'and',
) {
if (searchTerm === '') {
return '';
}
const words = searchTerm.trim().split(/\s+/);
const formattedWords = words.map((word) => {
const escapedWord = word.replace(/[\\:'&|!()]/g, '\\$&');
return `${escapedWord}:*`;
});
return formattedWords.join(` ${operator === 'and' ? '&' : '|'} `);
}
async validate(
_args: SearchResolverArgs,
_options: WorkspaceQueryRunnerOptions,

View File

@ -5,6 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
@ -21,6 +22,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { fileStorageModuleFactory } from 'src/engine/core-modules/file-storage/file-storage.module-factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { GlobalSearchModule } from 'src/engine/core-modules/global-search/global-search.module';
import { HealthModule } from 'src/engine/core-modules/health/health.module';
import { LabModule } from 'src/engine/core-modules/lab/lab.module';
import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module';
@ -46,7 +48,6 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { ClientConfigModule } from './client-config/client-config.module';
@ -120,6 +121,7 @@ import { FileModule } from './file/file.module';
useFactory: serverlessModuleFactory,
inject: [EnvironmentService, FileStorageService],
}),
GlobalSearchModule,
],
exports: [
AnalyticsModule,

View File

@ -0,0 +1,243 @@
import { FieldMetadataType } from 'twenty-shared';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const mockObjectMetadataItemsWithFieldMaps: ObjectMetadataItemWithFieldMaps[] =
[
{
id: '',
standardId: '',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
fromRelations: [],
toRelations: [],
labelIdentifierFieldMetadataId: 'nameFieldMetadataId',
imageIdentifierFieldMetadataId: '',
workspaceId: '',
fields: [],
indexMetadatas: [],
fieldsById: {
nameFieldMetadataId: {
id: 'nameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.FULL_NAME,
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
description: 'Contacts name',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
},
fieldsByName: {
name: {
id: 'nameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.FULL_NAME,
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
description: 'Contacts name',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
},
},
{
id: '',
standardId: '',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
fromRelations: [],
toRelations: [],
labelIdentifierFieldMetadataId: 'nameFieldMetadataId',
imageIdentifierFieldMetadataId: '',
workspaceId: '',
fields: [],
indexMetadatas: [],
fieldsById: {
nameFieldMetadataId: {
id: 'nameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
domainNameFieldMetadataId: {
id: 'domainNameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.LINKS,
name: 'domainName',
label: 'Domain Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
},
fieldsByName: {
name: {
id: 'nameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
domainName: {
id: 'domainNameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.LINKS,
name: 'domainName',
label: 'Domain Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
},
},
{
id: '',
standardId: '',
nameSingular: 'regular-custom-object',
namePlural: 'regular-custom-objects',
labelSingular: 'Regular Custom Object',
labelPlural: 'Regular Custom Objects',
description: 'A regular custom object',
targetTableName: 'DEPRECATED',
isCustom: true,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
fromRelations: [],
toRelations: [],
labelIdentifierFieldMetadataId: 'nameFieldMetadataId',
imageIdentifierFieldMetadataId: 'imageIdentifierFieldMetadataId',
workspaceId: '',
fields: [],
indexMetadatas: [],
fieldsById: {
nameFieldMetadataId: {
id: 'nameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
imageIdentifierFieldMetadataId: {
id: 'imageIdentifierFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'imageIdentifierFieldName',
label: 'Image Identifier Field Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
},
fieldsByName: {
name: {
id: 'nameFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
imageIdentifierFieldName: {
id: 'imageIdentifierFieldMetadataId',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'imageIdentifierFieldName',
label: 'Image Identifier Field Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
},
},
{
id: '',
standardId: '',
nameSingular: 'non-searchable-object',
namePlural: 'non-searchable-objects',
labelSingular: '',
labelPlural: '',
description: '',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: true,
isAuditLogged: true,
fromRelations: [],
toRelations: [],
labelIdentifierFieldMetadataId: '',
imageIdentifierFieldMetadataId: '',
workspaceId: '',
fields: [],
indexMetadatas: [],
fieldsById: {},
fieldsByName: {},
},
];

View File

@ -0,0 +1,195 @@
import { Test, TestingModule } from '@nestjs/testing';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/global-search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service';
describe('GlobalSearchService', () => {
let service: GlobalSearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GlobalSearchService],
}).compile();
service = module.get<GlobalSearchService>(GlobalSearchService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('filterObjectMetadataItems', () => {
it('should return searchable object metadata items -- TODO isSearchable only', () => {
const objectMetadataItems = service.filterObjectMetadataItems(
mockObjectMetadataItemsWithFieldMaps,
[],
);
expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0],
mockObjectMetadataItemsWithFieldMaps[1],
mockObjectMetadataItemsWithFieldMaps[2],
]);
});
it('should return searchable object metadata items without excluded ones', () => {
const objectMetadataItems = service.filterObjectMetadataItems(
mockObjectMetadataItemsWithFieldMaps,
['company'],
);
expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0],
mockObjectMetadataItemsWithFieldMaps[2],
]);
});
});
describe('getLabelIdentifierColumns', () => {
it('should return the two label identifier columns for a person object metadata item', () => {
const labelIdentifierColumns = service.getLabelIdentifierColumns(
mockObjectMetadataItemsWithFieldMaps[0],
);
expect(labelIdentifierColumns).toEqual(['nameFirstName', 'nameLastName']);
});
it('should return the label identifier column for a regular object metadata item', () => {
const labelIdentifierColumns = service.getLabelIdentifierColumns(
mockObjectMetadataItemsWithFieldMaps[1],
);
expect(labelIdentifierColumns).toEqual(['name']);
});
});
describe('getImageIdentifierColumn', () => {
it('should return null if the object metadata item does not have an image identifier', () => {
const imageIdentifierColumn = service.getImageIdentifierColumn(
mockObjectMetadataItemsWithFieldMaps[0],
);
expect(imageIdentifierColumn).toBeNull();
});
it('should return `domainNamePrimaryLinkUrl` column for a company object metadata item', () => {
const imageIdentifierColumn = service.getImageIdentifierColumn(
mockObjectMetadataItemsWithFieldMaps[1],
);
expect(imageIdentifierColumn).toEqual('domainNamePrimaryLinkUrl');
});
it('should return the image identifier column', () => {
const imageIdentifierColumn = service.getImageIdentifierColumn(
mockObjectMetadataItemsWithFieldMaps[2],
);
expect(imageIdentifierColumn).toEqual('imageIdentifierFieldName');
});
});
describe('sortSearchObjectResults', () => {
it('should sort the search object results by tsRankCD', () => {
const objectResults = [
{
objectSingularName: 'person',
tsRankCD: 2,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectSingularName: 'company',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectSingularName: 'regular-custom-object',
tsRankCD: 3,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
];
expect(service.sortSearchObjectResults([...objectResults])).toEqual([
objectResults[2],
objectResults[0],
objectResults[1],
]);
});
it('should sort the search object results by tsRank, if tsRankCD is the same', () => {
const objectResults = [
{
objectSingularName: 'person',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectSingularName: 'company',
tsRankCD: 1,
tsRank: 2,
recordId: '',
label: '',
imageUrl: '',
},
{
objectSingularName: 'regular-custom-object',
tsRankCD: 1,
tsRank: 3,
recordId: '',
label: '',
imageUrl: '',
},
];
expect(service.sortSearchObjectResults([...objectResults])).toEqual([
objectResults[2],
objectResults[1],
objectResults[0],
]);
});
it('should sort the search object results by priority rank, if tsRankCD and tsRank are the same', () => {
const objectResults = [
{
objectSingularName: 'company',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectSingularName: 'person',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectSingularName: 'regular-custom-object',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
];
expect(service.sortSearchObjectResults([...objectResults])).toEqual([
objectResults[1],
objectResults[0],
objectResults[2],
]);
});
});
});

View File

@ -0,0 +1 @@
export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8;

View File

@ -0,0 +1,8 @@
//the higher the number, the higher the priority
export const STANDARD_OBJECTS_BY_PRIORITY_RANK = {
person: 5,
company: 4,
opportunity: 3,
note: 2,
task: 1,
};

View File

@ -0,0 +1,19 @@
import { ArgsType, Field, Int } from '@nestjs/graphql';
import { IsArray, IsInt, IsOptional, IsString } from 'class-validator';
@ArgsType()
export class GlobalSearchArgs {
@Field(() => String)
@IsString()
searchInput: string;
@Field(() => Int)
@IsInt()
limit: number;
@IsArray()
@Field(() => [String], { nullable: true })
@IsOptional()
excludedObjectNameSingulars?: string[];
}

View File

@ -0,0 +1,32 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
@ObjectType('GlobalSearchRecord')
export class GlobalSearchRecordDTO {
@Field(() => String)
@IsString()
@IsNotEmpty()
recordId: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
objectSingularName: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
label: string;
@Field(() => String, { nullable: true })
imageUrl: string;
@Field(() => Number)
@IsNumber()
tsRankCD: number;
@Field(() => Number)
@IsNumber()
tsRank: number;
}

View File

@ -0,0 +1,13 @@
import { CustomException } from 'src/utils/custom-exception';
export class GlobalSearchException extends CustomException {
constructor(message: string, code: GlobalSearchExceptionCode) {
super(message, code);
}
}
export enum GlobalSearchExceptionCode {
METADATA_CACHE_VERSION_NOT_FOUND = 'METADATA_CACHE_VERSION_NOT_FOUND',
LABEL_IDENTIFIER_FIELD_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_NOT_FOUND',
OBJECT_METADATA_MAP_NOT_FOUND = 'OBJECT_METADATA_MAP_NOT_FOUND',
}

View File

@ -0,0 +1,16 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import { GlobalSearchException } from 'src/engine/core-modules/global-search/exceptions/global-search.exception';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(GlobalSearchException)
export class GlobalSearchApiExceptionFilter implements ExceptionFilter {
constructor() {}
catch(exception: GlobalSearchException) {
switch (exception.code) {
default:
throw new InternalServerError(exception.message);
}
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
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],
providers: [GlobalSearchResolver, GlobalSearchService],
})
export class GlobalSearchModule {}

View File

@ -0,0 +1,110 @@
import { UseFilters } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import chunk from 'lodash.chunk';
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 {
GlobalSearchException,
GlobalSearchExceptionCode,
} from 'src/engine/core-modules/global-search/exceptions/global-search.exception';
import { GlobalSearchApiExceptionFilter } from 'src/engine/core-modules/global-search/filters/global-search-api-exception.filter';
import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service';
import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/global-search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/global-search/utils/format-search-terms';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5;
@Resolver(() => [GlobalSearchRecordDTO])
@UseFilters(GlobalSearchApiExceptionFilter)
export class GlobalSearchResolver {
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly twentyORMManager: TwentyORMManager,
private readonly globalSearchService: GlobalSearchService,
) {}
@Query(() => [GlobalSearchRecordDTO])
async globalSearch(
@AuthWorkspace() workspace: Workspace,
@Args()
{ searchInput, limit, excludedObjectNameSingulars }: GlobalSearchArgs,
) {
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
throw new GlobalSearchException(
'Metadata cache version not found',
GlobalSearchExceptionCode.METADATA_CACHE_VERSION_NOT_FOUND,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new GlobalSearchException(
`Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`,
GlobalSearchExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND,
);
}
const objectMetadataItemWithFieldMaps = Object.values(
objectMetadataMaps.byId,
);
const filteredObjectMetadataItems =
this.globalSearchService.filterObjectMetadataItems(
objectMetadataItemWithFieldMaps,
excludedObjectNameSingulars,
);
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
[];
const filteredObjectMetadataItemsChunks = chunk(
filteredObjectMetadataItems,
OBJECT_METADATA_ITEMS_CHUNK_SIZE,
);
for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) {
const recordsWithObjectMetadataItems = await Promise.all(
objectMetadataItemChunk.map(async (objectMetadataItem) => {
const repository = await this.twentyORMManager.getRepository(
objectMetadataItem.nameSingular,
);
repository.createQueryBuilder();
return {
objectMetadataItem,
records:
await this.globalSearchService.buildSearchQueryAndGetRecords(
repository,
objectMetadataItem,
formatSearchTerms(searchInput, 'and'),
formatSearchTerms(searchInput, 'or'),
limit,
),
};
}),
);
allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems);
}
return this.globalSearchService.computeSearchObjectResults(
allRecordsWithObjectMetadataItems,
limit,
);
}
}

View File

@ -0,0 +1,217 @@
import { Entity } from '@microsoft/microsoft-graph-types';
import { getLogoUrlFromDomainName } from 'twenty-shared';
import { Brackets } from 'typeorm';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
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 {
GlobalSearchException,
GlobalSearchExceptionCode,
} from 'src/engine/core-modules/global-search/exceptions/global-search.exception';
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 { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
export class GlobalSearchService {
filterObjectMetadataItems(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[],
excludedObjectNameSingulars: string[] | undefined,
) {
return objectMetadataItemWithFieldMaps.filter(
({ nameSingular, isSystem, isRemote, isCustom }) => {
if (excludedObjectNameSingulars?.includes(nameSingular)) {
return false;
}
//TODO - #345 issue - IsSearchable decorator
if (isSystem || isRemote) {
return false;
}
return (
isCustom ||
['company', 'person', 'opportunity', 'note', 'task'].includes(
nameSingular,
)
);
},
);
}
async buildSearchQueryAndGetRecords(
entityManager: WorkspaceRepository<Entity>,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
searchTerms: string,
searchTermsOr: string,
limit: number,
) {
const queryBuilder = entityManager.createQueryBuilder();
const imageIdentifierField =
this.getImageIdentifierColumn(objectMetadataItem);
const fieldsToSelect = [
'id',
...this.getLabelIdentifierColumns(objectMetadataItem),
...(imageIdentifierField ? [imageIdentifierField] : []),
].map((field) => `"${field}"`);
const searchQuery = searchTerms
? queryBuilder
.select(fieldsToSelect)
.addSelect(
`ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`,
'tsRankCD',
)
.addSelect(
`ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`,
'tsRank',
)
.andWhere(
new Brackets((qb) => {
qb.where(
`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`,
{ searchTerms },
).orWhere(
`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`,
{ searchTermsOr },
);
}),
)
.orderBy(
`ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`,
'DESC',
)
.addOrderBy(
`ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`,
'DESC',
)
.setParameter('searchTerms', searchTerms)
.setParameter('searchTermsOr', searchTermsOr)
.take(limit)
: queryBuilder
.select(fieldsToSelect)
.addSelect('0', 'tsRankCD')
.addSelect('0', 'tsRank')
.andWhere(
new Brackets((qb) => {
qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`);
}),
)
.take(RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS);
return await searchQuery.getRawMany();
}
getLabelIdentifierColumns(
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
) {
if (!objectMetadataItem.labelIdentifierFieldMetadataId) {
throw new GlobalSearchException(
'Label identifier field not found',
GlobalSearchExceptionCode.LABEL_IDENTIFIER_FIELD_NOT_FOUND,
);
}
const labelIdentifierField =
objectMetadataItem.fieldsById[
objectMetadataItem.labelIdentifierFieldMetadataId
];
if (objectMetadataItem.nameSingular === 'person') {
return [
`${labelIdentifierField.name}FirstName`,
`${labelIdentifierField.name}LastName`,
];
}
return [
objectMetadataItem.fieldsById[
objectMetadataItem.labelIdentifierFieldMetadataId
].name,
];
}
getLabelIdentifierValue(
record: ObjectRecord,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
): string {
const labelIdentifierFields =
this.getLabelIdentifierColumns(objectMetadataItem);
return labelIdentifierFields.map((field) => record[field]).join(' ');
}
getImageIdentifierColumn(
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
) {
if (objectMetadataItem.nameSingular === 'company') {
return 'domainNamePrimaryLinkUrl';
}
if (!objectMetadataItem.imageIdentifierFieldMetadataId) {
return null;
}
return objectMetadataItem.fieldsById[
objectMetadataItem.imageIdentifierFieldMetadataId
].name;
}
getImageIdentifierValue(
record: ObjectRecord,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
): string {
const imageIdentifierField =
this.getImageIdentifierColumn(objectMetadataItem);
if (objectMetadataItem.nameSingular === 'company') {
return getLogoUrlFromDomainName(record.domainNamePrimaryLinkUrl) || '';
}
return imageIdentifierField ? record[imageIdentifierField] : '';
}
computeSearchObjectResults(
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[],
limit: number,
) {
const searchRecords = recordsWithObjectMetadataItems.flatMap(
({ objectMetadataItem, records }) => {
return records.map((record) => {
return {
recordId: record.id,
objectSingularName: objectMetadataItem.nameSingular,
label: this.getLabelIdentifierValue(record, objectMetadataItem),
imageUrl: this.getImageIdentifierValue(record, objectMetadataItem),
tsRankCD: record.tsRankCD,
tsRank: record.tsRank,
};
});
},
);
return this.sortSearchObjectResults(searchRecords).slice(0, limit);
}
sortSearchObjectResults(
searchObjectResultsWithRank: GlobalSearchRecordDTO[],
) {
return searchObjectResultsWithRank.sort((a, b) => {
if (a.tsRankCD !== b.tsRankCD) {
return b.tsRankCD - a.tsRankCD;
}
if (a.tsRank !== b.tsRank) {
return b.tsRank - a.tsRank;
}
return (
(STANDARD_OBJECTS_BY_PRIORITY_RANK[b.objectSingularName] || 0) -
(STANDARD_OBJECTS_BY_PRIORITY_RANK[a.objectSingularName] || 0)
);
});
}
}

View File

@ -0,0 +1,8 @@
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export type RecordsWithObjectMetadataItem = {
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
records: ObjectRecord[];
};

View File

@ -0,0 +1,15 @@
import { formatSearchTerms } from 'src/engine/core-modules/global-search/utils/format-search-terms';
describe('formatSearchTerms', () => {
it('should format the search terms', () => {
const formattedTerms = formatSearchTerms('my search input', 'and');
expect(formattedTerms).toBe('my:* & search:* & input:*');
});
it('should format the search terms with or', () => {
const formattedTerms = formatSearchTerms('my search input', 'or');
expect(formattedTerms).toBe('my:* | search:* | input:*');
});
});

View File

@ -0,0 +1,16 @@
export const formatSearchTerms = (
searchTerm: string,
operator: 'and' | 'or' = 'and',
) => {
if (searchTerm === '') {
return '';
}
const words = searchTerm.trim().split(/\s+/);
const formattedWords = words.map((word) => {
const escapedWord = word.replace(/[\\:'&|!()]/g, '\\$&');
return `${escapedWord}:*`;
});
return formattedWords.join(` ${operator === 'and' ? '&' : '|'} `);
};