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:
neon
2026-09-08 15:43:27 +00:00
parent 3d6afb3149
commit cf40058cb6
6 changed files with 185 additions and 16 deletions
@@ -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);
}