mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(users): select exact content restriction matches
Prioritize exact genre and tag results within bounded catalog searches. Resolve Enter presses against existing IDs and ignore stale lookup responses. Fixes #1295
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { PgDialect, pgTable, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from './accent-insensitive-search.utils';
|
||||
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from './accent-insensitive-search.utils';
|
||||
|
||||
const records = pgTable('records', {
|
||||
name: text('name'),
|
||||
@@ -25,6 +25,17 @@ describe('accentInsensitiveIlike', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('accentInsensitiveExactMatchRank', () => {
|
||||
it('ranks accent-insensitive, case-insensitive exact matches before partial matches', () => {
|
||||
const dialect = new PgDialect();
|
||||
|
||||
const query = dialect.sqlToQuery(accentInsensitiveExactMatchRank(records.name, 'Garcia'));
|
||||
|
||||
expect(query.sql).toBe('case when lower(public.bookorbit_unaccent("records"."name")) = lower(public.bookorbit_unaccent($1)) then 0 else 1 end');
|
||||
expect(query.params).toEqual(['Garcia']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeLikePattern', () => {
|
||||
it('escapes every LIKE metacharacter', () => {
|
||||
expect(escapeLikePattern('100%_off\\now')).toBe('100\\%\\_off\\\\now');
|
||||
|
||||
@@ -8,6 +8,10 @@ export function accentInsensitiveIlike(value: SQLWrapper, pattern: string | SQLW
|
||||
return sql`public.bookorbit_unaccent(${value}) ILIKE public.bookorbit_unaccent(${pattern})`;
|
||||
}
|
||||
|
||||
export function accentInsensitiveExactMatchRank(value: SQLWrapper, term: string): SQL<number> {
|
||||
return sql<number>`case when lower(public.bookorbit_unaccent(${value})) = lower(public.bookorbit_unaccent(${term})) then 0 else 1 end`;
|
||||
}
|
||||
|
||||
export function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ vi.mock('drizzle-orm', () => ({
|
||||
|
||||
vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../common/utils/accent-insensitive-search.utils')>()),
|
||||
accentInsensitiveExactMatchRank: vi.fn((left: unknown, term: string) => ({ type: 'sql', left, term })),
|
||||
accentInsensitiveIlike: vi.fn((left: unknown, pattern: string) => ({ type: 'accentInsensitiveIlike', left, pattern })),
|
||||
}));
|
||||
|
||||
import { and, eq, isNotNull } from 'drizzle-orm';
|
||||
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { authors, bookMetadata, bookSeries, collections, narrators } from '../../db/schema';
|
||||
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
interface QueryChain<T> {
|
||||
@@ -114,6 +115,20 @@ describe('CatalogService', () => {
|
||||
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['genres', genres, (service: CatalogService, q: string) => service.searchGenres(q)],
|
||||
['tags', tags, (service: CatalogService, q: string) => service.searchTags(q)],
|
||||
] as const)('ranks an exact %s match before alphabetical partial matches', async (_label, table, search) => {
|
||||
const { service, selectChains } = makeService();
|
||||
|
||||
await search(service, ' Young\u00a0 Adult ');
|
||||
|
||||
expect(accentInsensitiveIlike).toHaveBeenCalledWith(table.name, '%Young Adult%');
|
||||
expect(accentInsensitiveExactMatchRank).toHaveBeenCalledWith(table.name, 'Young Adult');
|
||||
expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(expect.objectContaining({ type: 'sql' }), table.name);
|
||||
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
|
||||
});
|
||||
|
||||
it('uses distinct metadata lookup for publishers and filters null rows defensively', async () => {
|
||||
const { service, setSelectDistinctRows, selectDistinctChains } = makeService();
|
||||
setSelectDistinctRows([{ name: 'Orbit' }, { name: null }, { name: 'Tor' }]);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { and, eq, isNotNull } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import { accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
|
||||
|
||||
@@ -74,14 +75,14 @@ export class CatalogService {
|
||||
}
|
||||
|
||||
private searchByNameWithId(q: string, table: NamedTableWithId): Promise<SearchResultWithId[]> {
|
||||
const pattern = this.toContainsPattern(q);
|
||||
if (!pattern) return Promise.resolve([]);
|
||||
const term = normalizeMetadataText(q);
|
||||
if (!term) return Promise.resolve([]);
|
||||
|
||||
return this.db
|
||||
.select({ id: table.id, name: table.name })
|
||||
.from(table)
|
||||
.where(accentInsensitiveIlike(table.name, pattern))
|
||||
.orderBy(table.name)
|
||||
.where(accentInsensitiveIlike(table.name, buildSearchPattern(term)))
|
||||
.orderBy(accentInsensitiveExactMatchRank(table.name, term), table.name)
|
||||
.limit(DEFAULT_SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user