feat(books): add primary file size filter rule

Add fileSize as a filterable rule field resolved against the book's primary file size in bytes. In the filter builder UI, allow operators to input sizes in megabytes with automatic serialization to bytes for queries.
This commit is contained in:
neon
2026-08-28 15:32:28 +00:00
parent f39e0e8ab5
commit 1dd39bf0eb
9 changed files with 149 additions and 18 deletions
@@ -136,7 +136,7 @@ const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
* new operator is added to RuleOperator but not handled here.
*/
function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'rating', 'communityRating', 'metadataScore'];
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore'];
const dateFields: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt'];
const isNumericField = numericFields.includes(field);
@@ -879,6 +879,16 @@ describe('numericRuleToSql (via pageCount and publishedYear)', () => {
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'publishedYear', operator: 'notEq', value: 2001 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'ne' });
});
it('filters file size through the selected primary file', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'fileSize', operator: 'gte', value: 10_485_760 }) as never, BASE_CTX) as any;
const clause = getRuleSql(where) as any;
expect(clause).toMatchObject({ type: 'gte', right: 10_485_760 });
expect(clause.left).toMatchObject({ type: 'sql' });
expect(collectColumnNames(clause.left)).toEqual(expect.arrayContaining(['size_bytes', 'id', 'primary_file_id']));
});
});
describe('authorRuleToSql', () => {
@@ -186,6 +186,13 @@ export class BookQueryBuilder {
return this.libraryRuleToSql(operator, value as string[]);
case 'format':
return this.formatRuleToSql(operator, value as string[]);
case 'fileSize':
return this.numericRuleToSql(
sql<number>`(SELECT ${bookFiles.sizeBytes} FROM ${bookFiles} WHERE ${bookFiles.id} = ${books.primaryFileId})`,
operator,
value as number,
valueTo as number | undefined,
);
case 'addedAt':
return this.dateRuleToSql(operator, value as string | number, valueTo as string | undefined);
case 'startedAt':
@@ -254,34 +261,35 @@ export class BookQueryBuilder {
}
}
private numericRuleToSql(col: AnyColumn, operator: string, value?: number, valueTo?: number): SQL {
private numericRuleToSql(col: AnyColumn | SQL, operator: string, value?: number, valueTo?: number): SQL {
const expression = col as SQL;
switch (operator) {
case 'eq':
this.assertNumber(value, operator, 'value');
return eq(col, value!);
return eq(expression, value!);
case 'notEq':
this.assertNumber(value, operator, 'value');
return ne(col, value!);
return ne(expression, value!);
case 'gt':
this.assertNumber(value, operator, 'value');
return gt(col, value!);
return gt(expression, value!);
case 'gte':
this.assertNumber(value, operator, 'value');
return gte(col, value!);
return gte(expression, value!);
case 'lt':
this.assertNumber(value, operator, 'value');
return lt(col, value!);
return lt(expression, value!);
case 'lte':
this.assertNumber(value, operator, 'value');
return lte(col, value!);
return lte(expression, value!);
case 'between':
this.assertNumber(value, operator, 'value');
this.assertNumber(valueTo, operator, 'valueTo');
return and(gte(col, value!), lte(col, valueTo!))!;
return and(gte(expression, value!), lte(expression, valueTo!))!;
case 'isEmpty':
return isNull(col);
return isNull(expression);
case 'isNotEmpty':
return isNotNull(col);
return isNotNull(expression);
default:
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
}
+28 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import { rm } from 'fs/promises';
import { rm, stat } from 'fs/promises';
import { join } from 'path';
import * as unzipper from 'unzipper';
@@ -317,6 +317,33 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expectError(invalidLimit, 400, 'limit must not be greater than 20');
});
it('filters books by the selected primary file size', async () => {
const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: {
filter: {
type: 'group',
join: 'AND',
rules: [
{ type: 'rule', field: 'title', operator: 'eq', value: 'Alpha Contract EPUB' },
{ type: 'rule', field: 'fileSize', operator: 'eq', value: primaryFileSize },
],
},
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 10 },
},
});
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
total: 1,
items: [{ id: visibleEpub.bookId, files: [{ id: visibleEpub.bookFileId, role: 'primary', sizeBytes: primaryFileSize }] }],
});
});
});
describe('bulk query selections', () => {