mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(collections): keep books in the order they were added
Collections opened in title order, so a curated shelf came back rearranged. Add a collectionOrder sort field, make it the default for collection views, and offer it from a sort control that grid and list previously did not have. Back the ordering with a new collection_books.position sequence rather than added_at: added_at defaults to now(), the transaction timestamp, so every book added in one request shared it and the order fell back to books.id. Kobo collection paging had the same tie and now orders by position. Also stop useBookWindow blanking its slots and total on every query change. Clearing them collapsed the virtual list to zero height and back, which read as a flash on any sort, search, or filter change. Closes #978
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "collection_books" ADD COLUMN "position" bigserial NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "collection_books_collection_position_idx" ON "collection_books" USING btree ("collection_id","position");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -456,6 +456,13 @@
|
||||
"when": 1786401421899,
|
||||
"tag": "0064_add_kobo_store_sync_setting",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 65,
|
||||
"version": "7",
|
||||
"when": 1786466476402,
|
||||
"tag": "0065_add_collection_books_position",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { index, boolean, integer, pgTable, primaryKey, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { bigserial, index, boolean, integer, pgTable, primaryKey, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { books } from './books';
|
||||
import { users } from './auth';
|
||||
@@ -37,6 +37,13 @@ export const collectionBooks = pgTable(
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
// `added_at` defaults to now(), which is the transaction timestamp, so every book added in
|
||||
// one request shares it. This sequence is what actually keeps membership in insertion order.
|
||||
position: bigserial('position', { mode: 'number' }),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.collectionId, table.bookId] }), index('collection_books_book_id_idx').on(table.bookId)],
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.collectionId, table.bookId] }),
|
||||
index('collection_books_book_id_idx').on(table.bookId),
|
||||
index('collection_books_collection_position_idx').on(table.collectionId, table.position),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1732,6 +1732,15 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'addedAt', dir: 'desc' }], 1)).toBe('sort_added_at DESC NULLS LAST, r.id ASC');
|
||||
});
|
||||
|
||||
it('generates sort_collection_position for collectionOrder field', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'collectionOrder', dir: 'asc' }], 1)).toBe(
|
||||
'sort_collection_position ASC NULLS LAST, r.id ASC',
|
||||
);
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'collectionOrder', dir: 'desc' }], 1)).toBe(
|
||||
'sort_collection_position DESC NULLS LAST, r.id ASC',
|
||||
);
|
||||
});
|
||||
|
||||
it('generates seriesIndex with sort_title fallback when series is not in sort', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'seriesIndex', dir: 'asc' }], 1);
|
||||
expect(result).toBe('series_index ASC NULLS LAST, sort_title ASC NULLS LAST, r.id ASC');
|
||||
|
||||
@@ -18,7 +18,7 @@ import { isDateKey, resolveTimeZone, toDateKeyInTimeZone } from '../../common/ut
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import * as schema from '../../db/schema';
|
||||
import { BookSortBuilder, customMetadataValueColumn } from './book-sort-builder.service';
|
||||
import { BookSortBuilder, customMetadataValueColumn, type BookSortContext } from './book-sort-builder.service';
|
||||
import {
|
||||
audiobookProgress,
|
||||
authors,
|
||||
@@ -129,8 +129,8 @@ export class BookQueryBuilder {
|
||||
)!;
|
||||
}
|
||||
|
||||
buildOrderBy(sort: SortSpec[], userId?: number, customFieldTypes?: CustomMetadataFieldTypeMap): SQL[] {
|
||||
return this.sortBuilder.build(sort, userId, customFieldTypes);
|
||||
buildOrderBy(sort: SortSpec[], userId?: number, customFieldTypes?: CustomMetadataFieldTypeMap, context?: BookSortContext): SQL[] {
|
||||
return this.sortBuilder.build(sort, userId, customFieldTypes, context);
|
||||
}
|
||||
|
||||
private groupToSql(node: GroupRule, depth: number, userId?: number, timeZone = 'UTC'): SQL {
|
||||
@@ -1081,6 +1081,11 @@ export class BookQueryBuilder {
|
||||
case 'addedAt':
|
||||
parts.push(`sort_added_at ${D} NULLS LAST`);
|
||||
break;
|
||||
// Precomputed by the collapsed query as the earliest membership position in the group,
|
||||
// so a collapsed series sits where its first-added volume was added.
|
||||
case 'collectionOrder':
|
||||
parts.push(`sort_collection_position ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'seriesIndex':
|
||||
parts.push(`series_index ${D} NULLS LAST`);
|
||||
if (!sort.some((s) => s.field === 'series')) {
|
||||
|
||||
@@ -32,6 +32,46 @@ describe('BookSortBuilder', () => {
|
||||
expect(result[0]).toMatchObject({ type: 'sql', text: ' ASC NULLS LAST' });
|
||||
});
|
||||
|
||||
it('still falls back to title for an empty sort inside a collection query', () => {
|
||||
const result = service.build([], 7, undefined, { defaultCollectionId: 42 });
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({ type: 'sql', text: ' ASC NULLS LAST' });
|
||||
expect(result[0]?.values).not.toContain(42);
|
||||
});
|
||||
|
||||
it('builds collectionOrder as a correlated lookup of the membership position', () => {
|
||||
const raw = (sql as unknown as { raw: vi.Mock }).raw;
|
||||
|
||||
const result = service.build([{ field: 'collectionOrder', dir: 'asc' }], 7, undefined, { defaultCollectionId: 42 });
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.text).toContain('SELECT');
|
||||
expect(result[0]?.text).toContain('NULLS LAST');
|
||||
expect(result[0]?.values).toContain(42);
|
||||
expect(raw).toHaveBeenCalledWith('ASC');
|
||||
});
|
||||
|
||||
it('honours a descending collectionOrder', () => {
|
||||
const raw = (sql as unknown as { raw: vi.Mock }).raw;
|
||||
|
||||
service.build([{ field: 'collectionOrder', dir: 'desc' }], 7, undefined, { defaultCollectionId: 42 });
|
||||
|
||||
expect(raw).toHaveBeenCalledWith('DESC');
|
||||
});
|
||||
|
||||
it('rejects collectionOrder outside a collection scope', () => {
|
||||
expect(() => service.build([{ field: 'collectionOrder', dir: 'asc' }], 7)).toThrow(
|
||||
new BadRequestException('collectionOrder sort requires a collection scope'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects collectionOrder with an unusable collection id', () => {
|
||||
expect(() => service.build([{ field: 'collectionOrder', dir: 'asc' }], 7, undefined, { defaultCollectionId: 0 })).toThrow(
|
||||
new BadRequestException('Invalid collection id for collectionOrder sort'),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds simple field sorts for title asc and desc', () => {
|
||||
const raw = (sql as unknown as { raw: vi.Mock }).raw;
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { AnyColumn, SQL, sql } from 'drizzle-orm';
|
||||
|
||||
import { parseCustomSortFieldId } from '@bookorbit/types';
|
||||
import type { CustomMetadataFieldType, CustomMetadataFieldTypeMap, SortField, SortSpec } from '@bookorbit/types';
|
||||
import { bookMetadata, books } from '../../db/schema';
|
||||
import { bookMetadata, books, collectionBooks } from '../../db/schema';
|
||||
|
||||
export type BookSortContext = {
|
||||
defaultCollectionId?: number;
|
||||
};
|
||||
|
||||
const CUSTOM_VALUE_COLUMNS: Record<CustomMetadataFieldType, string> = {
|
||||
text: 'value_text',
|
||||
@@ -37,12 +41,12 @@ const SORT_FIELD_MAP: Partial<Record<SortField, AnyColumn>> = {
|
||||
|
||||
@Injectable()
|
||||
export class BookSortBuilder {
|
||||
build(sort: SortSpec[], userId?: number, customFieldTypes?: CustomMetadataFieldTypeMap): SQL[] {
|
||||
build(sort: SortSpec[], userId?: number, customFieldTypes?: CustomMetadataFieldTypeMap, context?: BookSortContext): SQL[] {
|
||||
const result: SQL[] = [];
|
||||
for (const { field, dir } of sort) {
|
||||
const D = this.normalizeDir(dir);
|
||||
if (!D) continue;
|
||||
this.appendField(result, field, D, sort, userId, customFieldTypes);
|
||||
this.appendField(result, field, D, sort, userId, customFieldTypes, context);
|
||||
}
|
||||
if (result.length === 0) result.push(sql`${bookMetadata.title} ASC NULLS LAST`);
|
||||
result.push(sql`${books.id} ASC`);
|
||||
@@ -61,6 +65,7 @@ export class BookSortBuilder {
|
||||
allSorts: SortSpec[],
|
||||
userId?: number,
|
||||
customFieldTypes?: CustomMetadataFieldTypeMap,
|
||||
context?: BookSortContext,
|
||||
): void {
|
||||
const customFieldId = parseCustomSortFieldId(field);
|
||||
if (customFieldId !== null) {
|
||||
@@ -109,6 +114,15 @@ export class BookSortBuilder {
|
||||
sql`(SELECT ubr.rating FROM user_book_ratings ubr WHERE ubr.book_id = books.id AND ubr.user_id = ${userId}) ${sql.raw(D)} NULLS LAST`,
|
||||
);
|
||||
break;
|
||||
case 'collectionOrder': {
|
||||
const collectionId = context?.defaultCollectionId;
|
||||
if (collectionId === undefined) throw new BadRequestException('collectionOrder sort requires a collection scope');
|
||||
if (!Number.isSafeInteger(collectionId) || collectionId <= 0) throw new BadRequestException('Invalid collection id for collectionOrder sort');
|
||||
result.push(
|
||||
sql`(SELECT ${collectionBooks.position} FROM ${collectionBooks} WHERE ${collectionBooks.collectionId} = ${collectionId} AND ${collectionBooks.bookId} = ${books.id}) ${sql.raw(D)} NULLS LAST`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'random': {
|
||||
const daySeed = Math.floor(Date.now() / 86_400_000);
|
||||
const scopedSeed = daySeed + (userId ?? 0);
|
||||
|
||||
@@ -218,6 +218,62 @@ describe('BookRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('orders collapsed collection groups by their earliest membership position', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({ rows: [] });
|
||||
const repo = new BookRepository({ execute } as never);
|
||||
|
||||
await repo.findCardsCollapsed({
|
||||
where: undefined,
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
userId: 7,
|
||||
defaultCollectionId: 42,
|
||||
});
|
||||
|
||||
const query = new PgDialect().sqlToQuery(execute.mock.calls[0]![0]);
|
||||
expect(query.sql).toContain('MIN(base.collection_position) AS first_collection_position');
|
||||
expect(query.sql).toContain('COALESCE(sa.first_collection_position, base.collection_position) AS sort_collection_position');
|
||||
expect(query.sql).toContain('ORDER BY sort_collection_position ASC NULLS LAST, r.id ASC');
|
||||
expect(query.params).toContain(42);
|
||||
});
|
||||
|
||||
// The order by names sort_collection_position unconditionally, so the column has to be selected
|
||||
// even when no collection is in scope or the query fails to parse.
|
||||
it('selects a null membership position when no collection is in scope', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({ rows: [] });
|
||||
const repo = new BookRepository({ execute } as never);
|
||||
|
||||
await repo.findCardsCollapsed({
|
||||
where: undefined,
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
userId: 7,
|
||||
});
|
||||
|
||||
const query = new PgDialect().sqlToQuery(execute.mock.calls[0]![0]);
|
||||
expect(query.sql).toContain('NULL::bigint AS collection_position');
|
||||
expect(query.sql).not.toContain('FROM "collection_books"');
|
||||
});
|
||||
|
||||
it('rejects an unusable collection id on the collapsed path', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({ rows: [] });
|
||||
const repo = new BookRepository({ execute } as never);
|
||||
|
||||
await expect(
|
||||
repo.findCardsCollapsed({
|
||||
where: undefined,
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
userId: 7,
|
||||
defaultCollectionId: 0,
|
||||
}),
|
||||
).rejects.toThrow('Invalid default collection id');
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('findCardIds applies card query pagination without running enrichment queries', async () => {
|
||||
const chain = makeSelectChain('offset', [{ id: 9 }, { id: 3 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { SQL, and, asc, count, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm';
|
||||
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
@@ -752,6 +752,7 @@ export class BookRepository {
|
||||
offset: number;
|
||||
userId: number;
|
||||
customFieldTypes?: CustomMetadataFieldTypeMap;
|
||||
defaultCollectionId?: number;
|
||||
}): Promise<{
|
||||
rows: Array<{
|
||||
id: number;
|
||||
@@ -811,9 +812,23 @@ export class BookRepository {
|
||||
}[];
|
||||
total: number;
|
||||
}> {
|
||||
const { where, sort, limit, offset, userId } = opts;
|
||||
const { where, sort, limit, offset, userId, defaultCollectionId } = opts;
|
||||
if (defaultCollectionId !== undefined && (!Number.isSafeInteger(defaultCollectionId) || defaultCollectionId <= 0)) {
|
||||
throw new BadRequestException('Invalid default collection id');
|
||||
}
|
||||
const whereFragment = this.visibleWhere(where);
|
||||
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId, opts.customFieldTypes);
|
||||
// The collectionOrder branch of the order by names sort_collection_position, so the column has
|
||||
// to exist even for the library and smart scope queries that can never sort on it.
|
||||
const collectionPosition =
|
||||
defaultCollectionId === undefined
|
||||
? sql`NULL::bigint`
|
||||
: sql`(
|
||||
SELECT ${collectionBooks.position}
|
||||
FROM ${collectionBooks}
|
||||
WHERE ${collectionBooks.collectionId} = ${defaultCollectionId}
|
||||
AND ${collectionBooks.bookId} = ${books.id}
|
||||
)`;
|
||||
|
||||
const result = await this.db.execute<CollapsedRawRow>(sql`
|
||||
WITH base_rows AS (
|
||||
@@ -843,6 +858,7 @@ export class BookRepository {
|
||||
book_metadata.hardcover_id,
|
||||
book_metadata.hardcover_edition_id,
|
||||
book_metadata.metadata_score,
|
||||
${collectionPosition} AS collection_position,
|
||||
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
|
||||
FROM books
|
||||
INNER JOIN libraries ON libraries.id = books.library_id
|
||||
@@ -855,7 +871,8 @@ export class BookRepository {
|
||||
base.library_id,
|
||||
COUNT(*) AS book_count,
|
||||
SUM(CASE WHEN user_book_status.status = 'read' THEN 1 ELSE 0 END) AS read_count,
|
||||
MAX(base.added_at) AS latest_added_at
|
||||
MAX(base.added_at) AS latest_added_at,
|
||||
MIN(base.collection_position) AS first_collection_position
|
||||
FROM base_rows base
|
||||
LEFT JOIN user_book_status ON user_book_status.book_id = base.id AND user_book_status.user_id = ${userId}
|
||||
WHERE base.series_id IS NOT NULL
|
||||
@@ -980,6 +997,7 @@ export class BookRepository {
|
||||
base.primary_author_sort_name AS author_sort_name,
|
||||
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
||||
COALESCE(sa.latest_added_at, base.added_at) AS sort_added_at,
|
||||
COALESCE(sa.first_collection_position, base.collection_position) AS sort_collection_position,
|
||||
sa.book_count,
|
||||
sa.read_count,
|
||||
sc.cover_book_ids,
|
||||
|
||||
@@ -4472,6 +4472,50 @@ describe('BookService', () => {
|
||||
expect(result).toEqual({ items: [], total: 0, page: 0, size: 50 });
|
||||
});
|
||||
|
||||
it('passes collection ordering context to an uncollapsed collectionOrder query', async () => {
|
||||
const { service, bookRepo, queryBuilder } = makeService();
|
||||
const query: BookQuery = {
|
||||
pagination: { page: 0, size: 50 },
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
filter: undefined,
|
||||
collapseSeries: false,
|
||||
};
|
||||
queryBuilder.buildOrderBy.mockReturnValue('collection-order');
|
||||
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
|
||||
|
||||
await service.executeBooksQuery(12, undefined, query, { defaultCollectionId: 42 });
|
||||
|
||||
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined, { defaultCollectionId: 42 });
|
||||
expect(bookRepo.findCards).toHaveBeenCalledWith(expect.objectContaining({ orderBy: 'collection-order' }));
|
||||
});
|
||||
|
||||
it('rejects collectionOrder when the query is not scoped to a collection', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
const query: BookQuery = {
|
||||
pagination: { page: 0, size: 50 },
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
filter: undefined,
|
||||
collapseSeries: false,
|
||||
};
|
||||
|
||||
await expect(service.executeBooksQuery(12, undefined, query)).rejects.toThrow('This sort is only available inside a collection');
|
||||
expect(bookRepo.findCards).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects collectionOrder on the collapsed path too', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
|
||||
const query: BookQuery = {
|
||||
pagination: { page: 0, size: 50 },
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
filter: undefined,
|
||||
collapseSeries: true,
|
||||
};
|
||||
|
||||
await expect(service.executeBooksQuery(12, undefined, query)).rejects.toThrow('This sort is only available inside a collection');
|
||||
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns results from findCardsCollapsed when collapseSeries is true', async () => {
|
||||
const { service, bookRepo, queryBuilder } = makeService();
|
||||
const hasSeriesSelectionFilterSpy = vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
|
||||
@@ -4499,6 +4543,30 @@ describe('BookService', () => {
|
||||
expect(result).toEqual({ items: [], total: 0, page: 1, size: 25 });
|
||||
});
|
||||
|
||||
it('passes collection ordering context to a collapsed collectionOrder query', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
|
||||
const query: BookQuery = {
|
||||
pagination: { page: 0, size: 50 },
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
filter: undefined,
|
||||
collapseSeries: true,
|
||||
};
|
||||
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
|
||||
|
||||
await service.executeBooksQuery(12, undefined, query, { defaultCollectionId: 42 });
|
||||
|
||||
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
|
||||
where: undefined,
|
||||
sort: query.sort,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
userId: 12,
|
||||
customFieldTypes: undefined,
|
||||
defaultCollectionId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves custom metadata field types for custom sort fields', async () => {
|
||||
const { service, bookRepo, queryBuilder, customMetadataService } = makeService();
|
||||
const fieldTypes = new Map([[7, 'number' as const]]);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
MetadataProviderKey,
|
||||
Permission,
|
||||
customSortFieldIds,
|
||||
hasCollectionScopedSort,
|
||||
isAudioFormat,
|
||||
isSortField,
|
||||
jumpRailStrategyForSort,
|
||||
@@ -110,6 +111,10 @@ type SeriesCollapseQueryOptions = {
|
||||
seriesSelectionFilter: GroupRule | undefined;
|
||||
};
|
||||
|
||||
type BookQueryExecutionOptions = Partial<SeriesCollapseQueryOptions> & {
|
||||
defaultCollectionId?: number;
|
||||
};
|
||||
|
||||
const METADATA_UPDATE_FAILPOINTS = [
|
||||
'afterScalarUpdate',
|
||||
'afterSeriesMembershipsReplace',
|
||||
@@ -1064,15 +1069,15 @@ export class BookService {
|
||||
return this.customMetadataService.getActiveFieldTypes(fieldIds);
|
||||
}
|
||||
|
||||
async executeBooksQuery(
|
||||
userId: number,
|
||||
where: SQL | undefined,
|
||||
query: BookQuery,
|
||||
collapseOptions?: SeriesCollapseQueryOptions,
|
||||
): Promise<BooksPage> {
|
||||
async executeBooksQuery(userId: number, where: SQL | undefined, query: BookQuery, options?: BookQueryExecutionOptions): Promise<BooksPage> {
|
||||
const start = Date.now();
|
||||
const { page, size } = query.pagination;
|
||||
const seriesSelectionFilter = collapseOptions ? collapseOptions.seriesSelectionFilter : query.filter;
|
||||
if (hasCollectionScopedSort(query.sort) && options?.defaultCollectionId === undefined) {
|
||||
throw new BadRequestException('This sort is only available inside a collection');
|
||||
}
|
||||
// An absent key means "fall back to the query filter"; a present key set to undefined means the
|
||||
// caller resolved the selection filter to nothing, which is not the same thing.
|
||||
const seriesSelectionFilter = options && 'seriesSelectionFilter' in options ? options.seriesSelectionFilter : query.filter;
|
||||
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(seriesSelectionFilter);
|
||||
|
||||
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
|
||||
@@ -1086,6 +1091,7 @@ export class BookService {
|
||||
offset: page * size,
|
||||
userId,
|
||||
customFieldTypes,
|
||||
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
|
||||
});
|
||||
// Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata.
|
||||
const result = {
|
||||
@@ -1113,7 +1119,10 @@ export class BookService {
|
||||
return result;
|
||||
}
|
||||
|
||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes);
|
||||
const orderBy =
|
||||
options?.defaultCollectionId !== undefined
|
||||
? this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, { defaultCollectionId: options.defaultCollectionId })
|
||||
: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes);
|
||||
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
|
||||
await this.bookRepo.findCards({
|
||||
where,
|
||||
|
||||
@@ -120,7 +120,7 @@ export class CollectionRepository {
|
||||
.innerJoin(books, eq(books.id, collectionBooks.bookId))
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(where)
|
||||
.orderBy(collectionBooks.addedAt, collectionBooks.bookId)
|
||||
.orderBy(collectionBooks.position)
|
||||
.limit(size)
|
||||
.offset(page * size),
|
||||
this.db
|
||||
@@ -147,7 +147,7 @@ export class CollectionRepository {
|
||||
.innerJoin(books, eq(books.id, collectionBooks.bookId))
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(and(eq(collectionBooks.collectionId, collectionId), inArray(books.libraryId, libraryIds), ...(extraWhere ? [extraWhere] : [])))
|
||||
.orderBy(collectionBooks.addedAt, collectionBooks.bookId);
|
||||
.orderBy(collectionBooks.position);
|
||||
return rows.map((row) => row.bookId);
|
||||
}
|
||||
|
||||
|
||||
@@ -398,10 +398,15 @@ describe('CollectionService', () => {
|
||||
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||
});
|
||||
expect(collectionRepo.buildMembershipWhere).toHaveBeenCalledWith(10);
|
||||
expect(bookService.executeBooksQuery).toHaveBeenCalledWith(1, expect.anything(), {
|
||||
sort: [],
|
||||
pagination: { page: 0, size: 50 },
|
||||
});
|
||||
expect(bookService.executeBooksQuery).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
{
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
pagination: { page: 0, size: 50 },
|
||||
},
|
||||
{ defaultCollectionId: 10 },
|
||||
);
|
||||
});
|
||||
|
||||
it('passes collapse, search, and query filters into the shared book query pipeline', async () => {
|
||||
@@ -439,6 +444,7 @@ describe('CollectionService', () => {
|
||||
collapseSeries: true,
|
||||
q: 'science',
|
||||
}),
|
||||
{ defaultCollectionId: 10 },
|
||||
);
|
||||
expect(result).toEqual({ items: [{ id: 2 }, { id: 1 }], total: 2, page: 0, size: 50 });
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ export class CollectionService {
|
||||
|
||||
async getBooks(id: number, user: RequestUser, page: number, size: number, collapseSeries?: boolean, q?: string): Promise<BooksPage> {
|
||||
return this.queryBooks(id, user, {
|
||||
sort: [],
|
||||
sort: [{ field: 'collectionOrder', dir: 'asc' }],
|
||||
pagination: { page, size },
|
||||
...(collapseSeries ? { collapseSeries: true } : {}),
|
||||
...(q?.trim() ? { q: q.trim() } : {}),
|
||||
@@ -232,7 +232,7 @@ export class CollectionService {
|
||||
);
|
||||
try {
|
||||
const where = await this.buildBooksWhere(id, user, query);
|
||||
const page = await this.bookService.executeBooksQuery(user.id, where, query);
|
||||
const page = await this.bookService.executeBooksQuery(user.id, where, query, { defaultCollectionId: id });
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} total=${page.total} itemCount=${page.items.length} - query collection books completed`,
|
||||
|
||||
Reference in New Issue
Block a user