diff --git a/viewer-frontend/app/api/search/route.ts b/viewer-frontend/app/api/search/route.ts index cf1382d..b4d54eb 100644 --- a/viewer-frontend/app/api/search/route.ts +++ b/viewer-frontend/app/api/search/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma, prismaAuth } from '@/lib/db'; import { serializePhotos } from '@/lib/serialize'; import { auth } from '@/app/api/auth/[...nextauth]/route'; +import { photoIdsMatchingAll } from '@/lib/search-filter-sql'; export async function GET(request: NextRequest) { try { @@ -184,20 +185,13 @@ export async function GET(request: NextRequest) { } } - // Handle people filter - embed IDs directly since they're safe integers + // People / tags filters — honour any vs all (raw SQL path must match Prisma where above) if (people.length > 0) { - const peopleIds = people.join(','); - whereConditions.push(`id IN ( - SELECT DISTINCT photo_id FROM faces WHERE person_id IN (${peopleIds}) - )`); + whereConditions.push(photoIdsMatchingAll('faces', 'person_id', people, peopleMode)); } - - // Handle tags filter - embed IDs directly since they're safe integers + if (tags.length > 0) { - const tagIds = tags.join(','); - whereConditions.push(`id IN ( - SELECT DISTINCT photo_id FROM phototaglinkage WHERE tag_id IN (${tagIds}) - )`); + whereConditions.push(photoIdsMatchingAll('phototaglinkage', 'tag_id', tags, tagsMode)); } // Handle favorites filter - embed IDs directly since they're safe integers diff --git a/viewer-frontend/lib/search-filter-sql.test.ts b/viewer-frontend/lib/search-filter-sql.test.ts new file mode 100644 index 0000000..9515527 --- /dev/null +++ b/viewer-frontend/lib/search-filter-sql.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { photoIdsMatchingAll } from './search-filter-sql'; + +describe('photoIdsMatchingAll', () => { + it('uses DISTINCT for any mode', () => { + const sql = photoIdsMatchingAll('faces', 'person_id', [1, 2], 'any'); + expect(sql).toContain('DISTINCT photo_id'); + expect(sql).toContain('person_id IN (1,2)'); + expect(sql).not.toContain('GROUP BY'); + }); + + it('uses HAVING COUNT for all mode with multiple ids', () => { + const sql = photoIdsMatchingAll('faces', 'person_id', [1, 2, 3], 'all'); + expect(sql).toContain('GROUP BY photo_id'); + expect(sql).toContain('HAVING COUNT(DISTINCT person_id) = 3'); + }); + + it('falls back to DISTINCT when all mode has single id', () => { + const sql = photoIdsMatchingAll('phototaglinkage', 'tag_id', [5], 'all'); + expect(sql).toContain('DISTINCT photo_id'); + expect(sql).not.toContain('GROUP BY'); + }); +}); diff --git a/viewer-frontend/lib/search-filter-sql.ts b/viewer-frontend/lib/search-filter-sql.ts new file mode 100644 index 0000000..8e2893f --- /dev/null +++ b/viewer-frontend/lib/search-filter-sql.ts @@ -0,0 +1,20 @@ +/** Build a photo_id subquery for people/tags filters (any = OR, all = AND). */ +export function photoIdsMatchingAll( + table: 'faces' | 'phototaglinkage', + column: 'person_id' | 'tag_id', + ids: number[], + mode: 'any' | 'all' +): string { + const idList = ids.join(','); + if (mode === 'all' && ids.length > 1) { + return `id IN ( + SELECT photo_id FROM ${table} + WHERE ${column} IN (${idList}) + GROUP BY photo_id + HAVING COUNT(DISTINCT ${column}) = ${ids.length} + )`; + } + return `id IN ( + SELECT DISTINCT photo_id FROM ${table} WHERE ${column} IN (${idList}) + )`; +}