Fix global search for CJK and non-tokenizable text (#18030)
## Summary Fixes #12962 - Adds a two-pass ILIKE fallback to the global search service (`search.service.ts`) - **Fast path**: runs the tsvector query first (uses GIN index, sub-millisecond) - **Fallback**: only if tsvector returns fewer results than the limit, runs an ILIKE query on `searchVector::text` to catch cases where PostgreSQL's `simple` text search config fails to tokenize (continuous CJK text, etc.) - Zero performance impact for the common case (Latin text where tsvector works) - Also adds `escapeForIlike` utility to properly escape `%`, `_`, `\` in user input ### Why tsvector fails for CJK PostgreSQL's `simple` config treats continuous CJK text as a single lexeme: - `to_tsvector('simple', '示例商业线索')` → `'示例商业线索':1` - Searching `商业:*` only prefix-matches from the start, so it misses `商业` in the middle The ILIKE fallback catches these substring matches when the tsvector path can't. ### What this fixes - Global search (command menu / sidebar) - Relation picker (single and multi-object) - Morph relation picker All three use `search.service.ts` under the hood. Co-authored-by: mykh-hailo (original direction in #18021) ## Test plan - [ ] Search `示例` with records `示例商业线索` and `示例-商业-线索` → both should appear - [ ] Search `商业` → both should appear (previously only the hyphenated one did) - [ ] Search for Latin text (e.g. `john`) → same performance, results unchanged - [ ] Relation picker search with CJK text → results appear - [ ] Search input with special chars like `%` or `_` → no SQL injection, results correct Made with [Cursor](https://cursor.com) --------- Co-authored-by: mykh-hailo <mykh-hailo@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
SearchExceptionCode,
|
||||
} from 'src/engine/core-modules/search/exceptions/search.exception';
|
||||
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
|
||||
import { escapeForIlike } from 'src/engine/core-modules/search/utils/escape-for-ilike';
|
||||
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
@@ -98,10 +99,11 @@ export class SearchService {
|
||||
|
||||
return {
|
||||
objectMetadataItem: flatObjectMetadata,
|
||||
records: await this.buildSearchQueryAndGetRecords({
|
||||
records: await this.buildSearchQueryAndGetRecordsWithFallback({
|
||||
entityManager: repository,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
searchInput,
|
||||
searchTerms: formatSearchTerms(searchInput, 'and'),
|
||||
searchTermsOr: formatSearchTerms(searchInput, 'or'),
|
||||
limit: limit as number,
|
||||
@@ -149,6 +151,70 @@ export class SearchService {
|
||||
);
|
||||
}
|
||||
|
||||
// Runs a fast tsvector query first (uses GIN index). On the first page only,
|
||||
// if not enough results, falls back to an ILIKE query on the searchVector
|
||||
// text representation to catch cases where tsvector tokenization fails
|
||||
// (e.g. continuous CJK text). Skipped on subsequent pages since any ILIKE-only
|
||||
// matches would already have appeared on page 1 with rank 0.
|
||||
async buildSearchQueryAndGetRecordsWithFallback<
|
||||
Entity extends ObjectLiteral,
|
||||
>({
|
||||
entityManager,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
searchInput,
|
||||
searchTerms,
|
||||
searchTermsOr,
|
||||
limit,
|
||||
filter,
|
||||
after,
|
||||
}: {
|
||||
entityManager: WorkspaceRepository<Entity>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
searchInput: string;
|
||||
searchTerms: string;
|
||||
searchTermsOr: string;
|
||||
limit: number;
|
||||
filter: ObjectRecordFilterInput;
|
||||
after?: string;
|
||||
}) {
|
||||
const tsvectorResults = await this.buildSearchQueryAndGetRecords({
|
||||
entityManager,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
searchTerms,
|
||||
searchTermsOr,
|
||||
limit,
|
||||
filter,
|
||||
after,
|
||||
});
|
||||
|
||||
if (
|
||||
tsvectorResults.length >= limit ||
|
||||
!isNonEmptyString(searchInput.trim()) ||
|
||||
isDefined(after)
|
||||
) {
|
||||
return tsvectorResults;
|
||||
}
|
||||
|
||||
const tsvectorRecordIds = new Set(
|
||||
tsvectorResults.map((record) => record.id as string),
|
||||
);
|
||||
|
||||
const fallbackResults = await this.buildIlikeFallbackQuery({
|
||||
entityManager,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
searchInput,
|
||||
excludeIds: [...tsvectorRecordIds],
|
||||
limit: limit + 1 - tsvectorResults.length,
|
||||
filter,
|
||||
});
|
||||
|
||||
return [...tsvectorResults, ...fallbackResults];
|
||||
}
|
||||
|
||||
async buildSearchQueryAndGetRecords<Entity extends ObjectLiteral>({
|
||||
entityManager,
|
||||
flatObjectMetadata,
|
||||
@@ -250,6 +316,74 @@ export class SearchService {
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
private async buildIlikeFallbackQuery<Entity extends ObjectLiteral>({
|
||||
entityManager,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
searchInput,
|
||||
excludeIds,
|
||||
limit,
|
||||
filter,
|
||||
}: {
|
||||
entityManager: WorkspaceRepository<Entity>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
searchInput: string;
|
||||
excludeIds: string[];
|
||||
limit: number;
|
||||
filter: ObjectRecordFilterInput;
|
||||
}) {
|
||||
const queryBuilder = entityManager.createQueryBuilder();
|
||||
|
||||
const { flatObjectMetadataMaps } = entityManager.internalContext;
|
||||
|
||||
const queryParser = new GraphqlQueryParser(
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
queryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
flatObjectMetadata.nameSingular,
|
||||
filter,
|
||||
);
|
||||
|
||||
queryParser.applyDeletedAtToBuilder(queryBuilder, filter);
|
||||
|
||||
const imageIdentifierField = this.getImageIdentifierColumn(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const fieldsToSelect = [
|
||||
'id',
|
||||
...this.getLabelIdentifierColumns(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
),
|
||||
...(imageIdentifierField ? [imageIdentifierField] : []),
|
||||
].map((field) => `"${field}"`);
|
||||
|
||||
queryBuilder
|
||||
.select(fieldsToSelect)
|
||||
.addSelect('0', 'tsRankCD')
|
||||
.addSelect('0', 'tsRank');
|
||||
|
||||
const escapedInput = escapeForIlike(searchInput.trim());
|
||||
|
||||
queryBuilder.andWhere(
|
||||
`public.unaccent_immutable("${SEARCH_VECTOR_FIELD.name}"::text) ILIKE public.unaccent_immutable(:ilikeFallbackPattern)`,
|
||||
{ ilikeFallbackPattern: `%${escapedInput}%` },
|
||||
);
|
||||
|
||||
if (excludeIds.length > 0) {
|
||||
queryBuilder.andWhere('id NOT IN (:...excludeIds)', { excludeIds });
|
||||
}
|
||||
|
||||
return await queryBuilder.orderBy('"id"', 'ASC').take(limit).getRawMany();
|
||||
}
|
||||
|
||||
computeCursorWhereCondition({
|
||||
after,
|
||||
objectMetadataNameSingular,
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { escapeForIlike } from 'src/engine/core-modules/search/utils/escape-for-ilike';
|
||||
|
||||
describe('escapeForIlike', () => {
|
||||
it('should escape percent signs', () => {
|
||||
expect(escapeForIlike('100%')).toBe('100\\%');
|
||||
});
|
||||
|
||||
it('should escape underscores', () => {
|
||||
expect(escapeForIlike('my_company')).toBe('my\\_company');
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(escapeForIlike('path\\to')).toBe('path\\\\to');
|
||||
});
|
||||
|
||||
it('should leave normal text unchanged', () => {
|
||||
expect(escapeForIlike('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should handle CJK text unchanged', () => {
|
||||
expect(escapeForIlike('商业线索')).toBe('商业线索');
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
expect(escapeForIlike('50%_off\\deal')).toBe('50\\%\\_off\\\\deal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const escapeForIlike = (value: string): string =>
|
||||
value.replace(/[\\%_]/g, '\\$&');
|
||||
@@ -2,3 +2,4 @@ export const TEST_PET_ID_1 = 'a4907cff-a582-4daf-8635-ad6c782c7c25';
|
||||
export const TEST_PET_ID_2 = 'c4e97187-9b9b-4e1f-a3c5-b7883c590332';
|
||||
export const TEST_PET_ID_3 = 'e4907cff-a582-4aaf-8635-ad6c782c7c26';
|
||||
export const TEST_PET_ID_4 = 'f4907cff-a582-4aaf-8635-ad6c782c7c27';
|
||||
export const TEST_PET_ID_5 = 'd4907cff-a582-4aaf-8635-ad6c782c7c28';
|
||||
|
||||
+34
-1
@@ -21,6 +21,7 @@ import {
|
||||
TEST_PET_ID_2,
|
||||
TEST_PET_ID_3,
|
||||
TEST_PET_ID_4,
|
||||
TEST_PET_ID_5,
|
||||
} from 'test/integration/constants/test-pet-ids.constants';
|
||||
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
|
||||
import { search } from 'test/integration/graphql/utils/search.util';
|
||||
@@ -145,6 +146,7 @@ describe('SearchResolver', () => {
|
||||
{ id: TEST_PET_ID_2, name: 'searchInput2' },
|
||||
{ id: TEST_PET_ID_3, name: 'Café' },
|
||||
{ id: TEST_PET_ID_4, name: 'Naïve' },
|
||||
{ id: TEST_PET_ID_5, name: '示例商业线索' },
|
||||
];
|
||||
|
||||
const [
|
||||
@@ -159,7 +161,7 @@ describe('SearchResolver', () => {
|
||||
multiPhonePerson,
|
||||
] = persons;
|
||||
const [cafeCorp, naiveCorp] = companies;
|
||||
const [searchInput1Pet, searchInput2Pet, cafePet, naivePet] = pets;
|
||||
const [searchInput1Pet, searchInput2Pet, cafePet, naivePet, cjkPet] = pets;
|
||||
|
||||
beforeAll(async () => {
|
||||
// TODO refactor not a good practice, or should at least restore afterwards
|
||||
@@ -236,6 +238,7 @@ describe('SearchResolver', () => {
|
||||
cafeCorp.id,
|
||||
searchInput1Pet.id,
|
||||
searchInput2Pet.id,
|
||||
cjkPet.id,
|
||||
cafePet.id,
|
||||
naivePet.id,
|
||||
],
|
||||
@@ -297,6 +300,7 @@ describe('SearchResolver', () => {
|
||||
orderedRecordIds: [
|
||||
searchInput1Pet.id,
|
||||
searchInput2Pet.id,
|
||||
cjkPet.id,
|
||||
cafePet.id,
|
||||
naivePet.id,
|
||||
],
|
||||
@@ -331,6 +335,7 @@ describe('SearchResolver', () => {
|
||||
cafeCorp.id,
|
||||
searchInput1Pet.id,
|
||||
searchInput2Pet.id,
|
||||
cjkPet.id,
|
||||
cafePet.id,
|
||||
naivePet.id,
|
||||
],
|
||||
@@ -1298,6 +1303,34 @@ describe('SearchResolver', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should find CJK records via ILIKE fallback when tsvector tokenization fails',
|
||||
context: {
|
||||
input: {
|
||||
searchInput: '商业',
|
||||
excludedObjectNameSingulars: [
|
||||
'workspaceMember',
|
||||
'employmentHistory',
|
||||
'petCareAgreement',
|
||||
],
|
||||
includedObjectNameSingulars: ['pet'],
|
||||
limit: 50,
|
||||
},
|
||||
eval: {
|
||||
orderedRecordIds: [cjkPet.id],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
decodedEndCursor: {
|
||||
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||
lastRecordIdsPerObject: {
|
||||
pet: cjkPet.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(eachTestingContextFilter(testsUseCases))(
|
||||
|
||||
Reference in New Issue
Block a user