Infinite scrolling in relation picker menu (#12051)

https://github.com/user-attachments/assets/4be785e0-ea8a-4c8e-840e-6fa0a663d7ba

Closes #11938

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Abdul Rahman
2025-05-23 20:53:09 +05:30
committed by GitHub
parent 6ef9a3b4c9
commit af5762c8ba
37 changed files with 1867 additions and 562 deletions

View File

@ -3,13 +3,21 @@ import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { encodeCursorData } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
describe('SearchService', () => {
let service: SearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SearchService, { provide: FileService, useValue: {} }],
providers: [
SearchService,
{ provide: TwentyORMManager, useValue: {} },
{ provide: WorkspaceCacheStorageService, useValue: {} },
{ provide: FileService, useValue: {} },
],
}).compile();
service = module.get<SearchService>(SearchService);
@ -206,4 +214,236 @@ describe('SearchService', () => {
]);
});
});
describe('computeEdges', () => {
it('should compute edges properly', () => {
const sortedSlicedRecords = [
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.9,
tsRank: 0.9,
recordId: 'companyId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.9, tsRank: 0.9 },
lastRecordIdsPerObject: {
company: 'companyId1',
},
}),
},
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.89,
tsRank: 0.89,
recordId: 'companyId2',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.89, tsRank: 0.89 },
lastRecordIdsPerObject: {
company: 'companyId2',
},
}),
},
{
record: {
objectNameSingular: 'person',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'personId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId1',
},
}),
},
{
record: {
objectNameSingular: 'person',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'personId2',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
},
}),
},
{
record: {
objectNameSingular: 'opportunity',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'opportunityId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
},
}),
},
{
record: {
objectNameSingular: 'note',
tsRankCD: 0.2,
tsRank: 0.2,
recordId: 'noteId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.2, tsRank: 0.2 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.1,
tsRank: 0.1,
recordId: 'companyId3',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.1 },
lastRecordIdsPerObject: {
company: 'companyId3',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
];
const edges = service.computeEdges({
sortedRecords: sortedSlicedRecords.map((r) => r.record),
});
expect(edges.map((e) => e.cursor)).toEqual(
sortedSlicedRecords.map((r) => r.expectedCursor),
);
});
it('should compute pageInfo properly with an input after cursor', () => {
const sortedSlicedRecords = [
{
record: {
objectNameSingular: 'person',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'personId2',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
},
}),
},
{
record: {
objectNameSingular: 'opportunity',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'opportunityId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
},
}),
},
{
record: {
objectNameSingular: 'note',
tsRankCD: 0.2,
tsRank: 0.2,
recordId: 'noteId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.2, tsRank: 0.2 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.1,
tsRank: 0.1,
recordId: 'companyId3',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.1 },
lastRecordIdsPerObject: {
company: 'companyId3',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
];
const afterCursor = encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId1',
},
});
const edges = service.computeEdges({
sortedRecords: sortedSlicedRecords.map((r) => r.record),
after: afterCursor,
});
expect(edges.map((e) => e.cursor)).toEqual(
sortedSlicedRecords.map((r) => r.expectedCursor),
);
});
});
});

View File

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

View File

@ -1,6 +1,6 @@
import { ArgsType, Field, Int } from '@nestjs/graphql';
import { IsArray, IsInt, IsOptional, IsString } from 'class-validator';
import { IsArray, IsInt, IsOptional, IsString, Max } from 'class-validator';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
@ -12,8 +12,13 @@ export class SearchArgs {
@Field(() => Int)
@IsInt()
@Max(100, { message: 'Limit cannot exceed 100 items' })
limit: number;
@Field(() => String, { nullable: true })
@IsOptional()
after?: string;
@IsArray()
@Field(() => [String], { nullable: true })
@IsOptional()

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
import { SearchResultPageInfoDTO } from 'src/engine/core-modules/search/dtos/search-result-page-info.dto';
@ObjectType('SearchResultConnection')
export class SearchResultConnectionDTO {
@Field(() => [SearchResultEdgeDTO])
edges: SearchResultEdgeDTO[];
@Field(() => SearchResultPageInfoDTO)
pageInfo: SearchResultPageInfoDTO;
}

View File

@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record.dto';
@ObjectType('SearchResultEdge')
export class SearchResultEdgeDTO {
@Field(() => SearchRecordDTO)
node: SearchRecordDTO;
@Field(() => String)
cursor: string;
}

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SearchResultPageInfo')
export class SearchResultPageInfoDTO {
@Field(() => String, { nullable: true })
endCursor: string | null;
@Field(() => Boolean)
hasNextPage: boolean;
}

View File

@ -1,41 +1,19 @@
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 { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
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 {
WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import {
WorkspaceMetadataVersionException,
WorkspaceMetadataVersionExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-version/exceptions/workspace-metadata-version.exception';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5;
@Resolver(() => [SearchRecordDTO])
@Resolver()
@UseFilters(SearchApiExceptionFilter)
export class SearchResolver {
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly twentyORMManager: TwentyORMManager,
private readonly searchService: SearchService,
) {}
constructor(private readonly searchService: SearchService) {}
@Query(() => [SearchRecordDTO])
@Query(() => SearchResultConnectionDTO)
async search(
@AuthWorkspace() workspace: Workspace,
@Args()
@ -45,34 +23,11 @@ export class SearchResolver {
filter,
includedObjectNameSingulars,
excludedObjectNameSingulars,
after,
}: SearchArgs,
) {
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
throw new WorkspaceMetadataVersionException(
`Metadata version not found for workspace ${workspace.id}`,
WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new WorkspaceMetadataCacheException(
`Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`,
WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND,
);
}
const objectMetadataItemWithFieldMaps = Object.values(
objectMetadataMaps.byId,
);
const objectMetadataItemWithFieldMaps =
await this.searchService.getObjectMetadataItemWithFieldMaps(workspace);
const filteredObjectMetadataItems =
this.searchService.filterObjectMetadataItems({
@ -81,42 +36,22 @@ export class SearchResolver {
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
[];
const allRecordsWithObjectMetadataItems =
await this.searchService.getAllRecordsWithObjectMetadataItems({
objectMetadataItemWithFieldMaps: filteredObjectMetadataItems,
searchInput,
limit,
filter,
includedObjectNameSingulars,
excludedObjectNameSingulars,
after,
});
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,
searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit,
filter: filter ?? ({} as ObjectRecordFilter),
}),
};
}),
);
allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems);
}
return this.searchService.computeSearchObjectResults(
allRecordsWithObjectMetadataItems,
return this.searchService.computeSearchObjectResults({
recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems,
workspaceId: workspace.id,
limit,
workspace.id,
);
after,
});
}
}

View File

@ -4,15 +4,17 @@ import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
import { Brackets, ObjectLiteral } from 'typeorm';
import chunk from 'lodash.chunk';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
ObjectRecord,
ObjectRecordFilter,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
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,
@ -22,10 +24,124 @@ import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/searc
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 {
decodeCursor,
encodeCursorData,
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import {
WorkspaceMetadataVersionException,
WorkspaceMetadataVersionExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-version/exceptions/workspace-metadata-version.exception';
import {
WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record.dto';
type LastRanks = { tsRankCD: number; tsRank: number };
export type SearchCursor = {
lastRanks: LastRanks;
lastRecordIdsPerObject: Record<string, string | undefined>;
};
const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5;
@Injectable()
export class SearchService {
constructor(private readonly fileService: FileService) {}
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly fileService: FileService,
) {}
async getObjectMetadataItemWithFieldMaps(workspace: Workspace) {
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
throw new WorkspaceMetadataVersionException(
`Metadata version not found for workspace ${workspace.id}`,
WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new WorkspaceMetadataCacheException(
`Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`,
WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND,
);
}
return Object.values(objectMetadataMaps.byId);
}
async getAllRecordsWithObjectMetadataItems({
objectMetadataItemWithFieldMaps,
includedObjectNameSingulars,
excludedObjectNameSingulars,
searchInput,
limit,
filter,
after,
}: {
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
} & SearchArgs) {
const filteredObjectMetadataItems = this.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<ObjectRecord>(
objectMetadataItem.nameSingular,
);
return {
objectMetadataItem,
records: await this.buildSearchQueryAndGetRecords({
entityManager: repository,
objectMetadataItem,
searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit: limit as number,
filter: filter ?? ({} as ObjectRecordFilter),
after,
}),
};
}),
);
allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems);
}
return allRecordsWithObjectMetadataItems;
}
filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
@ -60,6 +176,7 @@ export class SearchService {
searchTermsOr,
limit,
filter,
after,
}: {
entityManager: WorkspaceRepository<Entity>;
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
@ -67,6 +184,7 @@ export class SearchService {
searchTermsOr: string;
limit: number;
filter: ObjectRecordFilterInput;
after?: string;
}) {
const queryBuilder = entityManager.createQueryBuilder();
@ -93,51 +211,102 @@ export class SearchService {
...(imageIdentifierField ? [imageIdentifierField] : []),
].map((field) => `"${field}"`);
const searchQuery = isNonEmptyString(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);
const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`;
return await searchQuery.getRawMany();
const tsRankExpr = `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`;
const cursorWhereCondition = this.computeCursorWhereCondition({
after,
objectMetadataNameSingular: objectMetadataItem.nameSingular,
tsRankExpr,
tsRankCDExpr,
});
queryBuilder
.select(fieldsToSelect)
.addSelect(tsRankCDExpr, 'tsRankCD')
.addSelect(tsRankExpr, 'tsRank');
if (isNonEmptyString(searchTerms)) {
queryBuilder.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 },
);
}),
);
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`);
}),
);
}
if (cursorWhereCondition) {
queryBuilder.andWhere(cursorWhereCondition);
}
return await queryBuilder
.orderBy(tsRankCDExpr, 'DESC')
.addOrderBy(tsRankExpr, 'DESC')
.addOrderBy('id', 'ASC', 'NULLS FIRST')
.setParameter('searchTerms', searchTerms)
.setParameter('searchTermsOr', searchTermsOr)
.take(limit + 1) // We take one more to check if hasNextPage is true
.getRawMany();
}
computeCursorWhereCondition({
after,
objectMetadataNameSingular,
tsRankExpr,
tsRankCDExpr,
}: {
after?: string;
objectMetadataNameSingular: string;
tsRankExpr: string;
tsRankCDExpr: string;
}) {
if (after) {
const { lastRanks, lastRecordIdsPerObject } =
decodeCursor<SearchCursor>(after);
const lastRecordId = lastRecordIdsPerObject[objectMetadataNameSingular];
return new Brackets((qb) => {
qb.where(`${tsRankCDExpr} < :tsRankCDLt`, {
tsRankCDLt: lastRanks.tsRankCD,
})
.orWhere(
new Brackets((inner) => {
inner.andWhere(`${tsRankCDExpr} = :tsRankCDEq`, {
tsRankCDEq: lastRanks.tsRankCD,
});
inner.andWhere(`${tsRankExpr} < :tsRankLt`, {
tsRankLt: lastRanks.tsRank,
});
}),
)
.orWhere(
new Brackets((inner) => {
inner.andWhere(`${tsRankCDExpr} = :tsRankCDEq`, {
tsRankCDEq: lastRanks.tsRankCD,
});
inner.andWhere(`${tsRankExpr} = :tsRankEq`, {
tsRankEq: lastRanks.tsRank,
});
if (lastRecordId !== undefined) {
inner.andWhere('id > :lastRecordId', { lastRecordId });
}
}),
);
});
}
}
getLabelIdentifierColumns(
@ -220,11 +389,54 @@ export class SearchService {
: '';
}
computeSearchObjectResults(
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[],
limit: number,
workspaceId: string,
) {
computeEdges({
sortedRecords,
after,
}: {
sortedRecords: SearchRecordDTO[];
after?: string;
}): SearchResultEdgeDTO[] {
const recordEdges = [];
const lastRecordIdsPerObject = after
? {
...decodeCursor<SearchCursor>(after).lastRecordIdsPerObject,
}
: {};
for (const record of sortedRecords) {
const { objectNameSingular, tsRankCD, tsRank, recordId } = record;
lastRecordIdsPerObject[objectNameSingular] = recordId;
const lastRecordIdsPerObjectSnapshot = { ...lastRecordIdsPerObject };
recordEdges.push({
node: record,
cursor: encodeCursorData({
lastRanks: {
tsRankCD,
tsRank,
},
lastRecordIdsPerObject: lastRecordIdsPerObjectSnapshot,
}),
});
}
return recordEdges;
}
computeSearchObjectResults({
recordsWithObjectMetadataItems,
workspaceId,
limit,
after,
}: {
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[];
workspaceId: string;
limit: number;
after?: string;
}): SearchResultConnectionDTO {
const searchRecords = recordsWithObjectMetadataItems.flatMap(
({ objectMetadataItem, records }) => {
return records.map((record) => {
@ -244,7 +456,25 @@ export class SearchService {
},
);
return this.sortSearchObjectResults(searchRecords).slice(0, limit);
const sortedRecords = this.sortSearchObjectResults(searchRecords).slice(
0,
limit,
);
const hasNextPage = searchRecords.length > limit;
const recordEdges = this.computeEdges({ sortedRecords, after });
if (recordEdges.length === 0) {
return { edges: [], pageInfo: { endCursor: null, hasNextPage } };
}
const lastRecordEdge = recordEdges[recordEdges.length - 1];
return {
edges: recordEdges,
pageInfo: { endCursor: lastRecordEdge.cursor, hasNextPage },
};
}
sortSearchObjectResults(searchObjectResultsWithRank: SearchRecordDTO[]) {