fix(library): filter entity duplicate detection by accessible libraries

Ensure entity manager strategies and duplicate computation restrict book references and candidate entities to libraries accessible by the current user.

Closes #664
This commit is contained in:
neon
2026-07-13 19:09:57 +00:00
parent 4c88c15365
commit 74e0e6112a
17 changed files with 534 additions and 82 deletions
@@ -1,5 +1,15 @@
import { DuplicateComputeService } from './duplicate-compute.service';
const scope = { libraryIds: [3, 8] };
function flattenSql(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(flattenSql).join(' ');
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' ');
}
function makeService() {
const where = vi.fn().mockResolvedValue([]);
const from = vi.fn().mockReturnValue({ where });
@@ -130,15 +140,49 @@ describe('DuplicateComputeService', () => {
const { service, db } = makeService();
db.execute.mockResolvedValue({ rows: [{ idA: 1, idB: 2, simScore: 0.9 }] });
await expect(service.readCandidatePairs('author', 0.7)).resolves.toEqual([{ idA: 1, idB: 2, simScore: 0.9 }]);
await expect(service.readCandidatePairs('author', 0.7, scope)).resolves.toEqual([{ idA: 1, idB: 2, simScore: 0.9 }]);
expect(db.execute).toHaveBeenCalledTimes(1);
const query = flattenSql(db.execute.mock.calls[0]![0]).replace(/\s+/g, ' ');
expect(query).toContain('book_authors');
expect(query.match(/scoped_relation/g)?.length).toBeGreaterThanOrEqual(4);
});
it('returns an empty array when no rows exist', async () => {
const { service, db } = makeService();
db.execute.mockResolvedValue({ rows: [] });
await expect(service.readCandidatePairs('author', 0.7)).resolves.toEqual([]);
await expect(service.readCandidatePairs('author', 0.7, scope)).resolves.toEqual([]);
});
it.each([
['author', 'book_authors'],
['genre', 'book_genres'],
['tag', 'book_tags'],
['narrator', 'book_narrators'],
['series', 'book_series_memberships'],
] as const)('scopes %s candidates through %s', async (entityType, relationTable) => {
const { service, db } = makeService();
await service.readCandidatePairs(entityType, 0.7, scope);
expect(flattenSql(db.execute.mock.calls[0]![0])).toContain(relationTable);
});
it('returns no candidates without querying when no libraries are accessible', async () => {
const { service, db } = makeService();
await expect(service.readCandidatePairs('author', 0.7, { libraryIds: [] })).resolves.toEqual([]);
expect(db.execute).not.toHaveBeenCalled();
});
it('does not serve inline entity types from the global candidate cache', async () => {
const { service, db } = makeService();
await expect(service.readCandidatePairs('publisher', 0.7, scope)).resolves.toEqual([]);
expect(db.execute).not.toHaveBeenCalled();
});
});
});
@@ -2,17 +2,26 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { EntityType } from '@bookorbit/types';
import type { EntityType, FirstClassEntityType } from '@bookorbit/types';
import { INLINE_ENTITY_TYPES } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { entityDuplicateCandidates, entityDuplicateScanStatus } from '../../db/schema';
import type { EntityStrategy } from './strategies/entity-strategy.interface';
import { books, entityDuplicateCandidates, entityDuplicateScanStatus } from '../../db/schema';
import type { EntityBookScope, EntityStrategy } from './strategies/entity-strategy.interface';
type Db = NodePgDatabase<typeof schema>;
const BATCH_SIZE = 1000;
const ENTITY_BOOK_RELATIONS: Record<FirstClassEntityType, { table: string; entityIdColumn: string }> = {
author: { table: 'book_authors', entityIdColumn: 'author_id' },
genre: { table: 'book_genres', entityIdColumn: 'genre_id' },
tag: { table: 'book_tags', entityIdColumn: 'tag_id' },
narrator: { table: 'book_narrators', entityIdColumn: 'narrator_id' },
series: { table: 'book_series_memberships', entityIdColumn: 'series_id' },
};
@Injectable()
export class DuplicateComputeService {
private readonly logger = new Logger(DuplicateComputeService.name);
@@ -55,6 +64,7 @@ export class DuplicateComputeService {
async readCandidatePairs(
entityType: EntityType,
minSimilarity: number,
scope: EntityBookScope,
): Promise<
{
idA: number;
@@ -62,6 +72,23 @@ export class DuplicateComputeService {
simScore: number;
}[]
> {
if (scope.libraryIds.length === 0 || this.isInline(entityType)) return [];
const relation = ENTITY_BOOK_RELATIONS[entityType as FirstClassEntityType];
const relationTable = sql.raw(relation.table);
const relationEntityId = sql.raw(`scoped_relation.${relation.entityIdColumn}`);
const relationBookId = sql.raw('scoped_relation.book_id');
const scopeClauses = [
sql`${books.libraryId} IN (${sql.join(
scope.libraryIds.map((id) => sql`${id}`),
sql`, `,
)})`,
];
if (scope.contentFilters) {
scopeClauses.push(...buildContentFilterClauses(scope.contentFilters, this.db));
}
const bookScope = sql.join(scopeClauses, sql` AND `);
const rows = await this.db.execute<{ idA: number; idB: number; simScore: number }>(sql`
SELECT
c.entity_id_a AS "idA",
@@ -70,6 +97,20 @@ export class DuplicateComputeService {
FROM entity_duplicate_candidates c
WHERE c.entity_type = ${entityType}
AND c.sim_score >= ${minSimilarity}
AND EXISTS (
SELECT 1
FROM ${relationTable} scoped_relation
JOIN ${books} ON ${relationBookId} = ${books.id}
WHERE ${relationEntityId} = c.entity_id_a
AND ${bookScope}
)
AND EXISTS (
SELECT 1
FROM ${relationTable} scoped_relation
JOIN ${books} ON ${relationBookId} = ${books.id}
WHERE ${relationEntityId} = c.entity_id_b
AND ${bookScope}
)
AND NOT EXISTS (
SELECT 1 FROM dismissed_duplicate_pairs d
WHERE d.entity_type = ${entityType}
@@ -182,16 +182,16 @@ describe('EntityManagerController', () => {
describe('getEntityInfo', () => {
it('handles numeric entity ID', async () => {
const { controller, service } = makeController();
await controller.getEntityInfo('author', '42');
await controller.getEntityInfo('author', '42', mockUser);
expect(service.getEntityInfo).toHaveBeenCalledWith('author', 42);
expect(service.getEntityInfo).toHaveBeenCalledWith('author', mockUser, 42);
});
it('handles string entity ID for inline types', async () => {
const { controller, service } = makeController();
await controller.getEntityInfo('publisher', 'Test Publisher');
await controller.getEntityInfo('publisher', 'Test Publisher', mockUser);
expect(service.getEntityInfo).toHaveBeenCalledWith('publisher', 'Test Publisher');
expect(service.getEntityInfo).toHaveBeenCalledWith('publisher', mockUser, 'Test Publisher');
});
});
});
@@ -150,8 +150,8 @@ export class EntityManagerController {
@Get(':entityType/info/:entityId')
@RequirePermission(Permission.LibraryEditMetadata)
getEntityInfo(@Param('entityType', EntityTypePipe) entityType: EntityType, @Param('entityId') entityId: string) {
getEntityInfo(@Param('entityType', EntityTypePipe) entityType: EntityType, @Param('entityId') entityId: string, @CurrentUser() user: RequestUser) {
const parsedId = isInline(entityType) ? entityId : Number(entityId);
return this.service.getEntityInfo(entityType, parsedId);
return this.service.getEntityInfo(entityType, user, parsedId);
}
}
@@ -128,7 +128,7 @@ describe('EntityManagerService', () => {
await service.scanDuplicates('publisher', mockUser, 2);
expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([2], 0.5);
expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([2], 0.5, EMPTY_CONTENT_FILTER_RULES);
});
it('throws when library not accessible', async () => {
@@ -142,7 +142,7 @@ describe('EntityManagerService', () => {
const { service, strategies } = makeService();
await service.scanDuplicates('publisher', mockUser, undefined, 0.8);
expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([1, 2], 0.8);
expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([1, 2], 0.8, EMPTY_CONTENT_FILTER_RULES);
});
it('filters out dismissed pairs for inline entities', async () => {
@@ -298,13 +298,43 @@ describe('EntityManagerService', () => {
const result = await service.scanDuplicates('author', mockUser);
expect(duplicateCompute.readCandidatePairs).toHaveBeenCalledWith('author', 0.5);
expect(duplicateCompute.readCandidatePairs).toHaveBeenCalledWith('author', 0.5, {
libraryIds: [1, 2],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
expect(repo.getDismissedPairSet).not.toHaveBeenCalled();
expect(strategies.author.findCandidatePairs).not.toHaveBeenCalled();
expect(result.clusters).toHaveLength(1);
expect(result.clusters[0]!.entities).toHaveLength(3);
});
it('uses only the explicitly requested accessible library for stored candidates and enrichment', async () => {
const { service, duplicateCompute, libraryService, strategies } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([4, 7]);
duplicateCompute.readCandidatePairs.mockResolvedValue([{ idA: 1, idB: 2, simScore: 0.9 }]);
(strategies.author.findEntityById as any).mockImplementation((id: number) => Promise.resolve({ id, name: `Author ${id}` }));
await service.scanDuplicates('author', mockUser, 7);
const scope = { libraryIds: [7], contentFilters: EMPTY_CONTENT_FILTER_RULES };
expect(duplicateCompute.readCandidatePairs).toHaveBeenCalledWith('author', 0.5, scope);
expect(strategies.author.getBookCount).toHaveBeenCalledWith(1, scope);
expect(strategies.author.getBookCount).toHaveBeenCalledWith(2, scope);
expect(strategies.author.getBookTitles).toHaveBeenCalledWith(1, 5, scope);
expect(strategies.author.getBookTitles).toHaveBeenCalledWith(2, 5, scope);
});
it('does not query candidates when the user has no accessible libraries', async () => {
const { service, duplicateCompute, libraryService, strategies } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
await expect(service.scanDuplicates('author', mockUser)).resolves.toMatchObject({ clusters: [], total: 0, totalEntities: 0 });
expect(duplicateCompute.readCandidatePairs).not.toHaveBeenCalled();
expect(duplicateCompute.triggerCompute).not.toHaveBeenCalled();
expect(strategies.author.findCandidatePairs).not.toHaveBeenCalled();
});
it('triggers duplicate compute with the requested threshold when no status exists', async () => {
const { service, duplicateCompute, strategies } = makeService();
@@ -767,7 +797,7 @@ describe('EntityManagerService', () => {
const result = await service.split('author', mockUser, 5, ['Author A', 'Author B'], false);
expect(strategies.author.split).toHaveBeenCalledWith({ entityId: 5, newNames: ['Author A', 'Author B'] });
expect(strategies.author.split).toHaveBeenCalledWith({ entityId: 5, newNames: ['Author A', 'Author B'], libraryIds: [1, 2] });
expect(repo.deleteDismissedPairsForEntity).toHaveBeenCalledWith('author', 5);
expect(result).toEqual({
originalId: 5,
@@ -887,16 +917,37 @@ describe('EntityManagerService', () => {
(strategies.author.getBookCount as any).mockResolvedValue(5);
(strategies.author.getBookTitles as any).mockResolvedValue(['Book 1', 'Book 2']);
const result = await service.getEntityInfo('author', 1);
const result = await service.getEntityInfo('author', mockUser, 1);
expect(result).toEqual({ id: 1, name: 'Author A', bookCount: 5, bookTitles: ['Book 1', 'Book 2'] });
expect(strategies.author.getBookCount).toHaveBeenCalledWith(1, {
libraryIds: [1, 2],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
expect(strategies.author.getBookTitles).toHaveBeenCalledWith(1, 5, {
libraryIds: [1, 2],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
});
it('throws NotFoundException when entity not found', async () => {
const { service, strategies } = makeService();
(strategies.author.findEntityById as any).mockResolvedValue(null);
await expect(service.getEntityInfo('author', 999)).rejects.toThrow(NotFoundException);
await expect(service.getEntityInfo('author', mockUser, 999)).rejects.toThrow(NotFoundException);
});
it('does not expose an entity that has books only outside the accessible scope', async () => {
const { service, strategies } = makeService();
(strategies.author.findEntityById as any).mockResolvedValue({ id: 1, name: 'Hidden Author' });
(strategies.author.getBookCount as any).mockResolvedValueOnce(4).mockResolvedValueOnce(0);
await expect(service.getEntityInfo('author', mockUser, 1)).rejects.toThrow(NotFoundException);
expect(strategies.author.getBookTitles).toHaveBeenCalledWith(1, 5, {
libraryIds: [1, 2],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
});
});
@@ -20,7 +20,7 @@ import { FileWriteService } from '../file-write/file-write.service';
import { LibraryService } from '../library/library.service';
import { EntityManagerRepository } from './entity-manager.repository';
import { DuplicateComputeService } from './duplicate-compute.service';
import type { EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface';
import type { EntityBookScope, EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface';
import { AuthorStrategy } from './strategies/author.strategy';
import { GenreStrategy } from './strategies/genre.strategy';
import { TagStrategy } from './strategies/tag.strategy';
@@ -89,23 +89,26 @@ export class EntityManagerService {
try {
const strategy = this.getStrategy(entityType);
const similarity = minSimilarity ?? DEFAULT_MIN_SIMILARITY;
const allLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
if (libraryId && !allLibraryIds.includes(libraryId)) throw new BadRequestException('Library not accessible');
if (libraryId) {
const allLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
if (!allLibraryIds.includes(libraryId)) throw new BadRequestException('Library not accessible');
}
const libraryIds = libraryId ? [libraryId] : allLibraryIds;
const bookScope: EntityBookScope = {
libraryIds,
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
};
let rawPairs: RawCandidatePair[];
if (strategy.isInline) {
const allLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const libraryIds = libraryId ? allLibraryIds.filter((id) => id === libraryId) : allLibraryIds;
rawPairs = await strategy.findCandidatePairs(libraryIds, similarity);
if (libraryIds.length === 0) {
rawPairs = [];
} else if (strategy.isInline) {
rawPairs = await strategy.findCandidatePairs(libraryIds, similarity, bookScope.contentFilters);
const dismissedSet = await this.repo.getInlineDismissedPairSet(entityType);
rawPairs = rawPairs.filter((pair) => !dismissedSet.has(`${pair.idA}:${pair.idB}`));
} else {
const storedPairs = await this.duplicateCompute.readCandidatePairs(entityType, similarity);
const storedPairs = await this.duplicateCompute.readCandidatePairs(entityType, similarity, bookScope);
if (storedPairs.length === 0) {
const status = await this.duplicateCompute.getStatus(entityType);
@@ -135,7 +138,7 @@ export class EntityManagerService {
const offset = (clampedPage - 1) * pageSize;
const pageSlice = ranked.slice(offset, offset + pageSize);
const enrichedClusters = await this.enrichClusterSlice(strategy, pageSlice, rawPairs);
const enrichedClusters = await this.enrichClusterSlice(strategy, pageSlice, rawPairs, bookScope);
this.logger.log(
`[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} total=${total} page=${clampedPage} clusters=${enrichedClusters.length} - duplicate scan completed`,
@@ -392,7 +395,8 @@ export class EntityManagerService {
throw new BadRequestException('Split requires at least 2 new names');
}
const result = await strategy.split({ entityId, newNames });
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const result = await strategy.split({ entityId, newNames, libraryIds });
await this.repo.deleteDismissedPairsForEntity(entityType, entityId);
@@ -491,12 +495,22 @@ export class EntityManagerService {
return result;
}
async getEntityInfo(entityType: EntityType, entityId: number | string): Promise<EntityInfo> {
async getEntityInfo(entityType: EntityType, user: RequestUser, entityId: number | string): Promise<EntityInfo> {
const strategy = this.getStrategy(entityType);
const entity = await strategy.findEntityById(entityId);
if (!entity) throw new NotFoundException(`${entityType} not found`);
const [bookCount, bookTitles] = await Promise.all([strategy.getBookCount(entityId), strategy.getBookTitles(entityId, BOOK_TITLES_LIMIT)]);
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const bookScope: EntityBookScope = {
libraryIds,
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
};
const [globalBookCount, bookCount, bookTitles] = await Promise.all([
strategy.getBookCount(entityId),
strategy.getBookCount(entityId, bookScope),
strategy.getBookTitles(entityId, BOOK_TITLES_LIMIT, bookScope),
]);
if (globalBookCount > 0 && bookCount === 0) throw new NotFoundException(`${entityType} not found`);
return { id: entity.id, name: entity.name, bookCount, bookTitles };
}
@@ -607,6 +621,7 @@ export class EntityManagerService {
strategy: EntityStrategy,
rankedSlice: Array<{ memberIds: Set<string>; avgSimilarity: number }>,
pairs: RawCandidatePair[],
bookScope: EntityBookScope,
): Promise<DuplicateCluster[]> {
const enriched: DuplicateCluster[] = [];
let clusterIndex = 0;
@@ -617,8 +632,8 @@ export class EntityManagerService {
const parsedId = strategy.isInline ? id : Number(id);
const [entity, bookCount, bookTitles] = await Promise.all([
strategy.findEntityById(parsedId),
strategy.getBookCount(parsedId),
strategy.getBookTitles(parsedId, BOOK_TITLES_LIMIT),
strategy.getBookCount(parsedId, bookScope),
strategy.getBookTitles(parsedId, BOOK_TITLES_LIMIT, bookScope),
]);
return entity
? { id: entity.id, name: entity.name, bookCount, bookTitles, sortName: (entity as any).sortName, hasPhoto: (entity as any).hasPhoto }
@@ -1,4 +1,4 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { vi } from 'vitest';
import { AuthorStrategy } from './author.strategy';
@@ -25,7 +25,8 @@ function makeStrategy(db: Record<string, unknown> = {}, deps: Record<string, unk
schedule: vi.fn().mockResolvedValue(undefined),
...(deps.enrichmentOrchestrator as object | undefined),
};
return new AuthorStrategy(db as never, authorsRepo as never, authorImageStorage as never, enrichmentOrchestrator as never);
const resolvedDb = { execute: vi.fn().mockResolvedValue({ rows: [] }), ...db };
return new AuthorStrategy(resolvedDb as never, authorsRepo as never, authorImageStorage as never, enrichmentOrchestrator as never);
}
function makeSelectChain(rows: unknown[]) {
@@ -234,6 +235,16 @@ describe('AuthorStrategy', () => {
return { select, selectDistinct, update };
}
it('rejects merging authors that are used outside the accessible libraries', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] });
const authorsRepo = { mergeAuthors: vi.fn() };
const strategy = makeStrategy({ execute }, { authorsRepo });
await expect(strategy.merge({ targetId: 1, sourceIds: [2], userId: 5, libraryIds: [7] })).rejects.toBeInstanceOf(ForbiddenException);
expect(authorsRepo.mergeAuthors).not.toHaveBeenCalled();
});
it('throws NotFoundException when target author does not exist', async () => {
const limit = vi.fn().mockResolvedValue([]);
const where = vi.fn().mockReturnValue({ limit });
@@ -799,6 +810,19 @@ describe('AuthorStrategy', () => {
await expect(strategy.getBookCount(1)).resolves.toBe(0);
});
it('joins books and applies the requested library scope', async () => {
const where = vi.fn().mockResolvedValue([{ count: 2 }]);
const innerJoin = vi.fn().mockReturnValue({ where });
const from = vi.fn().mockReturnValue({ innerJoin });
const select = vi.fn().mockReturnValue({ from });
const strategy = makeStrategy({ select });
await expect(strategy.getBookCount(1, { libraryIds: [7, 9] })).resolves.toBe(2);
expect(innerJoin).toHaveBeenCalledTimes(1);
expect(flattenSql(where.mock.calls[0]![0])).toMatch(/\bin\b/);
});
});
describe('getBookTitles', () => {
@@ -815,6 +839,21 @@ describe('AuthorStrategy', () => {
await expect(strategy.getBookTitles(1, 5)).resolves.toEqual(['The Hobbit', 'LOTR']);
});
it('applies the same library scope when loading preview titles', async () => {
const limit = vi.fn().mockResolvedValue([{ title: 'Visible Book' }]);
const orderBy = vi.fn().mockReturnValue({ limit });
const where = vi.fn().mockReturnValue({ orderBy });
const leftJoin = vi.fn().mockReturnValue({ where });
const innerJoin = vi.fn().mockReturnValue({ leftJoin });
const from = vi.fn().mockReturnValue({ innerJoin });
const select = vi.fn().mockReturnValue({ from });
const strategy = makeStrategy({ select });
await expect(strategy.getBookTitles(1, 5, { libraryIds: [7] })).resolves.toEqual(['Visible Book']);
expect(flattenSql(where.mock.calls[0]![0])).toMatch(/\bin\b/);
});
});
describe('findEntityById', () => {
@@ -30,7 +30,9 @@ import type {
StrategyMergeResult,
StrategyRenameResult,
StrategySplitResult,
EntityBookScope,
} from './entity-strategy.interface';
import { assertEntityRelationsWithinLibraries, buildEntityBookScopeClauses } from './entity-book-scope';
type Db = NodePgDatabase<typeof schema>;
@@ -216,6 +218,8 @@ export class AuthorStrategy implements EntityStrategy {
const sourceIds = input.sourceIds as number[];
const fieldsResolved: string[] = [];
await this.assertMutationScope([targetId, ...sourceIds], input.libraryIds ?? []);
const targetAuthor = await this.db
.select({ id: authors.id, sortName: authors.sortName, description: authors.description, hasPhoto: authors.hasPhoto })
.from(authors)
@@ -276,6 +280,7 @@ export class AuthorStrategy implements EntityStrategy {
async rename(input: RenameInput): Promise<StrategyRenameResult> {
const entityId = input.entityId as number;
await this.assertMutationScope([entityId], input.libraryIds);
const [entity] = await this.db.select({ name: authors.name }).from(authors).where(eq(authors.id, entityId)).limit(1);
if (!entity) throw new NotFoundException('Author not found');
@@ -290,7 +295,12 @@ export class AuthorStrategy implements EntityStrategy {
.where(eq(NORMALIZED_AUTHOR_NAME_SQL, normalizedName));
const mergeTarget = this.selectPreferredAuthorMatch(existingRows, entityId, displayName);
if (mergeTarget) {
const mergeResult = await this.merge({ targetId: mergeTarget.id, sourceIds: [entityId], userId: input.userId });
const mergeResult = await this.merge({
targetId: mergeTarget.id,
sourceIds: [entityId],
userId: input.userId,
libraryIds: input.libraryIds ?? [],
});
return { oldName, affectedBookIds: mergeResult.affectedBookIds, wasImplicitMerge: true, mergedEntityId: mergeTarget.id };
}
@@ -301,6 +311,7 @@ export class AuthorStrategy implements EntityStrategy {
async deleteEntity(input: DeleteInput): Promise<StrategyDeleteResult> {
const entityId = input.entityId as number;
await this.assertMutationScope([entityId], input.libraryIds);
const [entity] = await this.db.select({ name: authors.name }).from(authors).where(eq(authors.id, entityId)).limit(1);
if (!entity) throw new NotFoundException('Author not found');
@@ -318,6 +329,7 @@ export class AuthorStrategy implements EntityStrategy {
}
async split(input: SplitInput): Promise<StrategySplitResult> {
await this.assertMutationScope([input.entityId], input.libraryIds ?? []);
const [entity] = await this.db.select({ name: authors.name }).from(authors).where(eq(authors.id, input.entityId)).limit(1);
if (!entity) throw new NotFoundException('Author not found');
@@ -379,6 +391,10 @@ export class AuthorStrategy implements EntityStrategy {
return chooseCanonicalMetadataTextRow(rows, { desiredName: displayName, excludedId: excludedAuthorId });
}
private assertMutationScope(entityIds: number[], libraryIds: number[]): Promise<void> {
return assertEntityRelationsWithinLibraries(this.db, 'book_authors', 'author_id', entityIds, libraryIds);
}
async findAffectedBookIds(ids: (number | string)[]): Promise<number[]> {
const numericIds = ids as number[];
if (numericIds.length === 0) return [];
@@ -386,7 +402,16 @@ export class AuthorStrategy implements EntityStrategy {
return rows.map((r) => r.bookId);
}
async getBookCount(id: number | string): Promise<number> {
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
if (scope) {
const [row] = await this.db
.select({ count: count() })
.from(bookAuthors)
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.where(and(eq(bookAuthors.authorId, id as number), ...buildEntityBookScopeClauses(this.db, scope)));
return row?.count ?? 0;
}
const [row] = await this.db
.select({ count: count() })
.from(bookAuthors)
@@ -394,13 +419,13 @@ export class AuthorStrategy implements EntityStrategy {
return row?.count ?? 0;
}
async getBookTitles(id: number | string, limit: number): Promise<string[]> {
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
const rows = await this.db
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
.from(bookAuthors)
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(eq(bookAuthors.authorId, id as number))
.where(and(eq(bookAuthors.authorId, id as number), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : [])))
.orderBy(asc(bookMetadata.title))
.limit(limit);
return rows.map((r) => r.title);
@@ -0,0 +1,50 @@
import { ForbiddenException } from '@nestjs/common';
import { assertEntityRelationsWithinLibraries } from './entity-book-scope';
function flattenSql(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(flattenSql).join(' ');
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' ');
}
describe('assertEntityRelationsWithinLibraries', () => {
it('allows a mutation when no entity relations exist outside the accessible libraries', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
await expect(assertEntityRelationsWithinLibraries({ execute } as never, 'book_authors', 'author_id', [10, 20], [3, 4])).resolves.toBeUndefined();
const query = flattenSql(execute.mock.calls[0]![0]).replace(/\s+/g, ' ');
expect(query).toContain('book_authors');
expect(query).toContain('scoped_relation.author_id');
expect(query).toContain('NOT IN');
});
it('rejects a mutation when any entity relation belongs to an inaccessible library', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] });
await expect(assertEntityRelationsWithinLibraries({ execute } as never, 'book_tags', 'tag_id', [7], [2])).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('treats every existing relation as inaccessible when the user has no libraries', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] });
await expect(assertEntityRelationsWithinLibraries({ execute } as never, 'book_genres', 'genre_id', [5], [])).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(flattenSql(execute.mock.calls[0]![0])).toContain('true');
});
it('skips the database query when there are no entity IDs', async () => {
const execute = vi.fn();
await assertEntityRelationsWithinLibraries({ execute } as never, 'book_authors', 'author_id', [], [1]);
expect(execute).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import { ForbiddenException } from '@nestjs/common';
import { inArray, sql, type SQL } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils';
import * as schema from '../../../db/schema';
import { books } from '../../../db/schema';
import type { EntityBookScope } from './entity-strategy.interface';
type Db = NodePgDatabase<typeof schema>;
export function buildEntityBookScopeClauses(db: Db, scope: EntityBookScope): SQL[] {
if (scope.libraryIds.length === 0) return [sql`false`];
return [inArray(books.libraryId, scope.libraryIds), ...(scope.contentFilters ? buildContentFilterClauses(scope.contentFilters, db) : [])];
}
export async function assertEntityRelationsWithinLibraries(
db: Db,
relationTableName: string,
relationEntityIdColumn: string,
entityIds: number[],
libraryIds: number[],
): Promise<void> {
if (entityIds.length === 0) return;
const relationTable = sql.raw(relationTableName);
const relationEntityId = sql.raw(`scoped_relation.${relationEntityIdColumn}`);
const relationBookId = sql.raw('scoped_relation.book_id');
const entityIdList = sql.join(
entityIds.map((id) => sql`${id}`),
sql`, `,
);
const outsideLibraryCondition =
libraryIds.length > 0
? sql`${books.libraryId} NOT IN (${sql.join(
libraryIds.map((id) => sql`${id}`),
sql`, `,
)})`
: sql`true`;
const result = await db.execute(sql`
SELECT 1
FROM ${relationTable} scoped_relation
JOIN ${books} ON ${relationBookId} = ${books.id}
WHERE ${relationEntityId} IN (${entityIdList})
AND ${outsideLibraryCondition}
LIMIT 1
`);
if (result.rows.length > 0) {
throw new ForbiddenException('Entity is used by a library outside the accessible scope');
}
}
@@ -1,4 +1,9 @@
import type { BrowseEntityBookCountFilter, BrowseEntitySortBy, BrowseEntitySortOrder, EntityType } from '@bookorbit/types';
import type { BrowseEntityBookCountFilter, BrowseEntitySortBy, BrowseEntitySortOrder, ContentFilterRules, EntityType } from '@bookorbit/types';
export interface EntityBookScope {
libraryIds: number[];
contentFilters?: ContentFilterRules;
}
export interface RawCandidatePair {
idA: number | string;
@@ -69,6 +74,7 @@ export interface StrategyDeleteResult {
export interface SplitInput {
entityId: number;
newNames: string[];
libraryIds?: number[];
}
export interface StrategySplitResult {
@@ -81,7 +87,7 @@ export interface EntityStrategy {
readonly entityType: EntityType;
readonly isInline: boolean;
findCandidatePairs(libraryIds: number[], minSimilarity: number): Promise<RawCandidatePair[]>;
findCandidatePairs(libraryIds: number[], minSimilarity: number, contentFilters?: ContentFilterRules): Promise<RawCandidatePair[]>;
getAllEntityIds?(): Promise<number[]>;
@@ -95,7 +101,7 @@ export interface EntityStrategy {
split(input: SplitInput): Promise<StrategySplitResult>;
findAffectedBookIds(ids: (number | string)[]): Promise<number[]>;
getBookCount(id: number | string): Promise<number>;
getBookTitles(id: number | string, limit: number): Promise<string[]>;
getBookCount(id: number | string, scope?: EntityBookScope): Promise<number>;
getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]>;
findEntityById(id: number | string): Promise<{ id: number | string; name: string } | null>;
}
@@ -12,6 +12,14 @@ function makeStrategy(db: Record<string, unknown> = {}) {
return new TestInlineStrategy(db as never);
}
function flattenSql(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(flattenSql).join(' ');
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' ');
}
function makeSelectChain(rows: unknown[]) {
const limit = vi.fn().mockResolvedValue(rows);
const orderBy = vi.fn().mockReturnValue({ limit });
@@ -64,6 +72,25 @@ describe('InlineEntityStrategy', () => {
expect(Number(capturedThreshold)).toBe(1);
});
it('applies content filters to both candidate values', async () => {
const execute = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce({ rows: [] });
const transaction = vi.fn().mockImplementation(async (cb: (tx: { execute: typeof execute }) => Promise<unknown>) => cb({ execute }));
const where = vi.fn().mockReturnValue({ queryChunks: ['book_tags'] });
const from = vi.fn().mockReturnValue({ where });
const strategy = makeStrategy({ transaction, select: vi.fn().mockReturnValue({ from }) });
await strategy.findCandidatePairs([1], 0.5, {
includeTagIds: [9],
excludeTagIds: [],
includeGenreIds: [],
excludeGenreIds: [],
});
const query = flattenSql(execute.mock.calls[1]![0]);
expect(query).toContain('book_tags');
expect(query.match(/book_tags/g)?.length).toBeGreaterThanOrEqual(2);
});
});
describe('browse', () => {
@@ -93,11 +120,13 @@ describe('InlineEntityStrategy', () => {
describe('merge', () => {
it('calls execute once per source value and returns affected book ids', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
const execute = vi
.fn()
.mockResolvedValueOnce({ rows: [{ bookId: 10 }, { bookId: 20 }] })
.mockResolvedValueOnce({ rows: [{ bookId: 20 }] });
const strategy = makeStrategy({ execute });
vi.spyOn(strategy, 'findAffectedBookIds').mockResolvedValue([10, 20]);
const result = await strategy.merge({ targetId: 'Alice', sourceIds: ['Alicia', 'Ali'], userId: 1 });
const result = await strategy.merge({ targetId: 'Alice', sourceIds: ['Alicia', 'Ali'], userId: 1, libraryIds: [7] });
expect(execute).toHaveBeenCalledTimes(2);
expect(result).toEqual({ affectedBookIds: [10, 20] });
@@ -106,9 +135,19 @@ describe('InlineEntityStrategy', () => {
it('does nothing when sourceIds is empty', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
const strategy = makeStrategy({ execute });
vi.spyOn(strategy, 'findAffectedBookIds').mockResolvedValue([]);
await strategy.merge({ targetId: 'Alice', sourceIds: [], userId: 1 });
await strategy.merge({ targetId: 'Alice', sourceIds: [], userId: 1, libraryIds: [7] });
expect(execute).not.toHaveBeenCalled();
});
it('does not update metadata when no libraries are accessible', async () => {
const execute = vi.fn();
const strategy = makeStrategy({ execute });
await expect(strategy.merge({ targetId: 'Alice', sourceIds: ['Alicia'], userId: 1, libraryIds: [] })).resolves.toEqual({
affectedBookIds: [],
});
expect(execute).not.toHaveBeenCalled();
});
@@ -1,12 +1,13 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { SQL, asc, count, eq, sql } from 'drizzle-orm';
import { SQL, and, asc, count, eq, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { InlineEntityType } from '@bookorbit/types';
import type { ContentFilterRules, InlineEntityType } from '@bookorbit/types';
import { DB } from '../../../db';
import { accentInsensitiveIlike } from '../../../common/utils/accent-insensitive-search.utils';
import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils';
import * as schema from '../../../db/schema';
import { bookMetadata } from '../../../db/schema';
import { bookMetadata, books } from '../../../db/schema';
import type {
BrowseParams,
BrowseResult,
@@ -20,7 +21,9 @@ import type {
StrategyMergeResult,
StrategyRenameResult,
StrategySplitResult,
EntityBookScope,
} from './entity-strategy.interface';
import { buildEntityBookScopeClauses } from './entity-book-scope';
type Db = NodePgDatabase<typeof schema>;
@@ -50,17 +53,29 @@ export abstract class InlineEntityStrategy implements EntityStrategy {
return sql`${sql.raw(`${alias}.${this.rawFieldName}`)} = ${value}`;
}
async findCandidatePairs(libraryIds: number[], minSimilarity: number): Promise<RawCandidatePair[]> {
async findCandidatePairs(libraryIds: number[], minSimilarity: number, contentFilters?: ContentFilterRules): Promise<RawCandidatePair[]> {
const similarityThreshold = Math.max(0.1, Math.min(1, minSimilarity));
const f = sql.raw(this.rawFieldName);
const hasLibraryFilter = libraryIds.length > 0;
const idsLiteral = hasLibraryFilter ? sql.raw(`(${libraryIds.join(',')})`) : sql``;
const contentFilterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const contentFilterSql = contentFilterClauses.length > 0 ? sql`AND ${sql.join(contentFilterClauses, sql` AND `)}` : sql``;
const v1LibraryFilter = hasLibraryFilter
? sql`AND EXISTS (SELECT 1 FROM books b1 WHERE b1.id = bm1.book_id AND b1.library_id IN ${idsLiteral})`
? sql`AND EXISTS (
SELECT 1 FROM ${books}
WHERE ${books.id} = bm1.book_id
AND ${books.libraryId} IN ${idsLiteral}
${contentFilterSql}
)`
: sql``;
const v2LibraryFilter = hasLibraryFilter
? sql`AND EXISTS (SELECT 1 FROM books b2 WHERE b2.id = bm2.book_id AND b2.library_id IN ${idsLiteral})`
? sql`AND EXISTS (
SELECT 1 FROM ${books}
WHERE ${books.id} = bm2.book_id
AND ${books.libraryId} IN ${idsLiteral}
${contentFilterSql}
)`
: sql``;
const rows = await this.db.transaction(async (tx) => {
@@ -141,19 +156,29 @@ export abstract class InlineEntityStrategy implements EntityStrategy {
async merge(input: MergeInput): Promise<StrategyMergeResult> {
const targetValue = input.targetId as string;
const sourceValues = input.sourceIds as string[];
const libraryIds = input.libraryIds ?? [];
const affectedBookIds = await this.findAffectedBookIds(sourceValues);
if (sourceValues.length === 0 || libraryIds.length === 0) return { affectedBookIds: [] };
const libraryIdList = sql.join(
libraryIds.map((id) => sql`${id}`),
sql`, `,
);
const affectedBookIds = new Set<number>();
for (const sourceValue of sourceValues) {
await this.db.execute(sql`
const affectedRows = await this.db.execute<{ bookId: number }>(sql`
UPDATE book_metadata bm SET ${sql.raw(this.rawFieldName)} = ${targetValue}
FROM books b
WHERE bm.book_id = b.id
AND b.library_id IN (${libraryIdList})
AND bm.${sql.raw(this.rawFieldName)} = ${sourceValue}
RETURNING bm.book_id AS "bookId"
`);
for (const row of affectedRows.rows) affectedBookIds.add(row.bookId);
}
return { affectedBookIds };
return { affectedBookIds: [...affectedBookIds] };
}
async rename(input: RenameInput): Promise<StrategyRenameResult> {
@@ -161,6 +186,10 @@ export abstract class InlineEntityStrategy implements EntityStrategy {
const trimmed = this.normalizeInputValue(input.newName);
if (!trimmed) throw new BadRequestException('Name cannot be empty');
if (input.libraryIds.length === 0) {
return { oldName: currentValue, affectedBookIds: [], wasImplicitMerge: false };
}
const idsLiteral = sql.raw(`(${input.libraryIds.join(',')})`);
const existingCount = await this.db.execute<{ cnt: number }>(sql`
@@ -192,6 +221,8 @@ export abstract class InlineEntityStrategy implements EntityStrategy {
async deleteEntity(input: DeleteInput): Promise<StrategyDeleteResult> {
const value = input.entityId as string;
if (input.libraryIds.length === 0) return { name: value, affectedBookIds: [] };
const idsLiteral = sql.raw(`(${input.libraryIds.join(',')})`);
const affectedRows = await this.db.execute<{ bookId: number }>(sql`
@@ -227,18 +258,27 @@ export abstract class InlineEntityStrategy implements EntityStrategy {
return rows.rows.map((r) => r.bookId);
}
async getBookCount(id: number | string): Promise<number> {
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
const value = id as string;
if (scope) {
const [row] = await this.db
.select({ count: count() })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(eq(this.field, value), ...buildEntityBookScopeClauses(this.db, scope)));
return row?.count ?? 0;
}
const [row] = await this.db.select({ count: count() }).from(bookMetadata).where(eq(this.field, value));
return row?.count ?? 0;
}
async getBookTitles(id: number | string, limit: number): Promise<string[]> {
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
const value = id as string;
const rows = await this.db
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
.from(bookMetadata)
.where(eq(this.field, value))
const query = this.db.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` }).from(bookMetadata);
const scopedQuery = scope ? query.innerJoin(books, eq(books.id, bookMetadata.bookId)) : query;
const rows = await scopedQuery
.where(and(eq(this.field, value), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : [])))
.orderBy(asc(bookMetadata.title))
.limit(limit);
return rows.map((r) => r.title);
@@ -1,9 +1,9 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { TagStrategy } from './tag.strategy';
function makeStrategy(db: Record<string, unknown> = {}) {
return new TagStrategy(db as never);
return new TagStrategy({ execute: vi.fn().mockResolvedValue({ rows: [] }), ...db } as never);
}
function makeSelectChain(rows: unknown[]) {
@@ -223,6 +223,16 @@ describe('TagStrategy (JunctionEntityStrategy)', () => {
});
describe('merge', () => {
it('rejects merging entities that are used outside the accessible libraries', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] });
const strategy = makeStrategy({ execute });
vi.spyOn(strategy, 'findEntityById');
await expect(strategy.merge({ targetId: 5, sourceIds: [1], userId: 1, libraryIds: [2] })).rejects.toBeInstanceOf(ForbiddenException);
expect(strategy.findEntityById).not.toHaveBeenCalled();
});
it('throws NotFoundException when target entity is not found', async () => {
const { select } = makeSelectChain([]);
const strategy = makeStrategy({ select });
@@ -21,7 +21,9 @@ import type {
StrategyMergeResult,
StrategyRenameResult,
StrategySplitResult,
EntityBookScope,
} from './entity-strategy.interface';
import { assertEntityRelationsWithinLibraries, buildEntityBookScopeClauses } from './entity-book-scope';
type Db = NodePgDatabase<typeof schema>;
@@ -194,6 +196,8 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
const targetId = input.targetId as number;
const sourceIds = input.sourceIds as number[];
await this.assertMutationScope([targetId, ...sourceIds], input.libraryIds ?? []);
const target = await this.findEntityById(targetId);
if (!target) throw new NotFoundException(`${this.entityType} not found`);
@@ -221,6 +225,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
async rename(input: RenameInput): Promise<StrategyRenameResult> {
const entityId = input.entityId as number;
await this.assertMutationScope([entityId], input.libraryIds);
const [entity] = await this.db.select({ name: this.nameCol }).from(this.entityTable).where(eq(this.entityIdCol, entityId)).limit(1);
if (!entity) throw new NotFoundException(`${this.entityType} not found`);
@@ -231,7 +236,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
const [existing] = await this.db.select({ id: this.entityIdCol }).from(this.entityTable).where(eq(this.nameCol, trimmed)).limit(1);
if (existing && (existing as any).id !== entityId) {
const existingId = (existing as any).id as number;
const mergeResult = await this.merge({ targetId: existingId, sourceIds: [entityId], userId: input.userId });
const mergeResult = await this.merge({ targetId: existingId, sourceIds: [entityId], userId: input.userId, libraryIds: input.libraryIds ?? [] });
return { oldName, affectedBookIds: mergeResult.affectedBookIds, wasImplicitMerge: true, mergedEntityId: existingId };
}
@@ -242,6 +247,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
async deleteEntity(input: DeleteInput): Promise<StrategyDeleteResult> {
const entityId = input.entityId as number;
await this.assertMutationScope([entityId], input.libraryIds);
const [entity] = await this.db.select({ name: this.nameCol }).from(this.entityTable).where(eq(this.entityIdCol, entityId)).limit(1);
if (!entity) throw new NotFoundException(`${this.entityType} not found`);
@@ -264,6 +270,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
}
async split(input: SplitInput): Promise<StrategySplitResult> {
await this.assertMutationScope([input.entityId], input.libraryIds ?? []);
const [entity] = await this.db.select({ name: this.nameCol }).from(this.entityTable).where(eq(this.entityIdCol, input.entityId)).limit(1);
if (!entity) throw new NotFoundException(`${this.entityType} not found`);
@@ -313,7 +320,16 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
return rows.map((r: any) => r.bookId);
}
async getBookCount(id: number | string): Promise<number> {
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
if (scope) {
const [row] = await this.db
.select({ count: count() })
.from(this.junctionTable)
.innerJoin(books, eq(books.id, this.junctionBookIdCol))
.where(and(eq(this.junctionEntityIdCol, id as number), ...buildEntityBookScopeClauses(this.db, scope)));
return row?.count ?? 0;
}
const [row] = await this.db
.select({ count: count() })
.from(this.junctionTable)
@@ -321,13 +337,13 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
return row?.count ?? 0;
}
async getBookTitles(id: number | string, limit: number): Promise<string[]> {
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
const rows = await this.db
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
.from(this.junctionTable)
.innerJoin(books, eq(books.id, this.junctionBookIdCol))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(eq(this.junctionEntityIdCol, id as number))
.where(and(eq(this.junctionEntityIdCol, id as number), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : [])))
.orderBy(asc(bookMetadata.title))
.limit(limit);
return rows.map((r) => r.title);
@@ -344,4 +360,8 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
}
protected abstract buildJunctionRow(bookId: number, entityId: number): Record<string, unknown>;
private assertMutationScope(entityIds: number[], libraryIds: number[]): Promise<void> {
return assertEntityRelationsWithinLibraries(this.db, this.rawJunctionTable, this.rawEntityIdCol, entityIds, libraryIds);
}
}
@@ -1,11 +1,12 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, eq, sql } from 'drizzle-orm';
import { and, asc, eq, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
import { bookMetadata, bookNarrators, books, narrators } from '../../../db/schema';
import type { BrowseParams, BrowseResult } from './entity-strategy.interface';
import type { BrowseParams, BrowseResult, EntityBookScope } from './entity-strategy.interface';
import { buildEntityBookScopeClauses } from './entity-book-scope';
import { JunctionEntityStrategy } from './junction-entity.strategy';
type Db = NodePgDatabase<typeof schema>;
@@ -49,13 +50,13 @@ export class NarratorStrategy extends JunctionEntityStrategy {
};
}
override async getBookTitles(id: number | string, limit: number): Promise<string[]> {
override async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
const rows = await this.db
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
.from(bookNarrators)
.innerJoin(books, eq(books.id, bookNarrators.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(eq(bookNarrators.narratorId, id as number))
.where(and(eq(bookNarrators.narratorId, id as number), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : [])))
.orderBy(asc(bookMetadata.title))
.limit(limit);
return rows.map((r) => r.title);
@@ -20,7 +20,9 @@ import type {
StrategyMergeResult,
StrategyRenameResult,
StrategySplitResult,
EntityBookScope,
} from './entity-strategy.interface';
import { buildEntityBookScopeClauses } from './entity-book-scope';
type Db = NodePgDatabase<typeof schema>;
@@ -267,7 +269,16 @@ export class SeriesStrategy implements EntityStrategy {
return [...new Set(rows.map((row) => row.bookId))];
}
async getBookCount(id: number | string): Promise<number> {
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
if (scope) {
const [row] = await this.db
.select({ count: count() })
.from(bookSeriesMemberships)
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
.where(and(eq(bookSeriesMemberships.seriesId, Number(id)), ...buildEntityBookScopeClauses(this.db, scope)));
return row?.count ?? 0;
}
const [row] = await this.db
.select({ count: count() })
.from(bookSeriesMemberships)
@@ -275,14 +286,20 @@ export class SeriesStrategy implements EntityStrategy {
return row?.count ?? 0;
}
async getBookTitles(id: number | string, limit: number): Promise<string[]> {
const rows = await this.db
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
.from(bookSeriesMemberships)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId))
.where(eq(bookSeriesMemberships.seriesId, Number(id)))
.orderBy(asc(bookMetadata.title))
.limit(limit);
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
const baseQuery = this.db.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` }).from(bookSeriesMemberships);
const rows = scope
? await baseQuery
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId))
.where(and(eq(bookSeriesMemberships.seriesId, Number(id)), ...buildEntityBookScopeClauses(this.db, scope)))
.orderBy(asc(bookMetadata.title))
.limit(limit)
: await baseQuery
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId))
.where(eq(bookSeriesMemberships.seriesId, Number(id)))
.orderBy(asc(bookMetadata.title))
.limit(limit);
return rows.map((row) => row.title);
}