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:
@ -141,26 +141,59 @@ export class DataSeedWorkspaceCommand extends CommandRunner {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata);
|
||||
await this.seedCustomObjects({
|
||||
dataSourceMetadata,
|
||||
});
|
||||
|
||||
await this.seederService.seedCustomObjects(
|
||||
dataSourceMetadata.id,
|
||||
workspaceId,
|
||||
PETS_METADATA_SEEDS,
|
||||
PETS_DATA_SEEDS,
|
||||
);
|
||||
|
||||
await this.seederService.seedCustomObjects(
|
||||
dataSourceMetadata.id,
|
||||
workspaceId,
|
||||
SURVEY_RESULTS_METADATA_SEEDS,
|
||||
SURVEY_RESULTS_DATA_SEEDS,
|
||||
);
|
||||
await this.seedRecords({
|
||||
mainDataSource,
|
||||
dataSourceMetadata,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async seedCustomObjects({
|
||||
dataSourceMetadata,
|
||||
}: {
|
||||
dataSourceMetadata: DataSourceEntity;
|
||||
}) {
|
||||
await this.seederService.seedCustomObjects(
|
||||
dataSourceMetadata.id,
|
||||
dataSourceMetadata.workspaceId,
|
||||
PETS_METADATA_SEEDS,
|
||||
);
|
||||
|
||||
await this.seederService.seedCustomObjects(
|
||||
dataSourceMetadata.id,
|
||||
dataSourceMetadata.workspaceId,
|
||||
SURVEY_RESULTS_METADATA_SEEDS,
|
||||
);
|
||||
}
|
||||
|
||||
async seedRecords({
|
||||
mainDataSource,
|
||||
dataSourceMetadata,
|
||||
}: {
|
||||
mainDataSource: DataSource;
|
||||
dataSourceMetadata: DataSourceEntity;
|
||||
}) {
|
||||
await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata);
|
||||
|
||||
await this.seederService.seedCustomObjectRecords(
|
||||
dataSourceMetadata.workspaceId,
|
||||
PETS_METADATA_SEEDS,
|
||||
PETS_DATA_SEEDS,
|
||||
);
|
||||
|
||||
await this.seederService.seedCustomObjectRecords(
|
||||
dataSourceMetadata.workspaceId,
|
||||
SURVEY_RESULTS_METADATA_SEEDS,
|
||||
SURVEY_RESULTS_DATA_SEEDS,
|
||||
);
|
||||
}
|
||||
|
||||
async seedStandardObjectRecords(
|
||||
mainDataSource: DataSource,
|
||||
dataSourceMetadata: DataSourceEntity,
|
||||
|
||||
@ -14,7 +14,7 @@ export interface CursorData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const decodeCursor = (cursor: string): CursorData => {
|
||||
export const decodeCursor = <T = CursorData>(cursor: string): T => {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
} catch (err) {
|
||||
@ -45,6 +45,10 @@ export const encodeCursor = <T extends ObjectRecord = ObjectRecord>(
|
||||
id: objectRecord.id,
|
||||
};
|
||||
|
||||
return encodeCursorData(cursorData);
|
||||
};
|
||||
|
||||
export const encodeCursorData = (cursorData: CursorData) => {
|
||||
return Buffer.from(JSON.stringify(cursorData)).toString('base64');
|
||||
};
|
||||
|
||||
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8;
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -23,42 +23,17 @@ export class SeederService {
|
||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||
) {}
|
||||
|
||||
public async seedCustomObjects(
|
||||
dataSourceId: string,
|
||||
public async seedCustomObjectRecords(
|
||||
workspaceId: string,
|
||||
objectMetadataSeed: ObjectMetadataSeed,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
objectRecordSeeds: Record<string, any>[],
|
||||
): Promise<void> {
|
||||
const createdObjectMetadata = await this.objectMetadataService.createOne({
|
||||
...objectMetadataSeed,
|
||||
dataSourceId,
|
||||
) {
|
||||
const { fieldMetadataSeeds, objectMetadata } = await this.getSeedMetadata(
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!createdObjectMetadata) {
|
||||
throw new Error("Object metadata couldn't be created");
|
||||
}
|
||||
|
||||
await this.fieldMetadataService.createMany(
|
||||
objectMetadataSeed.fields.map((fieldMetadataSeed) => ({
|
||||
...fieldMetadataSeed,
|
||||
objectMetadataId: createdObjectMetadata.id,
|
||||
workspaceId,
|
||||
})),
|
||||
objectMetadataSeed,
|
||||
);
|
||||
|
||||
const objectMetadataAfterFieldCreation =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: { nameSingular: objectMetadataSeed.nameSingular },
|
||||
});
|
||||
|
||||
if (!objectMetadataAfterFieldCreation) {
|
||||
throw new Error(
|
||||
"Object metadata couldn't be found after field creation.",
|
||||
);
|
||||
}
|
||||
|
||||
const schemaName =
|
||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||
|
||||
@ -67,24 +42,11 @@ export class SeederService {
|
||||
|
||||
const entityManager: EntityManager = mainDataSource.createEntityManager();
|
||||
|
||||
const filteredFieldMetadataSeeds = objectMetadataSeed.fields.filter(
|
||||
(field) =>
|
||||
objectMetadataAfterFieldCreation.fields.some(
|
||||
(f) => f.name === field.name || f.name === `name`,
|
||||
),
|
||||
);
|
||||
|
||||
if (filteredFieldMetadataSeeds.length === 0) {
|
||||
throw new Error('No fields found for seeding, check metadata file');
|
||||
}
|
||||
|
||||
this.addNameFieldToFieldMetadataSeeds(filteredFieldMetadataSeeds);
|
||||
|
||||
const objectRecordSeedsAsSQLFlattenedSeeds = objectRecordSeeds.map(
|
||||
(recordSeed) => {
|
||||
const objectRecordSeedsAsSQLFlattenedSeeds = {};
|
||||
|
||||
for (const field of filteredFieldMetadataSeeds) {
|
||||
for (const field of fieldMetadataSeeds) {
|
||||
if (isCompositeFieldMetadataType(field.type)) {
|
||||
const compositeFieldTypeDefinition = compositeTypeDefinitions.get(
|
||||
field.type,
|
||||
@ -165,7 +127,7 @@ export class SeederService {
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(
|
||||
`${schemaName}.${computeTableName(objectMetadataAfterFieldCreation.nameSingular, true)}`,
|
||||
`${schemaName}.${computeTableName(objectMetadata.nameSingular, true)}`,
|
||||
sqlColumnNames,
|
||||
)
|
||||
.orIgnore()
|
||||
@ -174,6 +136,37 @@ export class SeederService {
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async seedCustomObjects(
|
||||
dataSourceId: string,
|
||||
workspaceId: string,
|
||||
objectMetadataSeed: ObjectMetadataSeed,
|
||||
): Promise<void> {
|
||||
const createdObjectMetadata = await this.objectMetadataService.createOne({
|
||||
...objectMetadataSeed,
|
||||
dataSourceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!createdObjectMetadata) {
|
||||
throw new Error("Object metadata couldn't be created");
|
||||
}
|
||||
|
||||
await this.fieldMetadataService.createMany(
|
||||
objectMetadataSeed.fields.map((fieldMetadataSeed) => ({
|
||||
...fieldMetadataSeed,
|
||||
objectMetadataId: createdObjectMetadata.id,
|
||||
workspaceId,
|
||||
})),
|
||||
);
|
||||
|
||||
const { fieldMetadataSeeds } = await this.getSeedMetadata(
|
||||
workspaceId,
|
||||
objectMetadataSeed,
|
||||
);
|
||||
|
||||
this.addNameFieldToFieldMetadataSeeds(fieldMetadataSeeds);
|
||||
}
|
||||
|
||||
private addNameFieldToFieldMetadataSeeds(
|
||||
arrayOfMetadataFields: Pick<CreateFieldInput, 'name' | 'type' | 'label'>[],
|
||||
) {
|
||||
@ -184,6 +177,34 @@ export class SeederService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getSeedMetadata(
|
||||
workspaceId: string,
|
||||
objectMetadataSeed: ObjectMetadataSeed,
|
||||
) {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: { nameSingular: objectMetadataSeed.nameSingular },
|
||||
});
|
||||
|
||||
if (!objectMetadata) {
|
||||
throw new Error(
|
||||
"Object metadata couldn't be found after field creation.",
|
||||
);
|
||||
}
|
||||
|
||||
const fieldMetadataSeeds = objectMetadataSeed.fields.filter((field) =>
|
||||
objectMetadata.fields.some(
|
||||
(f) => f.name === field.name || f.name === `name`,
|
||||
),
|
||||
);
|
||||
|
||||
if (fieldMetadataSeeds.length === 0) {
|
||||
throw new Error('No fields found for seeding, check metadata file');
|
||||
}
|
||||
|
||||
return { fieldMetadataSeeds, objectMetadata };
|
||||
}
|
||||
|
||||
private turnCompositeSubFieldValueAsSQLValue(
|
||||
fieldType: FieldMetadataType,
|
||||
subFieldName: string,
|
||||
|
||||
@ -244,6 +244,11 @@ export class WorkspaceManagerService {
|
||||
dataSourceMetadata.id,
|
||||
workspaceId,
|
||||
PETS_METADATA_SEEDS,
|
||||
);
|
||||
|
||||
await this.seederService.seedCustomObjectRecords(
|
||||
workspaceId,
|
||||
PETS_METADATA_SEEDS,
|
||||
PETS_DATA_SEEDS,
|
||||
);
|
||||
|
||||
@ -251,6 +256,11 @@ export class WorkspaceManagerService {
|
||||
dataSourceMetadata.id,
|
||||
workspaceId,
|
||||
SURVEY_RESULTS_METADATA_SEEDS,
|
||||
);
|
||||
|
||||
await this.seederService.seedCustomObjectRecords(
|
||||
workspaceId,
|
||||
SURVEY_RESULTS_METADATA_SEEDS,
|
||||
SURVEY_RESULTS_DATA_SEEDS,
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const TEST_API_KEY_1_ID = '982fb60e-67d9-44a3-b35c-0e508f41d3d6';
|
||||
@ -0,0 +1,2 @@
|
||||
export const TEST_PET_ID_1 = 'a4907cff-a582-4daf-8635-ad6c782c7c25';
|
||||
export const TEST_PET_ID_2 = 'c4e97187-9b9b-4e1f-a3c5-b7883c590332';
|
||||
@ -1,89 +1,58 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields';
|
||||
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
|
||||
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
|
||||
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils';
|
||||
import {
|
||||
SearchFactoryParams,
|
||||
searchFactory,
|
||||
} from 'test/integration/graphql/utils/search-factory.util';
|
||||
import {
|
||||
LISTING_NAME_PLURAL,
|
||||
LISTING_NAME_SINGULAR,
|
||||
} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant';
|
||||
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
||||
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
|
||||
import { searchFactory } from 'test/integration/graphql/utils/search-factory.util';
|
||||
import { EachTestingContext } from 'twenty-shared/testing';
|
||||
import {
|
||||
TEST_PERSON_1_ID,
|
||||
TEST_PERSON_2_ID,
|
||||
TEST_PERSON_3_ID,
|
||||
} from 'test/integration/constants/test-person-ids.constants';
|
||||
import { TEST_API_KEY_1_ID } from 'test/integration/constants/test-api-key-ids.constant';
|
||||
import { cleanTestDatabase } from 'test/integration/utils/clean-test-database';
|
||||
import {
|
||||
TEST_PET_ID_1,
|
||||
TEST_PET_ID_2,
|
||||
} from 'test/integration/constants/test-pet-ids.constants';
|
||||
|
||||
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
|
||||
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursorData,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
|
||||
import { SearchCursor } from 'src/engine/core-modules/search/services/search.service';
|
||||
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
|
||||
|
||||
describe('SearchResolver', () => {
|
||||
let listingObjectMetadataId: { objectMetadataId: string };
|
||||
const [firstPerson, secondPerson, thirdPerson] = [
|
||||
{ id: randomUUID(), name: { firstName: 'searchInput1' } },
|
||||
{ id: randomUUID(), name: { firstName: 'searchInput2' } },
|
||||
{ id: randomUUID(), name: { firstName: 'searchInput3' } },
|
||||
{ id: TEST_PERSON_1_ID, name: { firstName: 'searchInput1' } },
|
||||
{ id: TEST_PERSON_2_ID, name: { firstName: 'searchInput2' } },
|
||||
{ id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } },
|
||||
];
|
||||
|
||||
const [apiKey] = [
|
||||
{
|
||||
id: randomUUID(),
|
||||
id: TEST_API_KEY_1_ID,
|
||||
name: 'record not searchable',
|
||||
expiresAt: new Date(Date.now()),
|
||||
},
|
||||
];
|
||||
const [firstListing, secondListing] = [
|
||||
{ id: randomUUID(), name: 'searchInput1' },
|
||||
{ id: randomUUID(), name: 'searchInput2' },
|
||||
|
||||
const [firstPet, secondPet] = [
|
||||
{ id: TEST_PET_ID_1, name: 'searchInput1' },
|
||||
{ id: TEST_PET_ID_2, name: 'searchInput2' },
|
||||
];
|
||||
|
||||
const hasSearchRecord = (search: SearchRecordDTO[], recordId: string) => {
|
||||
return search.some((item: SearchRecordDTO) => item.recordId === recordId);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanTestDatabase({ seed: false });
|
||||
|
||||
try {
|
||||
const objectsMetadata = await findManyObjectMetadata({
|
||||
input: {
|
||||
filter: {},
|
||||
paging: {
|
||||
first: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listingObjectMetadata = objectsMetadata.objects.find(
|
||||
(object) => object.nameSingular === LISTING_NAME_SINGULAR,
|
||||
);
|
||||
|
||||
if (listingObjectMetadata) {
|
||||
listingObjectMetadataId = {
|
||||
objectMetadataId: listingObjectMetadata.id,
|
||||
};
|
||||
} else {
|
||||
const { data } = await createOneObjectMetadata({
|
||||
input: {
|
||||
labelSingular: LISTING_NAME_SINGULAR,
|
||||
labelPlural: LISTING_NAME_PLURAL,
|
||||
nameSingular: LISTING_NAME_SINGULAR,
|
||||
namePlural: LISTING_NAME_PLURAL,
|
||||
icon: 'IconBuildingSkyscraper',
|
||||
},
|
||||
});
|
||||
|
||||
listingObjectMetadataId = {
|
||||
objectMetadataId: data.createOneObject.id,
|
||||
};
|
||||
}
|
||||
|
||||
await performCreateManyOperation(
|
||||
LISTING_NAME_SINGULAR,
|
||||
LISTING_NAME_PLURAL,
|
||||
'pet',
|
||||
'pets',
|
||||
OBJECT_MODEL_COMMON_FIELDS,
|
||||
[firstListing, secondListing],
|
||||
[firstPet, secondPet],
|
||||
);
|
||||
|
||||
await performCreateManyOperation('person', 'people', PERSON_GQL_FIELDS, [
|
||||
@ -106,46 +75,17 @@ describe('SearchResolver', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await makeGraphqlAPIRequest(
|
||||
destroyManyOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
gqlFields: PERSON_GQL_FIELDS,
|
||||
filter: {
|
||||
id: {
|
||||
in: [firstPerson.id, secondPerson.id, thirdPerson.id],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
});
|
||||
|
||||
await deleteOneObjectMetadata({
|
||||
input: { idToDelete: listingObjectMetadataId.objectMetadataId },
|
||||
}).catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(
|
||||
destroyOneOperationFactory({
|
||||
objectMetadataSingularName: 'apiKey',
|
||||
gqlFields: OBJECT_MODEL_COMMON_FIELDS,
|
||||
recordId: apiKey.id,
|
||||
}),
|
||||
).catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
});
|
||||
await cleanTestDatabase({ seed: true });
|
||||
});
|
||||
|
||||
const testsUseCases: EachTestingContext<{
|
||||
input: SearchFactoryParams;
|
||||
input: SearchArgs;
|
||||
eval: {
|
||||
definedRecordIds: string[];
|
||||
undefinedRecordIds: string[];
|
||||
orderedRecordIds: string[];
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
decodedEndCursor: SearchCursor | null;
|
||||
};
|
||||
};
|
||||
}>[] = [
|
||||
{
|
||||
@ -154,10 +94,26 @@ describe('SearchResolver', () => {
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
limit: 50,
|
||||
},
|
||||
eval: {
|
||||
definedRecordIds: [firstListing.id, secondListing.id],
|
||||
undefinedRecordIds: [apiKey.id],
|
||||
orderedRecordIds: [
|
||||
firstPerson.id,
|
||||
secondPerson.id,
|
||||
thirdPerson.id,
|
||||
firstPet.id,
|
||||
secondPet.id,
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: thirdPerson.id,
|
||||
pet: secondPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -166,10 +122,20 @@ describe('SearchResolver', () => {
|
||||
context: {
|
||||
input: {
|
||||
searchInput: 'searchInput1',
|
||||
limit: 50,
|
||||
},
|
||||
eval: {
|
||||
definedRecordIds: [firstPerson.id, firstListing.id],
|
||||
undefinedRecordIds: [secondPerson.id, secondListing.id],
|
||||
orderedRecordIds: [firstPerson.id, firstPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: firstPerson.id,
|
||||
pet: firstPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -178,11 +144,20 @@ describe('SearchResolver', () => {
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
includedObjectNameSingulars: [LISTING_NAME_SINGULAR],
|
||||
includedObjectNameSingulars: ['pet'],
|
||||
limit: 50,
|
||||
},
|
||||
eval: {
|
||||
definedRecordIds: [firstListing.id, secondListing.id],
|
||||
undefinedRecordIds: [firstPerson.id, secondPerson.id],
|
||||
orderedRecordIds: [firstPet.id, secondPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: secondPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -192,10 +167,19 @@ describe('SearchResolver', () => {
|
||||
input: {
|
||||
searchInput: '',
|
||||
excludedObjectNameSingulars: ['person'],
|
||||
limit: 50,
|
||||
},
|
||||
eval: {
|
||||
definedRecordIds: [firstListing.id, secondListing.id],
|
||||
undefinedRecordIds: [firstPerson.id, secondPerson.id],
|
||||
orderedRecordIds: [firstPet.id, secondPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: secondPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -204,13 +188,263 @@ describe('SearchResolver', () => {
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
filter: {
|
||||
id: { eq: firstListing.id },
|
||||
},
|
||||
filter: { id: { eq: firstPet.id } },
|
||||
limit: 50,
|
||||
},
|
||||
eval: {
|
||||
definedRecordIds: [firstListing.id],
|
||||
undefinedRecordIds: [secondListing.id],
|
||||
orderedRecordIds: [firstPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should limit records number with limit',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
limit: 4,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [
|
||||
firstPerson.id,
|
||||
secondPerson.id,
|
||||
thirdPerson.id,
|
||||
firstPet.id,
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
person: thirdPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should return endCursor when paginating',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
limit: 2,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [firstPerson.id, secondPerson.id],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should return endCursor when paginating with Cursor',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
after: encodeCursorData({
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
}),
|
||||
limit: 2,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [thirdPerson.id, firstPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
person: thirdPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should limit records number with limit and searchInput',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: 'searchInput',
|
||||
limit: 4,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [
|
||||
firstPerson.id,
|
||||
secondPerson.id,
|
||||
thirdPerson.id,
|
||||
firstPet.id,
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
person: thirdPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should return endCursor when paginating with searchInput',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: 'searchInput',
|
||||
limit: 2,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [firstPerson.id, secondPerson.id],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should return endCursor when paginating with searchInput with Cursor',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: 'searchInput',
|
||||
after: encodeCursorData({
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
}),
|
||||
limit: 2,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [thirdPerson.id, firstPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
person: thirdPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should return endCursor when paginating with searchInput with Cursor and filter',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: 'searchInput',
|
||||
after: encodeCursorData({
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
}),
|
||||
limit: 2,
|
||||
filter: { id: { neq: firstPet.id } },
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [thirdPerson.id, secondPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: thirdPerson.id,
|
||||
pet: secondPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should paginate properly with excludedObject',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
excludedObjectNameSingulars: ['person'],
|
||||
limit: 1,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [firstPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should paginate properly with included Objects only',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
includedObjectNameSingulars: ['pet'],
|
||||
limit: 1,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [firstPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: firstPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should paginate properly when no records are returned',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '',
|
||||
limit: 0,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
decodedEndCursor: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -224,17 +458,127 @@ describe('SearchResolver', () => {
|
||||
expect(response.body.data.search).toBeDefined();
|
||||
|
||||
const search = response.body.data.search;
|
||||
const edges = search.edges;
|
||||
const pageInfo = search.pageInfo;
|
||||
|
||||
context.eval.definedRecordIds.length > 0
|
||||
? expect(search).not.toHaveLength(0)
|
||||
: expect(search).toHaveLength(0);
|
||||
context.eval.orderedRecordIds.length > 0
|
||||
? expect(edges).not.toHaveLength(0)
|
||||
: expect(edges).toHaveLength(0);
|
||||
|
||||
context.eval.definedRecordIds.forEach((recordId) => {
|
||||
expect(hasSearchRecord(search, recordId)).toBeTruthy();
|
||||
expect(
|
||||
edges.map((edge: SearchResultEdgeDTO) => edge.node.recordId),
|
||||
).toEqual(context.eval.orderedRecordIds);
|
||||
|
||||
expect(pageInfo).toBeDefined();
|
||||
expect(context.eval.pageInfo.hasNextPage).toEqual(pageInfo.hasNextPage);
|
||||
expect(context.eval.pageInfo.decodedEndCursor).toEqual(
|
||||
pageInfo.endCursor
|
||||
? decodeCursor(pageInfo.endCursor)
|
||||
: pageInfo.endCursor,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return cursor for each search edge', async () => {
|
||||
const graphqlOperation = searchFactory({
|
||||
searchInput: 'searchInput',
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
context.eval.undefinedRecordIds.forEach((recordId) => {
|
||||
expect(hasSearchRecord(search, recordId)).toBeFalsy();
|
||||
const response = await makeGraphqlAPIRequest(graphqlOperation);
|
||||
|
||||
const expectedResult = {
|
||||
edges: [
|
||||
{
|
||||
cursor: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: firstPerson.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
cursor: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect({
|
||||
...response.body.data.search,
|
||||
edges: response.body.data.search.edges.map(
|
||||
(edge: SearchResultEdgeDTO) => ({
|
||||
cursor: edge.cursor,
|
||||
}),
|
||||
),
|
||||
}).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should return cursor for each search edge with after cursor input', async () => {
|
||||
const graphqlOperation = searchFactory({
|
||||
searchInput: 'searchInput',
|
||||
limit: 2,
|
||||
after: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: secondPerson.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await makeGraphqlAPIRequest(graphqlOperation);
|
||||
|
||||
const expectedResult = {
|
||||
edges: [
|
||||
{
|
||||
cursor: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: thirdPerson.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
cursor: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: thirdPerson.id,
|
||||
pet: firstPet.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: encodeCursorData({
|
||||
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
||||
lastRecordIdsPerObject: {
|
||||
person: thirdPerson.id,
|
||||
pet: firstPet.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect({
|
||||
...response.body.data.search,
|
||||
edges: response.body.data.search.edges.map(
|
||||
(edge: SearchResultEdgeDTO) => ({
|
||||
cursor: edge.cursor,
|
||||
}),
|
||||
),
|
||||
}).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,20 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
|
||||
|
||||
export type SearchFactoryParams = {
|
||||
searchInput: string;
|
||||
excludedObjectNameSingulars?: string[];
|
||||
includedObjectNameSingulars?: string[];
|
||||
filter?: ObjectRecordFilterInput;
|
||||
};
|
||||
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
|
||||
|
||||
export const searchFactory = ({
|
||||
searchInput,
|
||||
excludedObjectNameSingulars,
|
||||
includedObjectNameSingulars,
|
||||
filter,
|
||||
}: SearchFactoryParams) => ({
|
||||
after,
|
||||
limit = 50,
|
||||
}: SearchArgs) => ({
|
||||
query: gql`
|
||||
query Search(
|
||||
$searchInput: String!
|
||||
$limit: Int!
|
||||
$after: String
|
||||
$excludedObjectNameSingulars: [String!]
|
||||
$includedObjectNameSingulars: [String!]
|
||||
$filter: ObjectRecordFilterInput
|
||||
@ -26,22 +22,33 @@ export const searchFactory = ({
|
||||
search(
|
||||
searchInput: $searchInput
|
||||
limit: $limit
|
||||
after: $after
|
||||
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
||||
includedObjectNameSingulars: $includedObjectNameSingulars
|
||||
filter: $filter
|
||||
) {
|
||||
recordId
|
||||
objectNameSingular
|
||||
label
|
||||
imageUrl
|
||||
tsRankCD
|
||||
tsRank
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
recordId
|
||||
objectNameSingular
|
||||
label
|
||||
imageUrl
|
||||
tsRankCD
|
||||
tsRank
|
||||
}
|
||||
cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
searchInput,
|
||||
limit: 50,
|
||||
limit,
|
||||
after,
|
||||
excludedObjectNameSingulars,
|
||||
includedObjectNameSingulars,
|
||||
filter,
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export const LISTING_NAME_SINGULAR = 'listinga';
|
||||
export const LISTING_NAME_PLURAL = 'listingas';
|
||||
export const LISTING_NAME_SINGULAR = 'listing';
|
||||
export const LISTING_NAME_PLURAL = 'listings';
|
||||
|
||||
@ -4,9 +4,9 @@ import {
|
||||
} from 'test/integration/constants/test-person-ids.constants';
|
||||
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||
import { generateRecordName } from 'test/integration/utils/generate-record-name';
|
||||
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
|
||||
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
|
||||
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
|
||||
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
|
||||
|
||||
describe('Core REST API Update One endpoint', () => {
|
||||
const updatedData = {
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
|
||||
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
|
||||
|
||||
export const cleanTestDatabase = async ({ seed }: { seed: boolean }) => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
"Don't run this 'setupTest' function in a non test environment",
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...[
|
||||
'person',
|
||||
'company',
|
||||
'opportunity',
|
||||
'workspaceMember',
|
||||
'_pet',
|
||||
'_surveyResult',
|
||||
].map(
|
||||
async (objectMetadataNameSingular) =>
|
||||
await deleteAllRecords(objectMetadataNameSingular),
|
||||
),
|
||||
]);
|
||||
|
||||
if (!seed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
const mainDataSource = global.typeOrmService.getMainDataSource();
|
||||
|
||||
const dataSourceMetadata =
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
await global.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
);
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
await global.dataSeedWorkspaceCommand.seedRecords({
|
||||
mainDataSource,
|
||||
dataSourceMetadata,
|
||||
});
|
||||
};
|
||||
@ -8,6 +8,7 @@ import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service';
|
||||
import { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter';
|
||||
import { CommandModule } from 'src/command/command.module';
|
||||
|
||||
interface TestingModuleCreatePreHook {
|
||||
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
|
||||
@ -32,7 +33,7 @@ export const createApp = async (
|
||||
const stripeSDKMockService = new StripeSDKMockService();
|
||||
const mockExceptionHandlerService = new ExceptionHandlerMockService();
|
||||
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
imports: [AppModule, CommandModule],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
|
||||
@ -2,6 +2,9 @@ import { JestConfigWithTsJest } from 'ts-jest';
|
||||
import 'tsconfig-paths/register';
|
||||
|
||||
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
|
||||
|
||||
import { createApp } from './create-app';
|
||||
|
||||
@ -21,4 +24,10 @@ export default async (_, projectConfig: JestConfigWithTsJest) => {
|
||||
global.app = app;
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
global.testDataSource = rawDataSource;
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
global.typeOrmService = app.get(TypeORMService);
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
global.dataSourceService = app.get(DataSourceService);
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
global.dataSeedWorkspaceCommand = app.get(DataSeedWorkspaceCommand);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user