fix(books): join metadata table when resolving query-based book ids

Query-based book selections failed with a 500 error when filtering by metadata fields because findIdsByWhere queried the bare books table without joining book_metadata. Left join book_metadata so WHERE clause predicates referencing metadata fields resolve correctly.

Closes #732
This commit is contained in:
neon
2026-07-22 06:31:31 +00:00
parent 8c287e9b42
commit a38288b1a2
3 changed files with 102 additions and 1 deletions
@@ -1,6 +1,7 @@
import { BookRepository } from './book.repository';
import { sql } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { bookMetadata, books } from '../../db/schema';
function makeSelectChain<T>(terminalMethod: string, terminalResult: T) {
const chain: Record<string, vi.Mock> = {
@@ -210,6 +211,26 @@ describe('BookRepository', () => {
});
});
it('findIdsByWhere joins metadata before applying selection predicates', async () => {
const rows = [{ id: 10 }, { id: 11 }];
const chain = makeSelectChain('where', rows);
const db = { select: vi.fn().mockReturnValue(chain) };
const repo = new BookRepository(db as never);
const result = await repo.findIdsByWhere(sql`${bookMetadata.title} is not null`);
expect(chain.from).toHaveBeenCalledWith(books);
expect(chain.leftJoin).toHaveBeenCalledWith(bookMetadata, expect.anything());
const dialect = new PgDialect();
const joinSql = dialect.sqlToQuery(chain.leftJoin.mock.calls[0]![1]).sql;
const whereSql = dialect.sqlToQuery(chain.where.mock.calls[0]![0]).sql;
expect(joinSql).toBe('"book_metadata"."book_id" = "books"."id"');
expect(whereSql).toContain('"book_metadata"."title" is not null');
expect(whereSql).toContain('"books"."status" <>');
expect(result).toEqual([10, 11]);
});
it('fetches per-book and per-file progress helpers with null-safe fallbacks', async () => {
const findCollectionsChain = makeSelectChain('orderBy', [{ id: 1, name: 'Favorites' }]);
const libraryIdChain = makeSelectChain('limit', [{ libraryId: 5 }]);
+5 -1
View File
@@ -1729,7 +1729,11 @@ export class BookRepository {
}
async findIdsByWhere(where: SQL | undefined): Promise<number[]> {
const rows = await this.db.select({ id: books.id }).from(books).where(this.visibleWhere(where));
const rows = await this.db
.select({ id: books.id })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(this.visibleWhere(where));
return rows.map((r) => r.id);
}
+76
View File
@@ -312,6 +312,82 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
});
});
describe('bulk query selections', () => {
it('resolves metadata filters and search without regressing subquery or explicit selections', async () => {
const metadataCollectionResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(limitedUser.accessToken),
payload: { name: `Metadata selection ${randomUUID()}`, icon: 'FolderOpen' },
});
expect(metadataCollectionResponse.statusCode).toBe(201);
const metadataCollection = metadataCollectionResponse.json() as { id: number };
const metadataAdd = await ctx.app.inject({
method: 'POST',
url: `/api/v1/collections/${metadataCollection.id}/books`,
headers: authHeader(limitedUser.accessToken),
payload: {
query: {
libraryId: visibleLibrary.libraryId,
filter: {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Contract' }],
},
},
},
});
expect(metadataAdd.statusCode).toBe(201);
expect(metadataAdd.json()).toMatchObject({ id: metadataCollection.id, bookCount: 3 });
const subqueryRemove = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/collections/${metadataCollection.id}/books`,
headers: authHeader(limitedUser.accessToken),
payload: {
query: {
libraryId: visibleLibrary.libraryId,
filter: {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] }],
},
},
},
});
expect(subqueryRemove.statusCode).toBe(200);
expect(subqueryRemove.json()).toMatchObject({ id: metadataCollection.id, bookCount: 2 });
const explicitAdd = await ctx.app.inject({
method: 'POST',
url: `/api/v1/collections/${metadataCollection.id}/books`,
headers: authHeader(limitedUser.accessToken),
payload: { bookIds: [visiblePdf.bookId] },
});
expect(explicitAdd.statusCode).toBe(201);
expect(explicitAdd.json()).toMatchObject({ id: metadataCollection.id, bookCount: 3 });
const searchCollectionResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(limitedUser.accessToken),
payload: { name: `Search selection ${randomUUID()}`, icon: 'FolderSearch' },
});
expect(searchCollectionResponse.statusCode).toBe(201);
const searchCollection = searchCollectionResponse.json() as { id: number };
const scopedSearchAdd = await ctx.app.inject({
method: 'POST',
url: `/api/v1/collections/${searchCollection.id}/books`,
headers: authHeader(limitedUser.accessToken),
payload: { query: { q: 'EPUB' } },
});
expect(scopedSearchAdd.statusCode).toBe(201);
expect(scopedSearchAdd.json()).toMatchObject({ id: searchCollection.id, bookCount: 1 });
});
});
describe('detail and reader state', () => {
it('returns detail payload fields and keeps progress and status isolated per user', async () => {
const saveProgress = await ctx.app.inject({