clean searchResolvers in server (#11114)

Introduces break in change

- remove search... resolvers
- rename globalSearch to search
- rename searchRecord.objectSingularName > objectNameSingular
closes https://github.com/twentyhq/core-team-issues/issues/643
This commit is contained in:
Etienne
2025-03-24 13:42:51 +01:00
committed by GitHub
parent 6e7d2db58f
commit 1c5f3ef5fa
52 changed files with 236 additions and 529 deletions

View File

@ -0,0 +1,247 @@
import { FieldMetadataType } from 'twenty-shared/types';
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,
isSearchable: 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,
isSearchable: 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,
isSearchable: 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,
isSearchable: false,
fromRelations: [],
toRelations: [],
labelIdentifierFieldMetadataId: '',
imageIdentifierFieldMetadataId: '',
workspaceId: '',
fields: [],
indexMetadatas: [],
fieldsById: {},
fieldsByName: {},
},
];

View File

@ -0,0 +1,208 @@
import { Test, TestingModule } from '@nestjs/testing';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
describe('SearchService', () => {
let service: SearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SearchService],
}).compile();
service = module.get<SearchService>(SearchService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('filterObjectMetadataItems', () => {
it('should return searchable object metadata items', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
includedObjectNameSingulars: [],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0],
mockObjectMetadataItemsWithFieldMaps[1],
mockObjectMetadataItemsWithFieldMaps[2],
]);
});
it('should return searchable object metadata items without excluded ones', () => {
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', () => {
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 = [
{
objectNameSingular: 'person',
tsRankCD: 2,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectNameSingular: 'company',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectNameSingular: '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 = [
{
objectNameSingular: 'person',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectNameSingular: 'company',
tsRankCD: 1,
tsRank: 2,
recordId: '',
label: '',
imageUrl: '',
},
{
objectNameSingular: '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 = [
{
objectNameSingular: 'company',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectNameSingular: 'person',
tsRankCD: 1,
tsRank: 1,
recordId: '',
label: '',
imageUrl: '',
},
{
objectNameSingular: '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,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

@ -0,0 +1,30 @@
import { ArgsType, Field, Int } from '@nestjs/graphql';
import { IsArray, IsInt, IsOptional, IsString } from 'class-validator';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
@ArgsType()
export class SearchArgs {
@Field(() => String)
@IsString()
searchInput: string;
@Field(() => Int)
@IsInt()
limit: number;
@IsArray()
@Field(() => [String], { nullable: true })
@IsOptional()
includedObjectNameSingulars?: string[];
@IsOptional()
@Field(() => ObjectRecordFilterInput, { nullable: true })
filter?: ObjectRecordFilterInput;
@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('SearchRecord')
export class SearchRecordDTO {
@Field(() => String)
@IsString()
@IsNotEmpty()
recordId: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
objectNameSingular: 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 SearchException extends CustomException {
constructor(message: string, code: SearchExceptionCode) {
super(message, code);
}
}
export enum SearchExceptionCode {
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 { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { SearchException } from 'src/engine/core-modules/search/exceptions/search.exception';
@Catch(SearchException)
export class SearchApiExceptionFilter implements ExceptionFilter {
constructor() {}
catch(exception: SearchException) {
switch (exception.code) {
default:
throw new InternalServerError(exception.message);
}
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { SearchResolver } from 'src/engine/core-modules/search/search.resolver';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [WorkspaceCacheStorageModule, FeatureFlagModule],
providers: [SearchResolver, SearchService],
})
export class SearchModule {}

View File

@ -0,0 +1,123 @@
import { UseFilters } from '@nestjs/common';
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 { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
import {
SearchException,
SearchExceptionCode,
} from 'src/engine/core-modules/search/exceptions/search.exception';
import { SearchApiExceptionFilter } from 'src/engine/core-modules/search/filters/search-api-exception.filter';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/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(() => [SearchRecordDTO])
@UseFilters(SearchApiExceptionFilter)
export class SearchResolver {
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly twentyORMManager: TwentyORMManager,
private readonly searchService: SearchService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => [SearchRecordDTO])
async search(
@AuthWorkspace() workspace: Workspace,
@Args()
{
searchInput,
limit,
filter,
includedObjectNameSingulars,
excludedObjectNameSingulars,
}: SearchArgs,
) {
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
throw new SearchException(
'Metadata cache version not found',
SearchExceptionCode.METADATA_CACHE_VERSION_NOT_FOUND,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new SearchException(
`Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`,
SearchExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND,
);
}
const featureFlagMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
const objectMetadataItemWithFieldMaps = Object.values(
objectMetadataMaps.byId,
);
const filteredObjectMetadataItems =
this.searchService.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
includedObjectNameSingulars: includedObjectNameSingulars ?? [],
excludedObjectNameSingulars: 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,
);
return {
objectMetadataItem,
records: await this.searchService.buildSearchQueryAndGetRecords({
entityManager: repository,
objectMetadataItem,
featureFlagMap,
searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit,
filter: filter ?? ({} as ObjectRecordFilter),
}),
};
}),
);
allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems);
}
return this.searchService.computeSearchObjectResults(
allRecordsWithObjectMetadataItems,
limit,
);
}
}

View File

@ -0,0 +1,249 @@
import { Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
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/search/constants/results-limit-by-object-without-search-terms';
import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/search/constants/standard-objects-by-priority-rank';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
import {
SearchException,
SearchExceptionCode,
} from 'src/engine/core-modules/search/exceptions/search.exception';
import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/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 SearchService {
filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
includedObjectNameSingulars,
excludedObjectNameSingulars,
}: {
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
includedObjectNameSingulars: string[];
excludedObjectNameSingulars: string[];
}) {
return objectMetadataItemWithFieldMaps.filter(
({ nameSingular, isSearchable }) => {
if (!isSearchable) {
return false;
}
if (excludedObjectNameSingulars.includes(nameSingular)) {
return false;
}
if (includedObjectNameSingulars.length > 0) {
return includedObjectNameSingulars.includes(nameSingular);
}
return true;
},
);
}
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);
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 SearchException(
'Label identifier field not found',
SearchExceptionCode.LABEL_IDENTIFIER_FIELD_NOT_FOUND,
);
}
const labelIdentifierField =
objectMetadataItem.fieldsById[
objectMetadataItem.labelIdentifierFieldMetadataId
];
if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) {
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,
objectNameSingular: 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: SearchRecordDTO[]) {
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.objectNameSingular] || 0) -
(STANDARD_OBJECTS_BY_PRIORITY_RANK[a.objectNameSingular] || 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/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' ? '&' : '|'} `);
};