From 7f38183cbb4764592fa929189d69c9005acc2ae2 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 12:02:49 +0200 Subject: [PATCH 01/16] feat: new search filtering schemas --- server/src/dtos/search.dto.ts | 232 +++++++++++++++++++++++++++++++++- server/src/enum.ts | 9 ++ 2 files changed, 240 insertions(+), 1 deletion(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index c9a92b165f..b4aa5c6410 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -3,7 +3,14 @@ import { Place } from 'src/database'; import { HistoryBuilder } from 'src/decorators'; import { AlbumResponseSchema } from 'src/dtos/album.dto'; import { AssetResponseSchema } from 'src/dtos/asset-response.dto'; -import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum'; +import { + AssetOrder, + AssetOrderSchema, + AssetTypeSchema, + AssetVisibilitySchema, + SearchOrderField, + SearchOrderFieldSchema, +} from 'src/enum'; import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation'; import z from 'zod'; @@ -141,6 +148,229 @@ const SearchSuggestionRequestSchema = z }) .meta({ id: 'SearchSuggestionRequestDto' }); +// v3 SearchFilter DTOs — new shape introduced alongside the legacy flat DTOs above. + +const atLeastOneKey = (schema: T, allowed: (keyof T['shape'] & string)[]) => + schema.refine((value) => Object.values(value).some((v) => v !== undefined), { + message: `At least one of the following keys is required: ${allowed.join(', ')}`, + }); + +const IdFilterSchema = atLeastOneKey( + z.strictObject({ + eq: z.uuidv4().optional(), + ne: z.uuidv4().optional(), + }), + ['eq', 'ne'], +).meta({ id: 'IdFilter' }); + +const IdFilterNullableSchema = atLeastOneKey( + z.strictObject({ + eq: z.uuidv4().nullable().optional(), + ne: z.uuidv4().nullable().optional(), + }), + ['eq', 'ne'], +).meta({ id: 'IdFilterNullable' }); + +const IdsFilterSchema = atLeastOneKey( + z.strictObject({ + any: z.array(z.uuidv4()).min(1).optional(), + all: z.array(z.uuidv4()).min(1).optional(), + none: z.array(z.uuidv4()).min(1).optional(), + }), + ['any', 'all', 'none'], +).meta({ id: 'IdsFilter' }); + +const StringFilterSchema = atLeastOneKey( + z.strictObject({ + eq: z.string().optional(), + ne: z.string().optional(), + in: z.array(z.string()).min(1).optional(), + notIn: z.array(z.string()).min(1).optional(), + }), + ['eq', 'ne', 'in', 'notIn'], +).meta({ id: 'StringFilter' }); + +const StringFilterNullableSchema = atLeastOneKey( + z.strictObject({ + eq: z.string().nullable().optional(), + ne: z.string().nullable().optional(), + in: z.array(z.string()).min(1).optional(), + notIn: z.array(z.string()).min(1).optional(), + }), + ['eq', 'ne', 'in', 'notIn'], +).meta({ id: 'StringFilterNullable' }); + +const StringPatternFilterSchema = atLeastOneKey( + z.strictObject({ + eq: z.string().nullable().optional(), + ne: z.string().nullable().optional(), + in: z.array(z.string()).min(1).optional(), + notIn: z.array(z.string()).min(1).optional(), + like: z.string().min(1).optional(), + notLike: z.string().min(1).optional(), + startsWith: z.string().min(1).optional(), + endsWith: z.string().min(1).optional(), + }), + ['eq', 'ne', 'in', 'notIn', 'like', 'notLike', 'startsWith', 'endsWith'], +).meta({ id: 'StringPatternFilter' }); + +const NumberFilterSchema = atLeastOneKey( + z.strictObject({ + eq: z.number().optional(), + lt: z.number().optional(), + lte: z.number().optional(), + gt: z.number().optional(), + gte: z.number().optional(), + in: z.array(z.number()).min(1).optional(), + notIn: z.array(z.number()).min(1).optional(), + }), + ['eq', 'lt', 'lte', 'gt', 'gte', 'in', 'notIn'], +).meta({ id: 'NumberFilter' }); + +const NumberFilterNullableSchema = atLeastOneKey( + z.strictObject({ + eq: z.number().nullable().optional(), + ne: z.number().nullable().optional(), + lt: z.number().optional(), + lte: z.number().optional(), + gt: z.number().optional(), + gte: z.number().optional(), + in: z.array(z.number()).min(1).optional(), + notIn: z.array(z.number()).min(1).optional(), + }), + ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'notIn'], +).meta({ id: 'NumberFilterNullable' }); + +const DateFilterSchema = atLeastOneKey( + z.strictObject({ + eq: isoDatetimeToDate.optional(), + ne: isoDatetimeToDate.optional(), + gt: isoDatetimeToDate.optional(), + gte: isoDatetimeToDate.optional(), + lt: isoDatetimeToDate.optional(), + lte: isoDatetimeToDate.optional(), + }), + ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'], +).meta({ id: 'DateFilter' }); + +const DateFilterNullableSchema = atLeastOneKey( + z.strictObject({ + eq: isoDatetimeToDate.nullable().optional(), + ne: isoDatetimeToDate.nullable().optional(), + gt: isoDatetimeToDate.optional(), + gte: isoDatetimeToDate.optional(), + lt: isoDatetimeToDate.optional(), + lte: isoDatetimeToDate.optional(), + }), + ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'], +).meta({ id: 'DateFilterNullable' }); + +const BoolFilterSchema = z.strictObject({ eq: z.boolean() }).meta({ id: 'BoolFilter' }); + +const enumFilterSchema = (values: z.ZodEnum, id: string) => + atLeastOneKey( + z.strictObject({ + eq: values.optional(), + ne: values.optional(), + in: z.array(values).min(1).optional(), + notIn: z.array(values).min(1).optional(), + }), + ['eq', 'ne', 'in', 'notIn'], + ).meta({ id }); + +const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType'); +const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility'); + +const StringSimilarityFilterSchema = z + .strictObject({ + matches: z.string().min(1), + }) + .meta({ id: 'StringSimilarityFilter' }); + +const SearchOrderSchema = z + .strictObject({ + field: SearchOrderFieldSchema.default(SearchOrderField.FileCreatedAt), + direction: AssetOrderSchema.default(AssetOrder.Desc), + }) + .meta({ id: 'SearchOrder' }); + +const SearchFilterBranchSchema = z + .strictObject({ + id: IdFilterSchema.optional(), + libraryId: IdFilterNullableSchema.optional(), + type: EnumFilterAssetTypeSchema.optional(), + visibility: EnumFilterAssetVisibilitySchema.optional(), + isFavorite: BoolFilterSchema.optional(), + isMotion: BoolFilterSchema.optional(), + isOffline: BoolFilterSchema.optional(), + isEncoded: BoolFilterSchema.optional(), + hasAlbums: BoolFilterSchema.optional(), + hasPeople: BoolFilterSchema.optional(), + hasTags: BoolFilterSchema.optional(), + city: StringFilterNullableSchema.optional(), + state: StringFilterNullableSchema.optional(), + country: StringFilterNullableSchema.optional(), + make: StringFilterNullableSchema.optional(), + model: StringFilterNullableSchema.optional(), + lensModel: StringFilterNullableSchema.optional(), + description: StringPatternFilterSchema.optional(), + originalFileName: StringPatternFilterSchema.optional(), + originalPath: StringPatternFilterSchema.optional(), + ocr: StringSimilarityFilterSchema.optional(), + rating: NumberFilterNullableSchema.optional(), + fileSizeInBytes: NumberFilterSchema.optional(), + takenAt: DateFilterSchema.optional(), + createdAt: DateFilterSchema.optional(), + updatedAt: DateFilterSchema.optional(), + trashedAt: DateFilterNullableSchema.optional(), + personIds: IdsFilterSchema.optional(), + tagIds: IdsFilterSchema.optional(), + albumIds: IdsFilterSchema.optional(), + checksum: StringFilterSchema.optional(), + encodedVideoPath: StringFilterSchema.optional(), + }) + .meta({ id: 'SearchFilterBranch' }); + +const SearchFilterSchema = SearchFilterBranchSchema.extend({ + or: z.array(SearchFilterBranchSchema).min(1).optional(), +}).meta({ id: 'SearchFilter' }); + +export { + BoolFilterSchema, + DateFilterNullableSchema, + DateFilterSchema, + EnumFilterAssetTypeSchema, + EnumFilterAssetVisibilitySchema, + IdFilterNullableSchema, + IdFilterSchema, + IdsFilterSchema, + NumberFilterNullableSchema, + NumberFilterSchema, + SearchFilterBranchSchema, + SearchFilterSchema, + SearchOrderSchema, + StringFilterNullableSchema, + StringFilterSchema, + StringPatternFilterSchema, + StringSimilarityFilterSchema, +}; + +export type IdFilter = z.infer; +export type IdFilterNullable = z.infer; +export type IdsFilter = z.infer; +export type StringFilter = z.infer; +export type StringFilterNullable = z.infer; +export type StringPatternFilter = z.infer; +export type NumberFilter = z.infer; +export type NumberFilterNullable = z.infer; +export type DateFilter = z.infer; +export type DateFilterNullable = z.infer; +export type BoolFilter = z.infer; +export type StringSimilarityFilter = z.infer; +export type SearchOrder = z.infer; +export type SearchFilter = z.infer; +export type SearchFilterBranch = z.infer; + export class RandomSearchDto extends createZodDto(RandomSearchSchema) {} export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {} export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {} diff --git a/server/src/enum.ts b/server/src/enum.ts index 27cab3fb5e..e33a7f0ba8 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1176,3 +1176,12 @@ export enum WorkflowType { } export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' }); + +export enum SearchOrderField { + FileCreatedAt = 'fileCreatedAt', + LocalDateTime = 'localDateTime', + FileSizeInBytes = 'fileSizeInBytes', + Rating = 'rating', +} + +export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' }); From 3a5e172262dfbb4ac7208ddb09537e37b3cdbbbf Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 13:35:39 +0200 Subject: [PATCH 02/16] drop allowed param --- server/src/dtos/search.dto.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index b4aa5c6410..70117bffc1 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -150,17 +150,18 @@ const SearchSuggestionRequestSchema = z // v3 SearchFilter DTOs — new shape introduced alongside the legacy flat DTOs above. -const atLeastOneKey = (schema: T, allowed: (keyof T['shape'] & string)[]) => - schema.refine((value) => Object.values(value).some((v) => v !== undefined), { - message: `At least one of the following keys is required: ${allowed.join(', ')}`, +const atLeastOneKey = (schema: T) => { + const keys = Object.keys(schema.shape); + return schema.refine((value) => Object.values(value).some((v) => v !== undefined), { + message: `At least one of the following keys is required: ${keys.join(', ')}`, }); +}; const IdFilterSchema = atLeastOneKey( z.strictObject({ eq: z.uuidv4().optional(), ne: z.uuidv4().optional(), }), - ['eq', 'ne'], ).meta({ id: 'IdFilter' }); const IdFilterNullableSchema = atLeastOneKey( @@ -168,7 +169,6 @@ const IdFilterNullableSchema = atLeastOneKey( eq: z.uuidv4().nullable().optional(), ne: z.uuidv4().nullable().optional(), }), - ['eq', 'ne'], ).meta({ id: 'IdFilterNullable' }); const IdsFilterSchema = atLeastOneKey( @@ -177,7 +177,6 @@ const IdsFilterSchema = atLeastOneKey( all: z.array(z.uuidv4()).min(1).optional(), none: z.array(z.uuidv4()).min(1).optional(), }), - ['any', 'all', 'none'], ).meta({ id: 'IdsFilter' }); const StringFilterSchema = atLeastOneKey( @@ -187,7 +186,6 @@ const StringFilterSchema = atLeastOneKey( in: z.array(z.string()).min(1).optional(), notIn: z.array(z.string()).min(1).optional(), }), - ['eq', 'ne', 'in', 'notIn'], ).meta({ id: 'StringFilter' }); const StringFilterNullableSchema = atLeastOneKey( @@ -197,7 +195,6 @@ const StringFilterNullableSchema = atLeastOneKey( in: z.array(z.string()).min(1).optional(), notIn: z.array(z.string()).min(1).optional(), }), - ['eq', 'ne', 'in', 'notIn'], ).meta({ id: 'StringFilterNullable' }); const StringPatternFilterSchema = atLeastOneKey( @@ -211,7 +208,6 @@ const StringPatternFilterSchema = atLeastOneKey( startsWith: z.string().min(1).optional(), endsWith: z.string().min(1).optional(), }), - ['eq', 'ne', 'in', 'notIn', 'like', 'notLike', 'startsWith', 'endsWith'], ).meta({ id: 'StringPatternFilter' }); const NumberFilterSchema = atLeastOneKey( @@ -224,7 +220,6 @@ const NumberFilterSchema = atLeastOneKey( in: z.array(z.number()).min(1).optional(), notIn: z.array(z.number()).min(1).optional(), }), - ['eq', 'lt', 'lte', 'gt', 'gte', 'in', 'notIn'], ).meta({ id: 'NumberFilter' }); const NumberFilterNullableSchema = atLeastOneKey( @@ -238,7 +233,6 @@ const NumberFilterNullableSchema = atLeastOneKey( in: z.array(z.number()).min(1).optional(), notIn: z.array(z.number()).min(1).optional(), }), - ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'notIn'], ).meta({ id: 'NumberFilterNullable' }); const DateFilterSchema = atLeastOneKey( @@ -250,7 +244,6 @@ const DateFilterSchema = atLeastOneKey( lt: isoDatetimeToDate.optional(), lte: isoDatetimeToDate.optional(), }), - ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'], ).meta({ id: 'DateFilter' }); const DateFilterNullableSchema = atLeastOneKey( @@ -262,7 +255,6 @@ const DateFilterNullableSchema = atLeastOneKey( lt: isoDatetimeToDate.optional(), lte: isoDatetimeToDate.optional(), }), - ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'], ).meta({ id: 'DateFilterNullable' }); const BoolFilterSchema = z.strictObject({ eq: z.boolean() }).meta({ id: 'BoolFilter' }); @@ -275,7 +267,6 @@ const enumFilterSchema = (values: z.ZodEnum, in: z.array(values).min(1).optional(), notIn: z.array(values).min(1).optional(), }), - ['eq', 'ne', 'in', 'notIn'], ).meta({ id }); const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType'); From 2c58b32bbcf80d2895563989711d3e0ec37f2eaa Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 14:24:52 +0200 Subject: [PATCH 03/16] add query helpers --- server/src/utils/database.ts | 129 +++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index fbf32c0ac2..1781dfc687 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -276,6 +276,135 @@ export function hasTags(qb: SelectQueryBuilder, tagIds: strin ); } +export function inAlbumsAny(qb: SelectQueryBuilder, albumIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('album_asset') + .select('assetId') + .where('albumId', '=', anyUuid(albumIds)) + .groupBy('assetId') + .as('in_albums_any'), + (join) => join.onRef('in_albums_any.assetId', '=', 'asset.id'), + ); +} + +export function inAlbumsAll(qb: SelectQueryBuilder, albumIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('album_asset') + .select('assetId') + .where('albumId', '=', anyUuid(albumIds)) + .groupBy('assetId') + .having((eb) => eb.fn.count('albumId').distinct(), '=', albumIds.length) + .as('in_albums_all'), + (join) => join.onRef('in_albums_all.assetId', '=', 'asset.id'), + ); +} + +export function inAlbumsNone(qb: SelectQueryBuilder, albumIds: string[]) { + return qb.where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('album_asset') + .select('assetId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('albumId', '=', anyUuid(albumIds)), + ), + ), + ); +} + +export function hasPeopleAny(qb: SelectQueryBuilder, personIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('asset_face') + .select('assetId') + .where('personId', '=', anyUuid(personIds)) + .where('deletedAt', 'is', null) + .where('isVisible', 'is', true) + .groupBy('assetId') + .as('has_people_any'), + (join) => join.onRef('has_people_any.assetId', '=', 'asset.id'), + ); +} + +export function hasPeopleAll(qb: SelectQueryBuilder, personIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('asset_face') + .select('assetId') + .where('personId', '=', anyUuid(personIds)) + .where('deletedAt', 'is', null) + .where('isVisible', 'is', true) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .as('has_people_all'), + (join) => join.onRef('has_people_all.assetId', '=', 'asset.id'), + ); +} + +export function hasPeopleNone(qb: SelectQueryBuilder, personIds: string[]) { + return qb.where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('asset_face') + .select('assetId') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('personId', '=', anyUuid(personIds)) + .where('deletedAt', 'is', null) + .where('isVisible', 'is', true), + ), + ), + ); +} + +export function hasTagsAny(qb: SelectQueryBuilder, tagIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('tag_asset') + .select('assetId') + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)) + .groupBy('assetId') + .as('has_tags_any'), + (join) => join.onRef('has_tags_any.assetId', '=', 'asset.id'), + ); +} + +export function hasTagsAll(qb: SelectQueryBuilder, tagIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('tag_asset') + .select('assetId') + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)) + .groupBy('assetId') + .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', tagIds.length) + .as('has_tags_all'), + (join) => join.onRef('has_tags_all.assetId', '=', 'asset.id'), + ); +} + +export function hasTagsNone(qb: SelectQueryBuilder, tagIds: string[]) { + return qb.where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('tag_asset') + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .select('tag_asset.assetId') + .whereRef('tag_asset.assetId', '=', 'asset.id') + .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)), + ), + ), + ); +} + export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'asset.ownerId')).as( 'owner', From dc7f3f5aa489e3e937185fd715e4d3cc6b4f4511 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 14:27:24 +0200 Subject: [PATCH 04/16] rename searchAssetBuilder --- server/src/repositories/search.repository.ts | 12 ++++++------ server/src/utils/database.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 6f03c80ce1..12cb9f2944 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -6,7 +6,7 @@ import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database'; +import { anyUuid, searchAssetBuilderLegacy, withExifInner } from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; @@ -195,7 +195,7 @@ export class SearchRepository { }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - const items = await searchAssetBuilder(this.db, options) + const items = await searchAssetBuilderLegacy(this.db, options) .selectAll('asset') .orderBy('asset.fileCreatedAt', orderDirection) .limit(pagination.size + 1) @@ -216,7 +216,7 @@ export class SearchRepository { ], }) searchStatistics(options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) + return searchAssetBuilderLegacy(this.db, options) .select((qb) => qb.fn.countAll().as('total')) .executeTakeFirstOrThrow(); } @@ -234,7 +234,7 @@ export class SearchRepository { ], }) async searchRandom(size: number, options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) + return searchAssetBuilderLegacy(this.db, options) .selectAll('asset') .orderBy(sql`random()`) .limit(size) @@ -255,7 +255,7 @@ export class SearchRepository { }) searchLargeAssets(size: number, options: LargeAssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - return searchAssetBuilder(this.db, options) + return searchAssetBuilderLegacy(this.db, options) .selectAll('asset') .$call(withExifInner) .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) @@ -284,7 +284,7 @@ export class SearchRepository { return this.db.transaction().execute(async (trx) => { await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx); - const items = await searchAssetBuilder(trx, options) + const items = await searchAssetBuilderLegacy(trx, options) .selectAll('asset') .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 1781dfc687..52fdde7566 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -497,7 +497,7 @@ export function withEdits(eb: ExpressionBuilder): AliasedEditAction const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ -export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { +export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearchBuilderOptions) { options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility; From 82054eb1c79b122ea3c238831ba391ebe85fd0f2 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 18:45:45 +0200 Subject: [PATCH 05/16] add query builders --- server/src/dtos/search.dto.ts | 1 + server/src/repositories/search.repository.ts | 188 ++++- server/src/utils/database.ts | 756 +++++++++++++++---- 3 files changed, 812 insertions(+), 133 deletions(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 70117bffc1..1c212024d8 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -213,6 +213,7 @@ const StringPatternFilterSchema = atLeastOneKey( const NumberFilterSchema = atLeastOneKey( z.strictObject({ eq: z.number().optional(), + ne: z.number().optional(), lt: z.number().optional(), lte: z.number().optional(), gt: z.number().optional(), diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 12cb9f2944..fc7a430ec6 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -2,11 +2,12 @@ import { Injectable } from '@nestjs/common'; import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; +import { SearchFilter, SearchOrder } from 'src/dtos/search.dto'; +import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SearchOrderField, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { anyUuid, searchAssetBuilderLegacy, withExifInner } from 'src/utils/database'; +import { anyUuid, searchAssetBuilder, searchAssetBuilderLegacy, withExifInner } from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; @@ -121,6 +122,22 @@ export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; export type AssetSearchBuilderOptions = Omit; +export interface AssetSearchBuilderV3Options { + filter?: SearchFilter; + /** Server-derived ownership scope. Never client-controlled. */ + userIds?: string[]; + withExif?: boolean; + withFaces?: boolean; + withPeople?: boolean; + withStacked?: boolean; + order?: SearchOrder; +} + +export interface AssetSearchPaginationV3Options { + cursor?: string; + size: number; +} + export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & @@ -489,6 +506,173 @@ export class SearchRepository { return res.map((row) => row.lensModel!); } + // --------------------------------------------------------------------------- + // v3 SQL coverage scaffolding — these methods exist solely to give the SQL + // generator structurally distinct snapshots of the new `searchAssetBuilder`. + // They have no consumer yet. PR 2 rewires the legacy methods above to use + // `searchAssetBuilder` and deletes these scaffolding methods. + // --------------------------------------------------------------------------- + + @GenerateSql( + { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] }, + { + name: 'string-eq-null', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }], + }, + { + name: 'string-pattern-like', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }], + }, + { + name: 'string-pattern-notLike', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }], + }, + { + name: 'string-pattern-startsWith', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } }, + ], + }, + { + name: 'string-similarity-ocr', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }], + }, + { + name: 'ids-any', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }], + }, + { + name: 'ids-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID] } } }, + ], + }, + { + name: 'ids-none', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }], + }, + { + name: 'ids-tags-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID] } } }, + ], + }, + { + name: 'has-albums-false', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }], + }, + { + name: 'is-encoded', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }], + }, + { + name: 'number-range', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }], + }, + { + name: 'date-eq', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }], + }, + { + name: 'date-range', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, + }, + ], + }, + { + name: 'order-fileSize-noExif', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc }, + withExif: false, + }, + ], + }, + { + name: 'order-rating-withExif', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc }, + withExif: true, + }, + ], + }, + { + name: 'or-branches', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { + or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }], + }, + }, + ], + }, + { + name: 'or-with-top-level', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { + takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, + or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }], + }, + }, + ], + }, + ) + async searchMetadataV3(pagination: AssetSearchPaginationV3Options, options: AssetSearchBuilderV3Options) { + return await searchAssetBuilder(this.db, options) + .selectAll('asset') + .limit(pagination.size + 1) + .execute(); + } + + @GenerateSql( + { name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] }, + { + name: 'with-filter', + params: [ + { + userIds: [DummyValue.UUID], + filter: { + takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, + fileSizeInBytes: { gte: 100 }, + }, + }, + ], + }, + { + name: 'with-or', + params: [ + { + userIds: [DummyValue.UUID], + filter: { + or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }], + }, + }, + ], + }, + ) + searchStatisticsV3(options: AssetSearchBuilderV3Options) { + return searchAssetBuilder(this.db, options) + .select((qb) => qb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + } + private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) { return this.db .selectFrom('asset_exif') diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 52fdde7566..5789c8c3a4 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -11,17 +11,41 @@ import { SelectQueryBuilder, ShallowDehydrateObject, sql, + SqlBool, } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; -import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { + DateFilter, + DateFilterNullable, + IdFilter, + IdFilterNullable, + IdsFilter, + NumberFilter, + NumberFilterNullable, + SearchFilter, + SearchFilterBranch, + StringFilter, + StringFilterNullable, + StringPatternFilter, +} from 'src/dtos/search.dto'; +import { + AssetFileType, + AssetOrder, + AssetOrderBy, + AssetVisibility, + DatabaseExtension, + ExifOrientation, + SearchOrderField, +} from 'src/enum'; +import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types'; +import { fromChecksum } from 'src/utils/request'; export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { return { @@ -276,135 +300,6 @@ export function hasTags(qb: SelectQueryBuilder, tagIds: strin ); } -export function inAlbumsAny(qb: SelectQueryBuilder, albumIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('album_asset') - .select('assetId') - .where('albumId', '=', anyUuid(albumIds)) - .groupBy('assetId') - .as('in_albums_any'), - (join) => join.onRef('in_albums_any.assetId', '=', 'asset.id'), - ); -} - -export function inAlbumsAll(qb: SelectQueryBuilder, albumIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('album_asset') - .select('assetId') - .where('albumId', '=', anyUuid(albumIds)) - .groupBy('assetId') - .having((eb) => eb.fn.count('albumId').distinct(), '=', albumIds.length) - .as('in_albums_all'), - (join) => join.onRef('in_albums_all.assetId', '=', 'asset.id'), - ); -} - -export function inAlbumsNone(qb: SelectQueryBuilder, albumIds: string[]) { - return qb.where(({ not, exists, selectFrom }) => - not( - exists( - selectFrom('album_asset') - .select('assetId') - .whereRef('album_asset.assetId', '=', 'asset.id') - .where('albumId', '=', anyUuid(albumIds)), - ), - ), - ); -} - -export function hasPeopleAny(qb: SelectQueryBuilder, personIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('asset_face') - .select('assetId') - .where('personId', '=', anyUuid(personIds)) - .where('deletedAt', 'is', null) - .where('isVisible', 'is', true) - .groupBy('assetId') - .as('has_people_any'), - (join) => join.onRef('has_people_any.assetId', '=', 'asset.id'), - ); -} - -export function hasPeopleAll(qb: SelectQueryBuilder, personIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('asset_face') - .select('assetId') - .where('personId', '=', anyUuid(personIds)) - .where('deletedAt', 'is', null) - .where('isVisible', 'is', true) - .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) - .as('has_people_all'), - (join) => join.onRef('has_people_all.assetId', '=', 'asset.id'), - ); -} - -export function hasPeopleNone(qb: SelectQueryBuilder, personIds: string[]) { - return qb.where(({ not, exists, selectFrom }) => - not( - exists( - selectFrom('asset_face') - .select('assetId') - .whereRef('asset_face.assetId', '=', 'asset.id') - .where('personId', '=', anyUuid(personIds)) - .where('deletedAt', 'is', null) - .where('isVisible', 'is', true), - ), - ), - ); -} - -export function hasTagsAny(qb: SelectQueryBuilder, tagIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('tag_asset') - .select('assetId') - .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') - .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)) - .groupBy('assetId') - .as('has_tags_any'), - (join) => join.onRef('has_tags_any.assetId', '=', 'asset.id'), - ); -} - -export function hasTagsAll(qb: SelectQueryBuilder, tagIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('tag_asset') - .select('assetId') - .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') - .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)) - .groupBy('assetId') - .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', tagIds.length) - .as('has_tags_all'), - (join) => join.onRef('has_tags_all.assetId', '=', 'asset.id'), - ); -} - -export function hasTagsNone(qb: SelectQueryBuilder, tagIds: string[]) { - return qb.where(({ not, exists, selectFrom }) => - not( - exists( - selectFrom('tag_asset') - .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') - .select('tag_asset.assetId') - .whereRef('tag_asset.assetId', '=', 'asset.id') - .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)), - ), - ), - ); -} - export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'asset.ownerId')).as( 'owner', @@ -614,6 +509,605 @@ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearc .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } +/** + * Join strategy each `SearchFilterBranch` field needs against the database. + * - `asset`: column on the `asset` table; simple WHERE; no join. + * - `asset_exif`: column on `asset_exif`; inner join + * - `asset_file`: column on `asset_file`; SQL is field-dependent + * - `ocr_search`: specialised trigram-indexed search column + * - `membership`: not a column; junction-table membership; SQL is operator-dependent + */ +type Backing = 'asset' | 'asset_exif' | 'asset_file' | 'ocr_search' | 'membership'; + +/** + * Exhaustive `SearchFilterBranch` backing map + */ +const FIELD_BACKING: Record, Backing> = { + id: 'asset', + libraryId: 'asset', + type: 'asset', + visibility: 'asset', + isFavorite: 'asset', + isMotion: 'asset', + isOffline: 'asset', + isEncoded: 'asset_file', + hasAlbums: 'membership', + hasPeople: 'membership', + hasTags: 'membership', + city: 'asset_exif', + state: 'asset_exif', + country: 'asset_exif', + make: 'asset_exif', + model: 'asset_exif', + lensModel: 'asset_exif', + description: 'asset_exif', + originalFileName: 'asset', + originalPath: 'asset', + ocr: 'ocr_search', + rating: 'asset_exif', + fileSizeInBytes: 'asset_exif', + takenAt: 'asset', + createdAt: 'asset', + updatedAt: 'asset', + trashedAt: 'asset', + personIds: 'membership', + tagIds: 'membership', + albumIds: 'membership', + checksum: 'asset', + encodedVideoPath: 'asset_file', +}; + +function branchNeedsExifJoin(branch: SearchFilterBranch): boolean { + for (const key of Object.keys(FIELD_BACKING) as (keyof typeof FIELD_BACKING)[]) { + if (FIELD_BACKING[key] === 'asset_exif' && branch[key] !== undefined) { + return true; + } + } + return false; +} + +/** + * Exhaustive `SearchOrderField` backing map + */ +const ORDER_BACKING = { + [SearchOrderField.FileCreatedAt]: 'asset', + [SearchOrderField.LocalDateTime]: 'asset', + [SearchOrderField.FileSizeInBytes]: 'asset_exif', + [SearchOrderField.Rating]: 'asset_exif', +} satisfies Record; + +/** + * `asset_exif` join is needed when either any filter or order field needs `asset_exif` + */ +function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): boolean { + if (ORDER_BACKING[orderField] === 'asset_exif') { + return true; + } + if (branchNeedsExifJoin(filter)) { + return true; + } + return filter.or?.some((branch) => branchNeedsExifJoin(branch)) ?? false; +} + +/** + * EB type used by `buildBranchPredicates`. The runtime invariant is that + * whenever a predicate references an `asset_exif` column, the `asset_exif` + * join has already been planted at the top of the builder chain (guaranteed + * by `exifJoinRequired`). `searchAssetBuilder` casts its `eb` into this type + * because TS can't see through the conditional `.$if(needsExifJoin, …)`. + */ +type AssetEB = ExpressionBuilder; + +// ---- EXISTS expression helpers (returned as Expression) ---- + +function existsAlbumLink(eb: AssetEB, want: boolean): Expression { + const e = eb.exists((eb2) => eb2.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id')); + return want ? e : eb.not(e); +} + +function existsPersonLink(eb: AssetEB, want: boolean): Expression { + const e = eb.exists((eb2) => + eb2 + .selectFrom('asset_face') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true), + ); + return want ? e : eb.not(e); +} + +function existsTagLink(eb: AssetEB, want: boolean): Expression { + const e = eb.exists((eb2) => eb2.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id')); + return want ? e : eb.not(e); +} + +function existsEncodedVideo(eb: AssetEB, want: boolean): Expression { + const e = eb.exists((eb2) => + eb2 + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.EncodedVideo), + ); + return want ? e : eb.not(e); +} + +function existsOcrMatch(eb: AssetEB, matches: string): Expression { + const tokens = tokenizeForSearch(matches).join(' '); + return eb.exists((eb2) => + eb2 + .selectFrom('ocr_search') + .whereRef('ocr_search.assetId', '=', 'asset.id') + .where(sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokens})`), + ); +} + +const encodedVideoFileBase = (eb2: ExpressionBuilder) => + eb2 + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.EncodedVideo) + .where('asset_file.isEdited', '=', false); + +function existsEncodedVideoPath(eb: AssetEB, f: StringFilter): Expression[] { + const out: Expression[] = []; + if (f.eq !== undefined) { + out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '=', f.eq!))); + } + if (f.ne !== undefined) { + out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '<>', f.ne!))); + } + if (f.in !== undefined) { + out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'in', f.in!))); + } + if (f.notIn !== undefined) { + out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'not in', f.notIn!))); + } + return out; +} + +// ---- IdsFilter EXISTS helpers ---- + +type IdsKind = 'album' | 'person' | 'tag'; + +function idsAnyExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression { + switch (kind) { + case 'album': { + return eb.exists((eb2) => + eb2 + .selectFrom('album_asset') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('album_asset.albumId', '=', anyUuid(ids)), + ); + } + case 'person': { + return eb.exists((eb2) => + eb2 + .selectFrom('asset_face') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.personId', '=', anyUuid(ids)) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true), + ); + } + case 'tag': { + return eb.exists((eb2) => + eb2 + .selectFrom('tag_asset') + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .whereRef('tag_asset.assetId', '=', 'asset.id') + .where('tag_closure.id_ancestor', '=', anyUuid(ids)), + ); + } + } +} + +function idsAllExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression { + switch (kind) { + case 'album': { + return eb.exists((eb2) => + eb2 + .selectFrom('album_asset') + .select('album_asset.assetId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('album_asset.albumId', '=', anyUuid(ids)) + .groupBy('album_asset.assetId') + .having((e3) => e3.fn.count('album_asset.albumId').distinct(), '=', ids.length), + ); + } + case 'person': { + return eb.exists((eb2) => + eb2 + .selectFrom('asset_face') + .select('asset_face.assetId') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.personId', '=', anyUuid(ids)) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) + .groupBy('asset_face.assetId') + .having((e3) => e3.fn.count('asset_face.personId').distinct(), '=', ids.length), + ); + } + case 'tag': { + return eb.exists((eb2) => + eb2 + .selectFrom('tag_asset') + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .select('tag_asset.assetId') + .whereRef('tag_asset.assetId', '=', 'asset.id') + .where('tag_closure.id_ancestor', '=', anyUuid(ids)) + .groupBy('tag_asset.assetId') + .having((e3) => e3.fn.count('tag_closure.id_ancestor').distinct(), '>=', ids.length), + ); + } + } +} + +function pushIdsFilter(preds: Expression[], eb: AssetEB, kind: IdsKind, f: IdsFilter) { + if (f.any) { + preds.push(idsAnyExists(eb, kind, f.any)); + } + if (f.all) { + preds.push(idsAllExists(eb, kind, f.all)); + } + if (f.none) { + preds.push(eb.not(idsAnyExists(eb, kind, f.none))); + } +} + +// ---- Per-filter-family pushers ---- + +function pushIdEqNe( + preds: Expression[], + eb: AssetEB, + column: 'asset.id' | 'asset.libraryId', + f: IdFilter | IdFilterNullable | undefined, +) { + if (!f) { + return; + } + if (f.eq === null) { + preds.push(eb(column, 'is', null)); + } else if (f.eq !== undefined) { + preds.push(eb(column, '=', asUuid(f.eq))); + } + if (f.ne === null) { + preds.push(eb(column, 'is not', null)); + } else if (f.ne !== undefined) { + preds.push(eb(column, '<>', asUuid(f.ne))); + } +} + +function pushEnum( + preds: Expression[], + eb: AssetEB, + column: 'asset.type' | 'asset.visibility', + f: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined, +) { + if (!f) { + return; + } + // Values cast: column type unions across asset.type / asset.visibility resolve + // to `AssetType | AssetVisibility` and TS can't narrow to the caller's T. + // The caller side (the SearchFilter enum schemas) is what guarantees validity. + if (f.eq !== undefined) { + preds.push(eb(column, '=', f.eq as never)); + } + if (f.ne !== undefined) { + preds.push(eb(column, '<>', f.ne as never)); + } + if (f.in !== undefined) { + preds.push(eb(column, 'in', f.in as never)); + } + if (f.notIn !== undefined) { + preds.push(eb(column, 'not in', f.notIn as never)); + } +} + +type StringColumn = + | 'asset_exif.city' + | 'asset_exif.state' + | 'asset_exif.country' + | 'asset_exif.make' + | 'asset_exif.model' + | 'asset_exif.lensModel' + | 'asset_exif.description' + | 'asset.originalFileName' + | 'asset.originalPath'; + +function pushStringEqNeInNotIn( + preds: Expression[], + eb: AssetEB, + column: StringColumn, + f: StringFilterNullable | StringPatternFilter | undefined, +) { + if (!f) { + return; + } + if (f.eq === null) { + preds.push(eb(column, 'is', null)); + } else if (f.eq !== undefined) { + preds.push(eb(column, '=', f.eq)); + } + if (f.ne === null) { + preds.push(eb(column, 'is not', null)); + } else if (f.ne !== undefined) { + preds.push(eb(column, '<>', f.ne)); + } + if (f.in !== undefined) { + preds.push(eb(column, 'in', f.in)); + } + if (f.notIn !== undefined) { + preds.push(eb(column, 'not in', f.notIn)); + } +} + +function pushStringPattern( + preds: Expression[], + eb: AssetEB, + column: StringColumn, + f: StringPatternFilter | undefined, +) { + if (!f) { + return; + } + pushStringEqNeInNotIn(preds, eb, column, f); + const ref = sql.ref(column); + if (f.like !== undefined) { + preds.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.like}) || '%')`); + } + if (f.notLike !== undefined) { + preds.push(sql`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${f.notLike}) || '%')`); + } + if (f.startsWith !== undefined) { + preds.push(sql`f_unaccent(${ref}) ilike (f_unaccent(${f.startsWith}) || '%')`); + } + if (f.endsWith !== undefined) { + preds.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.endsWith}))`); + } +} + +type NumberColumn = 'asset_exif.rating' | 'asset_exif.fileSizeInByte'; + +function pushNumber( + preds: Expression[], + eb: AssetEB, + column: NumberColumn, + f: NumberFilter | NumberFilterNullable | undefined, +) { + if (!f) { + return; + } + if (f.eq === null) { + preds.push(eb(column, 'is', null)); + } else if (f.eq !== undefined) { + preds.push(eb(column, '=', f.eq)); + } + if (f.ne === null) { + preds.push(eb(column, 'is not', null)); + } else if (f.ne !== undefined) { + preds.push(eb(column, '<>', f.ne)); + } + if (f.lt !== undefined) { + preds.push(eb(column, '<', f.lt)); + } + if (f.lte !== undefined) { + preds.push(eb(column, '<=', f.lte)); + } + if (f.gt !== undefined) { + preds.push(eb(column, '>', f.gt)); + } + if (f.gte !== undefined) { + preds.push(eb(column, '>=', f.gte)); + } + if (f.in !== undefined) { + preds.push(eb(column, 'in', f.in)); + } + if (f.notIn !== undefined) { + preds.push(eb(column, 'not in', f.notIn)); + } +} + +type DateColumn = 'asset.fileCreatedAt' | 'asset.createdAt' | 'asset.updatedAt' | 'asset.deletedAt'; + +function pushDate( + preds: Expression[], + eb: AssetEB, + column: DateColumn, + f: DateFilter | DateFilterNullable | undefined, +) { + if (!f) { + return; + } + if (f.eq === null) { + preds.push(eb(column, 'is', null)); + } else if (f.eq !== undefined) { + preds.push(eb(column, '=', f.eq)); + } + if (f.ne === null) { + preds.push(eb(column, 'is not', null)); + } else if (f.ne !== undefined) { + preds.push(eb(column, '<>', f.ne)); + } + if (f.gt !== undefined) { + preds.push(eb(column, '>', f.gt)); + } + if (f.gte !== undefined) { + preds.push(eb(column, '>=', f.gte)); + } + if (f.lt !== undefined) { + preds.push(eb(column, '<', f.lt)); + } + if (f.lte !== undefined) { + preds.push(eb(column, '<=', f.lte)); + } +} + +function pushChecksum(preds: Expression[], eb: AssetEB, f: StringFilter | undefined) { + if (!f) { + return; + } + if (f.eq !== undefined) { + preds.push(eb('asset.checksum', '=', fromChecksum(f.eq))); + } + if (f.ne !== undefined) { + preds.push(eb('asset.checksum', '<>', fromChecksum(f.ne))); + } + if (f.in !== undefined) { + preds.push( + eb( + 'asset.checksum', + 'in', + f.in.map((c: string) => fromChecksum(c)), + ), + ); + } + if (f.notIn !== undefined) { + preds.push( + eb( + 'asset.checksum', + 'not in', + f.notIn.map((c: string) => fromChecksum(c)), + ), + ); + } +} + +function buildBranchPredicates(eb: AssetEB, b: SearchFilterBranch): Expression[] { + const p: Expression[] = []; + + // id / libraryId + pushIdEqNe(p, eb, 'asset.id', b.id); + pushIdEqNe(p, eb, 'asset.libraryId', b.libraryId); + + // enums + pushEnum(p, eb, 'asset.type', b.type); + pushEnum(p, eb, 'asset.visibility', b.visibility); + + // bools on asset + if (b.isFavorite) { + p.push(eb('asset.isFavorite', '=', b.isFavorite.eq)); + } + if (b.isOffline) { + p.push(eb('asset.isOffline', '=', b.isOffline.eq)); + } + if (b.isMotion) { + p.push(eb('asset.livePhotoVideoId', b.isMotion.eq ? 'is not' : 'is', null)); + } + if (b.isEncoded) { + p.push(existsEncodedVideo(eb, b.isEncoded.eq)); + } + + // membership presence + if (b.hasAlbums) { + p.push(existsAlbumLink(eb, b.hasAlbums.eq)); + } + if (b.hasPeople) { + p.push(existsPersonLink(eb, b.hasPeople.eq)); + } + if (b.hasTags) { + p.push(existsTagLink(eb, b.hasTags.eq)); + } + + // EXIF string columns (nullable) + pushStringEqNeInNotIn(p, eb, 'asset_exif.city', b.city); + pushStringEqNeInNotIn(p, eb, 'asset_exif.state', b.state); + pushStringEqNeInNotIn(p, eb, 'asset_exif.country', b.country); + pushStringEqNeInNotIn(p, eb, 'asset_exif.make', b.make); + pushStringEqNeInNotIn(p, eb, 'asset_exif.model', b.model); + pushStringEqNeInNotIn(p, eb, 'asset_exif.lensModel', b.lensModel); + + // StringPattern columns + pushStringPattern(p, eb, 'asset_exif.description', b.description); + pushStringPattern(p, eb, 'asset.originalFileName', b.originalFileName); + pushStringPattern(p, eb, 'asset.originalPath', b.originalPath); + + // ocr similarity (EXISTS over ocr_search — no top-level join) + if (b.ocr) { + p.push(existsOcrMatch(eb, b.ocr.matches)); + } + + // numbers + pushNumber(p, eb, 'asset_exif.rating', b.rating); + pushNumber(p, eb, 'asset_exif.fileSizeInByte', b.fileSizeInBytes); + + // dates + pushDate(p, eb, 'asset.fileCreatedAt', b.takenAt); + pushDate(p, eb, 'asset.createdAt', b.createdAt); + pushDate(p, eb, 'asset.updatedAt', b.updatedAt); + pushDate(p, eb, 'asset.deletedAt', b.trashedAt); + + // IdsFilter — EXISTS-based, composable with OR + if (b.albumIds) { + pushIdsFilter(p, eb, 'album', b.albumIds); + } + if (b.personIds) { + pushIdsFilter(p, eb, 'person', b.personIds); + } + if (b.tagIds) { + pushIdsFilter(p, eb, 'tag', b.tagIds); + } + + // checksum (bytea, decoded from string on the wire) + pushChecksum(p, eb, b.checksum); + + // encodedVideoPath — EXISTS over asset_file with path predicate + if (b.encodedVideoPath) { + p.push(...existsEncodedVideoPath(eb, b.encodedVideoPath)); + } + + return p; +} + +function applySearchOrder( + qb: SelectQueryBuilder, + field: SearchOrderField, + direction: AssetOrder, +) { + switch (field) { + case SearchOrderField.FileCreatedAt: { + return qb.orderBy('asset.fileCreatedAt', direction); + } + case SearchOrderField.LocalDateTime: { + return qb.orderBy('asset.localDateTime', direction); + } + case SearchOrderField.FileSizeInBytes: { + return qb.orderBy('asset_exif.fileSizeInByte', direction); + } + case SearchOrderField.Rating: { + return qb.orderBy('asset_exif.rating', direction); + } + } +} + +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options) { + const filter = options.filter ?? {}; + const orderField = options.order?.field ?? SearchOrderField.FileCreatedAt; + const orderDirection = options.order?.direction ?? AssetOrder.Desc; + const needsExifJoin = exifJoinRequired(filter, orderField); + + return kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('asset') + .$if(needsExifJoin && !options.withExif, (qb) => qb.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')) + .$if(!!options.withExif && needsExifJoin, withExifInner) + .$if(!!options.withExif && !needsExifJoin, withExif) + .$if(!!options.userIds && options.userIds.length > 0, (qb) => + qb.where('asset.ownerId', '=', anyUuid(options.userIds!)), + ) + .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) + .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) + .where((eb) => { + const top = buildBranchPredicates(eb, filter); + if (filter.or && filter.or.length > 0) { + top.push(eb.or(filter.or.map((branch) => eb.and(buildBranchPredicates(eb, branch))))); + } + return top.length > 0 ? eb.and(top) : eb.val(true); + }) + .$call((qb) => + applySearchOrder(qb as SelectQueryBuilder, orderField, orderDirection), + ); +} + export type ReindexVectorIndexOptions = { indexName: string; lists?: number }; type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions; From 3817aec5b1f3d8eaa4e4064fe220308f73e232bb Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 18:51:57 +0200 Subject: [PATCH 06/16] drop unnecessary exports --- server/src/dtos/search.dto.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 1c212024d8..36b36de27b 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -327,26 +327,6 @@ const SearchFilterSchema = SearchFilterBranchSchema.extend({ or: z.array(SearchFilterBranchSchema).min(1).optional(), }).meta({ id: 'SearchFilter' }); -export { - BoolFilterSchema, - DateFilterNullableSchema, - DateFilterSchema, - EnumFilterAssetTypeSchema, - EnumFilterAssetVisibilitySchema, - IdFilterNullableSchema, - IdFilterSchema, - IdsFilterSchema, - NumberFilterNullableSchema, - NumberFilterSchema, - SearchFilterBranchSchema, - SearchFilterSchema, - SearchOrderSchema, - StringFilterNullableSchema, - StringFilterSchema, - StringPatternFilterSchema, - StringSimilarityFilterSchema, -}; - export type IdFilter = z.infer; export type IdFilterNullable = z.infer; export type IdsFilter = z.infer; @@ -357,8 +337,6 @@ export type NumberFilter = z.infer; export type NumberFilterNullable = z.infer; export type DateFilter = z.infer; export type DateFilterNullable = z.infer; -export type BoolFilter = z.infer; -export type StringSimilarityFilter = z.infer; export type SearchOrder = z.infer; export type SearchFilter = z.infer; export type SearchFilterBranch = z.infer; From 32945a01b4ccc15b478e98e136b514f724cafd13 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 29 May 2026 18:52:48 +0200 Subject: [PATCH 07/16] cleanup --- server/src/dtos/search.dto.ts | 4 ++-- server/src/utils/database.ts | 24 +++--------------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 36b36de27b..1504a6e6ea 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -279,7 +279,7 @@ const StringSimilarityFilterSchema = z }) .meta({ id: 'StringSimilarityFilter' }); -const SearchOrderSchema = z +export const SearchOrderSchema = z .strictObject({ field: SearchOrderFieldSchema.default(SearchOrderField.FileCreatedAt), direction: AssetOrderSchema.default(AssetOrder.Desc), @@ -323,7 +323,7 @@ const SearchFilterBranchSchema = z }) .meta({ id: 'SearchFilterBranch' }); -const SearchFilterSchema = SearchFilterBranchSchema.extend({ +export const SearchFilterSchema = SearchFilterBranchSchema.extend({ or: z.array(SearchFilterBranchSchema).min(1).optional(), }).meta({ id: 'SearchFilter' }); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5789c8c3a4..4222f5a96e 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -558,8 +558,8 @@ const FIELD_BACKING: Record, Backing> = { }; function branchNeedsExifJoin(branch: SearchFilterBranch): boolean { - for (const key of Object.keys(FIELD_BACKING) as (keyof typeof FIELD_BACKING)[]) { - if (FIELD_BACKING[key] === 'asset_exif' && branch[key] !== undefined) { + for (const key of Object.keys(branch) as (keyof typeof FIELD_BACKING)[]) { + if (FIELD_BACKING[key] === 'asset_exif') { return true; } } @@ -598,8 +598,6 @@ function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): b */ type AssetEB = ExpressionBuilder; -// ---- EXISTS expression helpers (returned as Expression) ---- - function existsAlbumLink(eb: AssetEB, want: boolean): Expression { const e = eb.exists((eb2) => eb2.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id')); return want ? e : eb.not(e); @@ -665,8 +663,6 @@ function existsEncodedVideoPath(eb: AssetEB, f: StringFilter): Expression { @@ -747,15 +743,13 @@ function pushIdsFilter(preds: Expression[], eb: AssetEB, kind: IdsKind, preds.push(idsAnyExists(eb, kind, f.any)); } if (f.all) { - preds.push(idsAllExists(eb, kind, f.all)); + preds.push(f.all.length === 1 ? idsAnyExists(eb, kind, f.all) : idsAllExists(eb, kind, f.all)); } if (f.none) { preds.push(eb.not(idsAnyExists(eb, kind, f.none))); } } -// ---- Per-filter-family pushers ---- - function pushIdEqNe( preds: Expression[], eb: AssetEB, @@ -975,15 +969,12 @@ function pushChecksum(preds: Expression[], eb: AssetEB, f: StringFilter function buildBranchPredicates(eb: AssetEB, b: SearchFilterBranch): Expression[] { const p: Expression[] = []; - // id / libraryId pushIdEqNe(p, eb, 'asset.id', b.id); pushIdEqNe(p, eb, 'asset.libraryId', b.libraryId); - // enums pushEnum(p, eb, 'asset.type', b.type); pushEnum(p, eb, 'asset.visibility', b.visibility); - // bools on asset if (b.isFavorite) { p.push(eb('asset.isFavorite', '=', b.isFavorite.eq)); } @@ -997,7 +988,6 @@ function buildBranchPredicates(eb: AssetEB, b: SearchFilterBranch): Expression Date: Fri, 29 May 2026 23:39:39 +0200 Subject: [PATCH 08/16] sync sql --- server/src/queries/search.repository.sql | 441 +++++++++++++++++++ server/src/repositories/search.repository.ts | 16 +- 2 files changed, 450 insertions(+), 7 deletions(-) diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 3e75d88af8..bcff7e9c25 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -281,3 +281,444 @@ where and "deletedAt" is null and "lensModel" is not null and "lensModel" != $3 + +-- SearchRepository.searchMetadataV3 (baseline) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and $2 +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (empty) +select + "asset".* +from + "asset" +where + $1 +order by + "asset"."fileCreatedAt" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (or-exif-only) +select + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset_exif"."city" = $2 +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-eq-null) +select + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset_exif"."city" is null +order by + "asset"."fileCreatedAt" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (string-pattern-like) +select + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-notLike) +select + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-startsWith) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-similarity-ocr) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "ocr_search" + where + "ocr_search"."assetId" = "asset"."id" + and f_unaccent (ocr_search.text) %>> f_unaccent ($2) + ) +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-any) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-all) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "asset_face"."assetId" + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."personId" = any ($2::uuid[]) + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $3 + group by + "asset_face"."assetId" + having + count(distinct "asset_face"."personId") = $4 + ) +order by + "asset"."fileCreatedAt" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (ids-all-single) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-none) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "tag_asset" + inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" + where + "tag_asset"."assetId" = "asset"."id" + and "tag_closure"."id_ancestor" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-tags-all) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "tag_asset"."assetId" + from + "tag_asset" + inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" + where + "tag_asset"."assetId" = "asset"."id" + and "tag_closure"."id_ancestor" = any ($2::uuid[]) + group by + "tag_asset"."assetId" + having + count(distinct "tag_closure"."id_ancestor") >= $3 + ) +order by + "asset"."fileCreatedAt" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (has-albums-false) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) +order by + "asset"."fileCreatedAt" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (is-encoded) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $2 + ) +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (number-range) +select + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset_exif"."fileSizeInByte" <= $2 + and "asset_exif"."fileSizeInByte" >= $3 + ) +order by + "asset"."fileCreatedAt" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (date-eq) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset"."fileCreatedAt" = $2 +order by + "asset"."fileCreatedAt" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (date-range) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" >= $2 + and "asset"."fileCreatedAt" < $3 + ) +order by + "asset"."fileCreatedAt" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (order-fileSize-noExif) +select + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and $2 +order by + "asset_exif"."fileSizeInByte" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (order-rating-withExif) +select + to_json("asset_exif") as "exifInfo", + "asset".* +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and $2 +order by + "asset_exif"."rating" asc +limit + $3 + +-- SearchRepository.searchMetadataV3 (or-branches) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or exists ( + select + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."personId" = any ($3::uuid[]) + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $4 + ) + ) +order by + "asset"."fileCreatedAt" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (or-with-top-level) +select + "asset".* +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" >= $2 + and "asset"."fileCreatedAt" < $3 + and ( + "asset"."isFavorite" = $4 + or exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($5::uuid[]) + ) + ) + ) +order by + "asset"."fileCreatedAt" desc +limit + $6 + +-- SearchRepository.searchStatisticsV3 (baseline) +select + count(*) as "total" +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and $2 +order by + "asset"."fileCreatedAt" desc + +-- SearchRepository.searchStatisticsV3 (with-filter) +select + count(*) as "total" +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset_exif"."fileSizeInByte" >= $2 + and "asset"."fileCreatedAt" >= $3 + and "asset"."fileCreatedAt" < $4 + ) +order by + "asset"."fileCreatedAt" desc + +-- SearchRepository.searchStatisticsV3 (with-or) +select + count(*) as "total" +from + "asset" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) + ) +order by + "asset"."fileCreatedAt" desc diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index fc7a430ec6..f1c791e814 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -506,15 +506,13 @@ export class SearchRepository { return res.map((row) => row.lensModel!); } - // --------------------------------------------------------------------------- - // v3 SQL coverage scaffolding — these methods exist solely to give the SQL - // generator structurally distinct snapshots of the new `searchAssetBuilder`. - // They have no consumer yet. PR 2 rewires the legacy methods above to use - // `searchAssetBuilder` and deletes these scaffolding methods. - // --------------------------------------------------------------------------- - @GenerateSql( { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] }, + { name: 'empty', params: [{ size: 100 }, {}] }, + { + name: 'or-exif-only', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }], + }, { name: 'string-eq-null', params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }], @@ -549,6 +547,10 @@ export class SearchRepository { { userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID] } } }, ], }, + { + name: 'ids-all-single', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }], + }, { name: 'ids-none', params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }], From 5874732d0735fbdf3996923d9376d859fba62a74 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 16:58:58 +0200 Subject: [PATCH 09/16] naming --- server/src/utils/database.ts | 104 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4222f5a96e..f4ce2cd772 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -596,88 +596,88 @@ function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): b * by `exifJoinRequired`). `searchAssetBuilder` casts its `eb` into this type * because TS can't see through the conditional `.$if(needsExifJoin, …)`. */ -type AssetEB = ExpressionBuilder; +type AssetExpressionBuilder = ExpressionBuilder; -function existsAlbumLink(eb: AssetEB, want: boolean): Expression { - const e = eb.exists((eb2) => eb2.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id')); - return want ? e : eb.not(e); +function existsAlbumLink(eb: AssetExpressionBuilder, present: boolean) { + const expression = eb.exists((eb) => eb.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id')); + return present ? expression : eb.not(expression); } -function existsPersonLink(eb: AssetEB, want: boolean): Expression { - const e = eb.exists((eb2) => - eb2 +function existsPersonLink(eb: AssetExpressionBuilder, present: boolean) { + const expression = eb.exists((eb) => + eb .selectFrom('asset_face') .whereRef('asset_face.assetId', '=', 'asset.id') .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', '=', true), ); - return want ? e : eb.not(e); + return present ? expression : eb.not(expression); } -function existsTagLink(eb: AssetEB, want: boolean): Expression { - const e = eb.exists((eb2) => eb2.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id')); - return want ? e : eb.not(e); +function existsTagLink(eb: AssetExpressionBuilder, present: boolean) { + const expression = eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id')); + return present ? expression : eb.not(expression); } -function existsEncodedVideo(eb: AssetEB, want: boolean): Expression { - const e = eb.exists((eb2) => - eb2 +function existsEncodedVideo(eb: AssetExpressionBuilder, present: boolean) { + const expression = eb.exists((eb) => + eb .selectFrom('asset_file') .whereRef('asset_file.assetId', '=', 'asset.id') .where('asset_file.type', '=', AssetFileType.EncodedVideo), ); - return want ? e : eb.not(e); + return present ? expression : eb.not(expression); } -function existsOcrMatch(eb: AssetEB, matches: string): Expression { +function existsOcrMatch(eb: AssetExpressionBuilder, matches: string) { const tokens = tokenizeForSearch(matches).join(' '); - return eb.exists((eb2) => - eb2 + return eb.exists((eb) => + eb .selectFrom('ocr_search') .whereRef('ocr_search.assetId', '=', 'asset.id') .where(sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokens})`), ); } -const encodedVideoFileBase = (eb2: ExpressionBuilder) => - eb2 +const encodedVideoFileBase = (eb: ExpressionBuilder) => + eb .selectFrom('asset_file') .whereRef('asset_file.assetId', '=', 'asset.id') .where('asset_file.type', '=', AssetFileType.EncodedVideo) .where('asset_file.isEdited', '=', false); -function existsEncodedVideoPath(eb: AssetEB, f: StringFilter): Expression[] { - const out: Expression[] = []; +function existsEncodedVideoPath(eb: AssetExpressionBuilder, f: StringFilter) { + const out = []; if (f.eq !== undefined) { - out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '=', f.eq!))); + out.push(eb.exists((eb) => encodedVideoFileBase(eb).where('asset_file.path', '=', f.eq!))); } if (f.ne !== undefined) { - out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '<>', f.ne!))); + out.push(eb.exists((eb) => encodedVideoFileBase(eb).where('asset_file.path', '<>', f.ne!))); } if (f.in !== undefined) { - out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'in', f.in!))); + out.push(eb.exists((eb) => encodedVideoFileBase(eb).where('asset_file.path', 'in', f.in!))); } if (f.notIn !== undefined) { - out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'not in', f.notIn!))); + out.push(eb.exists((eb) => encodedVideoFileBase(eb).where('asset_file.path', 'not in', f.notIn!))); } return out; } type IdsKind = 'album' | 'person' | 'tag'; -function idsAnyExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression { +function idsAnyExists(eb: AssetExpressionBuilder, kind: IdsKind, ids: string[]) { switch (kind) { case 'album': { - return eb.exists((eb2) => - eb2 + return eb.exists((eb) => + eb .selectFrom('album_asset') .whereRef('album_asset.assetId', '=', 'asset.id') .where('album_asset.albumId', '=', anyUuid(ids)), ); } case 'person': { - return eb.exists((eb2) => - eb2 + return eb.exists((eb) => + eb .selectFrom('asset_face') .whereRef('asset_face.assetId', '=', 'asset.id') .where('asset_face.personId', '=', anyUuid(ids)) @@ -686,8 +686,8 @@ function idsAnyExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression - eb2 + return eb.exists((eb) => + eb .selectFrom('tag_asset') .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') .whereRef('tag_asset.assetId', '=', 'asset.id') @@ -697,22 +697,22 @@ function idsAnyExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression { +function idsAllExists(eb: AssetExpressionBuilder, kind: IdsKind, ids: string[]) { switch (kind) { case 'album': { - return eb.exists((eb2) => - eb2 + return eb.exists((eb) => + eb .selectFrom('album_asset') .select('album_asset.assetId') .whereRef('album_asset.assetId', '=', 'asset.id') .where('album_asset.albumId', '=', anyUuid(ids)) .groupBy('album_asset.assetId') - .having((e3) => e3.fn.count('album_asset.albumId').distinct(), '=', ids.length), + .having((eb) => eb.fn.count('album_asset.albumId').distinct(), '=', ids.length), ); } case 'person': { - return eb.exists((eb2) => - eb2 + return eb.exists((eb) => + eb .selectFrom('asset_face') .select('asset_face.assetId') .whereRef('asset_face.assetId', '=', 'asset.id') @@ -720,25 +720,25 @@ function idsAllExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression e3.fn.count('asset_face.personId').distinct(), '=', ids.length), + .having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length), ); } case 'tag': { - return eb.exists((eb2) => - eb2 + return eb.exists((eb) => + eb .selectFrom('tag_asset') .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') .select('tag_asset.assetId') .whereRef('tag_asset.assetId', '=', 'asset.id') .where('tag_closure.id_ancestor', '=', anyUuid(ids)) .groupBy('tag_asset.assetId') - .having((e3) => e3.fn.count('tag_closure.id_ancestor').distinct(), '>=', ids.length), + .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', ids.length), ); } } } -function pushIdsFilter(preds: Expression[], eb: AssetEB, kind: IdsKind, f: IdsFilter) { +function pushIdsFilter(preds: Expression[], eb: AssetExpressionBuilder, kind: IdsKind, f: IdsFilter) { if (f.any) { preds.push(idsAnyExists(eb, kind, f.any)); } @@ -752,7 +752,7 @@ function pushIdsFilter(preds: Expression[], eb: AssetEB, kind: IdsKind, function pushIdEqNe( preds: Expression[], - eb: AssetEB, + eb: AssetExpressionBuilder, column: 'asset.id' | 'asset.libraryId', f: IdFilter | IdFilterNullable | undefined, ) { @@ -773,7 +773,7 @@ function pushIdEqNe( function pushEnum( preds: Expression[], - eb: AssetEB, + eb: AssetExpressionBuilder, column: 'asset.type' | 'asset.visibility', f: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined, ) { @@ -810,7 +810,7 @@ type StringColumn = function pushStringEqNeInNotIn( preds: Expression[], - eb: AssetEB, + eb: AssetExpressionBuilder, column: StringColumn, f: StringFilterNullable | StringPatternFilter | undefined, ) { @@ -837,7 +837,7 @@ function pushStringEqNeInNotIn( function pushStringPattern( preds: Expression[], - eb: AssetEB, + eb: AssetExpressionBuilder, column: StringColumn, f: StringPatternFilter | undefined, ) { @@ -864,7 +864,7 @@ type NumberColumn = 'asset_exif.rating' | 'asset_exif.fileSizeInByte'; function pushNumber( preds: Expression[], - eb: AssetEB, + eb: AssetExpressionBuilder, column: NumberColumn, f: NumberFilter | NumberFilterNullable | undefined, ) { @@ -905,7 +905,7 @@ type DateColumn = 'asset.fileCreatedAt' | 'asset.createdAt' | 'asset.updatedAt' function pushDate( preds: Expression[], - eb: AssetEB, + eb: AssetExpressionBuilder, column: DateColumn, f: DateFilter | DateFilterNullable | undefined, ) { @@ -936,7 +936,7 @@ function pushDate( } } -function pushChecksum(preds: Expression[], eb: AssetEB, f: StringFilter | undefined) { +function pushChecksum(preds: Expression[], eb: AssetExpressionBuilder, f: StringFilter | undefined) { if (!f) { return; } @@ -966,7 +966,7 @@ function pushChecksum(preds: Expression[], eb: AssetEB, f: StringFilter } } -function buildBranchPredicates(eb: AssetEB, b: SearchFilterBranch): Expression[] { +function buildBranchPredicates(eb: AssetExpressionBuilder, b: SearchFilterBranch) { const p: Expression[] = []; pushIdEqNe(p, eb, 'asset.id', b.id); From 227c1e62165f880a6dfc47421ffc6ca32894ae21 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 16:59:05 +0200 Subject: [PATCH 10/16] clean comments --- server/src/dtos/search.dto.ts | 2 -- server/src/utils/database.ts | 29 ++--------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 1504a6e6ea..b8c8464203 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -148,8 +148,6 @@ const SearchSuggestionRequestSchema = z }) .meta({ id: 'SearchSuggestionRequestDto' }); -// v3 SearchFilter DTOs — new shape introduced alongside the legacy flat DTOs above. - const atLeastOneKey = (schema: T) => { const keys = Object.keys(schema.shape); return schema.refine((value) => Object.values(value).some((v) => v !== undefined), { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f4ce2cd772..70c126e41e 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -509,19 +509,8 @@ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearc .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } -/** - * Join strategy each `SearchFilterBranch` field needs against the database. - * - `asset`: column on the `asset` table; simple WHERE; no join. - * - `asset_exif`: column on `asset_exif`; inner join - * - `asset_file`: column on `asset_file`; SQL is field-dependent - * - `ocr_search`: specialised trigram-indexed search column - * - `membership`: not a column; junction-table membership; SQL is operator-dependent - */ type Backing = 'asset' | 'asset_exif' | 'asset_file' | 'ocr_search' | 'membership'; -/** - * Exhaustive `SearchFilterBranch` backing map - */ const FIELD_BACKING: Record, Backing> = { id: 'asset', libraryId: 'asset', @@ -566,9 +555,6 @@ function branchNeedsExifJoin(branch: SearchFilterBranch): boolean { return false; } -/** - * Exhaustive `SearchOrderField` backing map - */ const ORDER_BACKING = { [SearchOrderField.FileCreatedAt]: 'asset', [SearchOrderField.LocalDateTime]: 'asset', @@ -576,9 +562,6 @@ const ORDER_BACKING = { [SearchOrderField.Rating]: 'asset_exif', } satisfies Record; -/** - * `asset_exif` join is needed when either any filter or order field needs `asset_exif` - */ function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): boolean { if (ORDER_BACKING[orderField] === 'asset_exif') { return true; @@ -589,13 +572,6 @@ function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): b return filter.or?.some((branch) => branchNeedsExifJoin(branch)) ?? false; } -/** - * EB type used by `buildBranchPredicates`. The runtime invariant is that - * whenever a predicate references an `asset_exif` column, the `asset_exif` - * join has already been planted at the top of the builder chain (guaranteed - * by `exifJoinRequired`). `searchAssetBuilder` casts its `eb` into this type - * because TS can't see through the conditional `.$if(needsExifJoin, …)`. - */ type AssetExpressionBuilder = ExpressionBuilder; function existsAlbumLink(eb: AssetExpressionBuilder, present: boolean) { @@ -780,9 +756,7 @@ function pushEnum( if (!f) { return; } - // Values cast: column type unions across asset.type / asset.visibility resolve - // to `AssetType | AssetVisibility` and TS can't narrow to the caller's T. - // The caller side (the SearchFilter enum schemas) is what guarantees validity. + // `as never`: kysely's column-union type can't narrow to T; SearchFilter enum schemas validate at the boundary. if (f.eq !== undefined) { preds.push(eb(column, '=', f.eq as never)); } @@ -1086,6 +1060,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild return top.length > 0 ? eb.and(top) : eb.val(true); }) .$call((qb) => + // cast: `.$if(needsExifJoin, ...)` doesn't carry the join into the type; `exifJoinRequired` guarantees it at runtime. applySearchOrder(qb as SelectQueryBuilder, orderField, orderDirection), ); } From dcf32fe20cdd87fbc37fddd4d1a93b7da03e7f92 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 17:02:04 +0200 Subject: [PATCH 11/16] reverse table map to sets --- server/src/utils/database.ts | 70 ++++++++++-------------------------- 1 file changed, 19 insertions(+), 51 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 70c126e41e..8e76165633 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -509,67 +509,35 @@ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearc .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } -type Backing = 'asset' | 'asset_exif' | 'asset_file' | 'ocr_search' | 'membership'; +const EXIF_FILTER_FIELDS = new Set([ + 'city', + 'state', + 'country', + 'make', + 'model', + 'lensModel', + 'description', + 'rating', + 'fileSizeInBytes', +]); -const FIELD_BACKING: Record, Backing> = { - id: 'asset', - libraryId: 'asset', - type: 'asset', - visibility: 'asset', - isFavorite: 'asset', - isMotion: 'asset', - isOffline: 'asset', - isEncoded: 'asset_file', - hasAlbums: 'membership', - hasPeople: 'membership', - hasTags: 'membership', - city: 'asset_exif', - state: 'asset_exif', - country: 'asset_exif', - make: 'asset_exif', - model: 'asset_exif', - lensModel: 'asset_exif', - description: 'asset_exif', - originalFileName: 'asset', - originalPath: 'asset', - ocr: 'ocr_search', - rating: 'asset_exif', - fileSizeInBytes: 'asset_exif', - takenAt: 'asset', - createdAt: 'asset', - updatedAt: 'asset', - trashedAt: 'asset', - personIds: 'membership', - tagIds: 'membership', - albumIds: 'membership', - checksum: 'asset', - encodedVideoPath: 'asset_file', -}; +const EXIF_ORDER_FIELDS = new Set([SearchOrderField.FileSizeInBytes, SearchOrderField.Rating]); function branchNeedsExifJoin(branch: SearchFilterBranch): boolean { - for (const key of Object.keys(branch) as (keyof typeof FIELD_BACKING)[]) { - if (FIELD_BACKING[key] === 'asset_exif') { + for (const key of Object.keys(branch) as (keyof SearchFilterBranch)[]) { + if (EXIF_FILTER_FIELDS.has(key)) { return true; } } return false; } -const ORDER_BACKING = { - [SearchOrderField.FileCreatedAt]: 'asset', - [SearchOrderField.LocalDateTime]: 'asset', - [SearchOrderField.FileSizeInBytes]: 'asset_exif', - [SearchOrderField.Rating]: 'asset_exif', -} satisfies Record; - function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): boolean { - if (ORDER_BACKING[orderField] === 'asset_exif') { - return true; - } - if (branchNeedsExifJoin(filter)) { - return true; - } - return filter.or?.some((branch) => branchNeedsExifJoin(branch)) ?? false; + return ( + EXIF_ORDER_FIELDS.has(orderField) || + branchNeedsExifJoin(filter) || + (filter.or?.some((branch) => branchNeedsExifJoin(branch)) ?? false) + ); } type AssetExpressionBuilder = ExpressionBuilder; From ea45af02a59d8c28b95198460190528ce25b496b Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 17:14:32 +0200 Subject: [PATCH 12/16] rename idskind --- server/src/utils/database.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 8e76165633..d7de2017b4 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -607,10 +607,10 @@ function existsEncodedVideoPath(eb: AssetExpressionBuilder, f: StringFilter) { return out; } -type IdsKind = 'album' | 'person' | 'tag'; +type Membership = 'album' | 'person' | 'tag'; -function idsAnyExists(eb: AssetExpressionBuilder, kind: IdsKind, ids: string[]) { - switch (kind) { +function idsAnyExists(eb: AssetExpressionBuilder, membership: Membership, ids: string[]) { + switch (membership) { case 'album': { return eb.exists((eb) => eb @@ -641,8 +641,8 @@ function idsAnyExists(eb: AssetExpressionBuilder, kind: IdsKind, ids: string[]) } } -function idsAllExists(eb: AssetExpressionBuilder, kind: IdsKind, ids: string[]) { - switch (kind) { +function idsAllExists(eb: AssetExpressionBuilder, membership: Membership, ids: string[]) { + switch (membership) { case 'album': { return eb.exists((eb) => eb @@ -682,15 +682,22 @@ function idsAllExists(eb: AssetExpressionBuilder, kind: IdsKind, ids: string[]) } } -function pushIdsFilter(preds: Expression[], eb: AssetExpressionBuilder, kind: IdsKind, f: IdsFilter) { - if (f.any) { - preds.push(idsAnyExists(eb, kind, f.any)); +function pushIdsFilter( + preds: Expression[], + eb: AssetExpressionBuilder, + membership: Membership, + filter: IdsFilter, +) { + if (filter.any) { + preds.push(idsAnyExists(eb, membership, filter.any)); } - if (f.all) { - preds.push(f.all.length === 1 ? idsAnyExists(eb, kind, f.all) : idsAllExists(eb, kind, f.all)); + if (filter.all) { + preds.push( + filter.all.length === 1 ? idsAnyExists(eb, membership, filter.all) : idsAllExists(eb, membership, filter.all), + ); } - if (f.none) { - preds.push(eb.not(idsAnyExists(eb, kind, f.none))); + if (filter.none) { + preds.push(eb.not(idsAnyExists(eb, membership, filter.none))); } } From db6ab7e37fd8b4e320a864fe79f41485f16e6171 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 17:42:58 +0200 Subject: [PATCH 13/16] spread predicates --- server/src/utils/database.ts | 372 ++++++++++++++++------------------- 1 file changed, 165 insertions(+), 207 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index d7de2017b4..6c2eeed2a0 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -682,68 +682,70 @@ function idsAllExists(eb: AssetExpressionBuilder, membership: Membership, ids: s } } -function pushIdsFilter( - preds: Expression[], - eb: AssetExpressionBuilder, - membership: Membership, - filter: IdsFilter, -) { +function idsPredicates(eb: AssetExpressionBuilder, membership: Membership, filter: IdsFilter | undefined) { + if (!filter) { + return []; + } + const predicates: Expression[] = []; if (filter.any) { - preds.push(idsAnyExists(eb, membership, filter.any)); + predicates.push(idsAnyExists(eb, membership, filter.any)); } if (filter.all) { - preds.push( + predicates.push( filter.all.length === 1 ? idsAnyExists(eb, membership, filter.all) : idsAllExists(eb, membership, filter.all), ); } if (filter.none) { - preds.push(eb.not(idsAnyExists(eb, membership, filter.none))); + predicates.push(eb.not(idsAnyExists(eb, membership, filter.none))); } + return predicates; } -function pushIdEqNe( - preds: Expression[], +function idPredicates( eb: AssetExpressionBuilder, column: 'asset.id' | 'asset.libraryId', - f: IdFilter | IdFilterNullable | undefined, + filter: IdFilter | IdFilterNullable | undefined, ) { - if (!f) { - return; + if (!filter) { + return []; } - if (f.eq === null) { - preds.push(eb(column, 'is', null)); - } else if (f.eq !== undefined) { - preds.push(eb(column, '=', asUuid(f.eq))); + const predicates: Expression[] = []; + if (filter.eq === null) { + predicates.push(eb(column, 'is', null)); + } else if (filter.eq !== undefined) { + predicates.push(eb(column, '=', asUuid(filter.eq))); } - if (f.ne === null) { - preds.push(eb(column, 'is not', null)); - } else if (f.ne !== undefined) { - preds.push(eb(column, '<>', asUuid(f.ne))); + if (filter.ne === null) { + predicates.push(eb(column, 'is not', null)); + } else if (filter.ne !== undefined) { + predicates.push(eb(column, '<>', asUuid(filter.ne))); } + return predicates; } -function pushEnum( - preds: Expression[], +function enumPredicates( eb: AssetExpressionBuilder, column: 'asset.type' | 'asset.visibility', - f: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined, + filter: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined, ) { - if (!f) { - return; + if (!filter) { + return []; } // `as never`: kysely's column-union type can't narrow to T; SearchFilter enum schemas validate at the boundary. - if (f.eq !== undefined) { - preds.push(eb(column, '=', f.eq as never)); + const predicates: Expression[] = []; + if (filter.eq !== undefined) { + predicates.push(eb(column, '=', filter.eq as never)); } - if (f.ne !== undefined) { - preds.push(eb(column, '<>', f.ne as never)); + if (filter.ne !== undefined) { + predicates.push(eb(column, '<>', filter.ne as never)); } - if (f.in !== undefined) { - preds.push(eb(column, 'in', f.in as never)); + if (filter.in !== undefined) { + predicates.push(eb(column, 'in', filter.in as never)); } - if (f.notIn !== undefined) { - preds.push(eb(column, 'not in', f.notIn as never)); + if (filter.notIn !== undefined) { + predicates.push(eb(column, 'not in', filter.notIn as never)); } + return predicates; } type StringColumn = @@ -757,236 +759,192 @@ type StringColumn = | 'asset.originalFileName' | 'asset.originalPath'; -function pushStringEqNeInNotIn( - preds: Expression[], +function stringEqNeInPredicates( eb: AssetExpressionBuilder, column: StringColumn, - f: StringFilterNullable | StringPatternFilter | undefined, + filter: StringFilterNullable | StringPatternFilter | undefined, ) { - if (!f) { - return; + if (!filter) { + return []; } - if (f.eq === null) { - preds.push(eb(column, 'is', null)); - } else if (f.eq !== undefined) { - preds.push(eb(column, '=', f.eq)); + const predicates: Expression[] = []; + if (filter.eq === null) { + predicates.push(eb(column, 'is', null)); + } else if (filter.eq !== undefined) { + predicates.push(eb(column, '=', filter.eq)); } - if (f.ne === null) { - preds.push(eb(column, 'is not', null)); - } else if (f.ne !== undefined) { - preds.push(eb(column, '<>', f.ne)); + if (filter.ne === null) { + predicates.push(eb(column, 'is not', null)); + } else if (filter.ne !== undefined) { + predicates.push(eb(column, '<>', filter.ne)); } - if (f.in !== undefined) { - preds.push(eb(column, 'in', f.in)); + if (filter.in !== undefined) { + predicates.push(eb(column, 'in', filter.in)); } - if (f.notIn !== undefined) { - preds.push(eb(column, 'not in', f.notIn)); + if (filter.notIn !== undefined) { + predicates.push(eb(column, 'not in', filter.notIn)); } + return predicates; } -function pushStringPattern( - preds: Expression[], +function stringPatternPredicates( eb: AssetExpressionBuilder, column: StringColumn, - f: StringPatternFilter | undefined, + filter: StringPatternFilter | undefined, ) { - if (!f) { - return; + if (!filter) { + return []; } - pushStringEqNeInNotIn(preds, eb, column, f); + const predicates: Expression[] = stringEqNeInPredicates(eb, column, filter); const ref = sql.ref(column); - if (f.like !== undefined) { - preds.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.like}) || '%')`); + if (filter.like !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`); } - if (f.notLike !== undefined) { - preds.push(sql`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${f.notLike}) || '%')`); + if (filter.notLike !== undefined) { + predicates.push(sql`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`); } - if (f.startsWith !== undefined) { - preds.push(sql`f_unaccent(${ref}) ilike (f_unaccent(${f.startsWith}) || '%')`); + if (filter.startsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`); } - if (f.endsWith !== undefined) { - preds.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.endsWith}))`); + if (filter.endsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.endsWith}))`); } + return predicates; } type NumberColumn = 'asset_exif.rating' | 'asset_exif.fileSizeInByte'; -function pushNumber( - preds: Expression[], +function numberPredicates( eb: AssetExpressionBuilder, column: NumberColumn, - f: NumberFilter | NumberFilterNullable | undefined, + filter: NumberFilter | NumberFilterNullable | undefined, ) { - if (!f) { - return; + if (!filter) { + return []; } - if (f.eq === null) { - preds.push(eb(column, 'is', null)); - } else if (f.eq !== undefined) { - preds.push(eb(column, '=', f.eq)); + const predicates: Expression[] = []; + if (filter.eq === null) { + predicates.push(eb(column, 'is', null)); + } else if (filter.eq !== undefined) { + predicates.push(eb(column, '=', filter.eq)); } - if (f.ne === null) { - preds.push(eb(column, 'is not', null)); - } else if (f.ne !== undefined) { - preds.push(eb(column, '<>', f.ne)); + if (filter.ne === null) { + predicates.push(eb(column, 'is not', null)); + } else if (filter.ne !== undefined) { + predicates.push(eb(column, '<>', filter.ne)); } - if (f.lt !== undefined) { - preds.push(eb(column, '<', f.lt)); + if (filter.lt !== undefined) { + predicates.push(eb(column, '<', filter.lt)); } - if (f.lte !== undefined) { - preds.push(eb(column, '<=', f.lte)); + if (filter.lte !== undefined) { + predicates.push(eb(column, '<=', filter.lte)); } - if (f.gt !== undefined) { - preds.push(eb(column, '>', f.gt)); + if (filter.gt !== undefined) { + predicates.push(eb(column, '>', filter.gt)); } - if (f.gte !== undefined) { - preds.push(eb(column, '>=', f.gte)); + if (filter.gte !== undefined) { + predicates.push(eb(column, '>=', filter.gte)); } - if (f.in !== undefined) { - preds.push(eb(column, 'in', f.in)); + if (filter.in !== undefined) { + predicates.push(eb(column, 'in', filter.in)); } - if (f.notIn !== undefined) { - preds.push(eb(column, 'not in', f.notIn)); + if (filter.notIn !== undefined) { + predicates.push(eb(column, 'not in', filter.notIn)); } + return predicates; } type DateColumn = 'asset.fileCreatedAt' | 'asset.createdAt' | 'asset.updatedAt' | 'asset.deletedAt'; -function pushDate( - preds: Expression[], +function datePredicates( eb: AssetExpressionBuilder, column: DateColumn, - f: DateFilter | DateFilterNullable | undefined, + filter: DateFilter | DateFilterNullable | undefined, ) { - if (!f) { - return; + if (!filter) { + return []; } - if (f.eq === null) { - preds.push(eb(column, 'is', null)); - } else if (f.eq !== undefined) { - preds.push(eb(column, '=', f.eq)); + const predicates: Expression[] = []; + if (filter.eq === null) { + predicates.push(eb(column, 'is', null)); + } else if (filter.eq !== undefined) { + predicates.push(eb(column, '=', filter.eq)); } - if (f.ne === null) { - preds.push(eb(column, 'is not', null)); - } else if (f.ne !== undefined) { - preds.push(eb(column, '<>', f.ne)); + if (filter.ne === null) { + predicates.push(eb(column, 'is not', null)); + } else if (filter.ne !== undefined) { + predicates.push(eb(column, '<>', filter.ne)); } - if (f.gt !== undefined) { - preds.push(eb(column, '>', f.gt)); + if (filter.gt !== undefined) { + predicates.push(eb(column, '>', filter.gt)); } - if (f.gte !== undefined) { - preds.push(eb(column, '>=', f.gte)); + if (filter.gte !== undefined) { + predicates.push(eb(column, '>=', filter.gte)); } - if (f.lt !== undefined) { - preds.push(eb(column, '<', f.lt)); + if (filter.lt !== undefined) { + predicates.push(eb(column, '<', filter.lt)); } - if (f.lte !== undefined) { - preds.push(eb(column, '<=', f.lte)); + if (filter.lte !== undefined) { + predicates.push(eb(column, '<=', filter.lte)); } + return predicates; } -function pushChecksum(preds: Expression[], eb: AssetExpressionBuilder, f: StringFilter | undefined) { - if (!f) { - return; +function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter | undefined) { + if (!filter) { + return []; } - if (f.eq !== undefined) { - preds.push(eb('asset.checksum', '=', fromChecksum(f.eq))); + const predicates: Expression[] = []; + if (filter.eq !== undefined) { + predicates.push(eb('asset.checksum', '=', fromChecksum(filter.eq))); } - if (f.ne !== undefined) { - preds.push(eb('asset.checksum', '<>', fromChecksum(f.ne))); + if (filter.ne !== undefined) { + predicates.push(eb('asset.checksum', '<>', fromChecksum(filter.ne))); } - if (f.in !== undefined) { - preds.push( - eb( - 'asset.checksum', - 'in', - f.in.map((c: string) => fromChecksum(c)), - ), - ); + if (filter.in !== undefined) { + predicates.push(eb('asset.checksum', 'in', filter.in.map(fromChecksum))); } - if (f.notIn !== undefined) { - preds.push( - eb( - 'asset.checksum', - 'not in', - f.notIn.map((c: string) => fromChecksum(c)), - ), - ); + if (filter.notIn !== undefined) { + predicates.push(eb('asset.checksum', 'not in', filter.notIn.map(fromChecksum))); } + return predicates; } -function buildBranchPredicates(eb: AssetExpressionBuilder, b: SearchFilterBranch) { - const p: Expression[] = []; - - pushIdEqNe(p, eb, 'asset.id', b.id); - pushIdEqNe(p, eb, 'asset.libraryId', b.libraryId); - - pushEnum(p, eb, 'asset.type', b.type); - pushEnum(p, eb, 'asset.visibility', b.visibility); - - if (b.isFavorite) { - p.push(eb('asset.isFavorite', '=', b.isFavorite.eq)); - } - if (b.isOffline) { - p.push(eb('asset.isOffline', '=', b.isOffline.eq)); - } - if (b.isMotion) { - p.push(eb('asset.livePhotoVideoId', b.isMotion.eq ? 'is not' : 'is', null)); - } - if (b.isEncoded) { - p.push(existsEncodedVideo(eb, b.isEncoded.eq)); - } - - if (b.hasAlbums) { - p.push(existsAlbumLink(eb, b.hasAlbums.eq)); - } - if (b.hasPeople) { - p.push(existsPersonLink(eb, b.hasPeople.eq)); - } - if (b.hasTags) { - p.push(existsTagLink(eb, b.hasTags.eq)); - } - - pushStringEqNeInNotIn(p, eb, 'asset_exif.city', b.city); - pushStringEqNeInNotIn(p, eb, 'asset_exif.state', b.state); - pushStringEqNeInNotIn(p, eb, 'asset_exif.country', b.country); - pushStringEqNeInNotIn(p, eb, 'asset_exif.make', b.make); - pushStringEqNeInNotIn(p, eb, 'asset_exif.model', b.model); - pushStringEqNeInNotIn(p, eb, 'asset_exif.lensModel', b.lensModel); - - pushStringPattern(p, eb, 'asset_exif.description', b.description); - pushStringPattern(p, eb, 'asset.originalFileName', b.originalFileName); - pushStringPattern(p, eb, 'asset.originalPath', b.originalPath); - - if (b.ocr) { - p.push(existsOcrMatch(eb, b.ocr.matches)); - } - - pushNumber(p, eb, 'asset_exif.rating', b.rating); - pushNumber(p, eb, 'asset_exif.fileSizeInByte', b.fileSizeInBytes); - - pushDate(p, eb, 'asset.fileCreatedAt', b.takenAt); - pushDate(p, eb, 'asset.createdAt', b.createdAt); - pushDate(p, eb, 'asset.updatedAt', b.updatedAt); - pushDate(p, eb, 'asset.deletedAt', b.trashedAt); - - if (b.albumIds) { - pushIdsFilter(p, eb, 'album', b.albumIds); - } - if (b.personIds) { - pushIdsFilter(p, eb, 'person', b.personIds); - } - if (b.tagIds) { - pushIdsFilter(p, eb, 'tag', b.tagIds); - } - - pushChecksum(p, eb, b.checksum); - - if (b.encodedVideoPath) { - p.push(...existsEncodedVideoPath(eb, b.encodedVideoPath)); - } - - return p; +function buildBranchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) { + return [ + ...idPredicates(eb, 'asset.id', branch.id), + ...idPredicates(eb, 'asset.libraryId', branch.libraryId), + ...enumPredicates(eb, 'asset.type', branch.type), + ...enumPredicates(eb, 'asset.visibility', branch.visibility), + ...(branch.isFavorite ? [eb('asset.isFavorite', '=', branch.isFavorite.eq)] : []), + ...(branch.isOffline ? [eb('asset.isOffline', '=', branch.isOffline.eq)] : []), + ...(branch.isMotion ? [eb('asset.livePhotoVideoId', branch.isMotion.eq ? 'is not' : 'is', null)] : []), + ...(branch.isEncoded ? [existsEncodedVideo(eb, branch.isEncoded.eq)] : []), + ...(branch.hasAlbums ? [existsAlbumLink(eb, branch.hasAlbums.eq)] : []), + ...(branch.hasPeople ? [existsPersonLink(eb, branch.hasPeople.eq)] : []), + ...(branch.hasTags ? [existsTagLink(eb, branch.hasTags.eq)] : []), + ...stringEqNeInPredicates(eb, 'asset_exif.city', branch.city), + ...stringEqNeInPredicates(eb, 'asset_exif.state', branch.state), + ...stringEqNeInPredicates(eb, 'asset_exif.country', branch.country), + ...stringEqNeInPredicates(eb, 'asset_exif.make', branch.make), + ...stringEqNeInPredicates(eb, 'asset_exif.model', branch.model), + ...stringEqNeInPredicates(eb, 'asset_exif.lensModel', branch.lensModel), + ...stringPatternPredicates(eb, 'asset_exif.description', branch.description), + ...stringPatternPredicates(eb, 'asset.originalFileName', branch.originalFileName), + ...stringPatternPredicates(eb, 'asset.originalPath', branch.originalPath), + ...(branch.ocr ? [existsOcrMatch(eb, branch.ocr.matches)] : []), + ...numberPredicates(eb, 'asset_exif.rating', branch.rating), + ...numberPredicates(eb, 'asset_exif.fileSizeInByte', branch.fileSizeInBytes), + ...datePredicates(eb, 'asset.fileCreatedAt', branch.takenAt), + ...datePredicates(eb, 'asset.createdAt', branch.createdAt), + ...datePredicates(eb, 'asset.updatedAt', branch.updatedAt), + ...datePredicates(eb, 'asset.deletedAt', branch.trashedAt), + ...idsPredicates(eb, 'album', branch.albumIds), + ...idsPredicates(eb, 'person', branch.personIds), + ...idsPredicates(eb, 'tag', branch.tagIds), + ...checksumPredicates(eb, branch.checksum), + ...(branch.encodedVideoPath ? existsEncodedVideoPath(eb, branch.encodedVideoPath) : []), + ]; } function applySearchOrder( From 37b5d68be23d1ffcbbaae5b581d2ea41b4a1b273 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 17:43:08 +0200 Subject: [PATCH 14/16] drop undefined filters --- server/src/utils/database.ts | 48 ++++++------------------------------ 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 6c2eeed2a0..94113d4b04 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -682,10 +682,7 @@ function idsAllExists(eb: AssetExpressionBuilder, membership: Membership, ids: s } } -function idsPredicates(eb: AssetExpressionBuilder, membership: Membership, filter: IdsFilter | undefined) { - if (!filter) { - return []; - } +function idsPredicates(eb: AssetExpressionBuilder, membership: Membership, filter: IdsFilter = {}) { const predicates: Expression[] = []; if (filter.any) { predicates.push(idsAnyExists(eb, membership, filter.any)); @@ -704,11 +701,8 @@ function idsPredicates(eb: AssetExpressionBuilder, membership: Membership, filte function idPredicates( eb: AssetExpressionBuilder, column: 'asset.id' | 'asset.libraryId', - filter: IdFilter | IdFilterNullable | undefined, + filter: IdFilter | IdFilterNullable = {}, ) { - if (!filter) { - return []; - } const predicates: Expression[] = []; if (filter.eq === null) { predicates.push(eb(column, 'is', null)); @@ -726,11 +720,8 @@ function idPredicates( function enumPredicates( eb: AssetExpressionBuilder, column: 'asset.type' | 'asset.visibility', - filter: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined, + filter: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } = {}, ) { - if (!filter) { - return []; - } // `as never`: kysely's column-union type can't narrow to T; SearchFilter enum schemas validate at the boundary. const predicates: Expression[] = []; if (filter.eq !== undefined) { @@ -762,11 +753,8 @@ type StringColumn = function stringEqNeInPredicates( eb: AssetExpressionBuilder, column: StringColumn, - filter: StringFilterNullable | StringPatternFilter | undefined, + filter: StringFilterNullable | StringPatternFilter = {}, ) { - if (!filter) { - return []; - } const predicates: Expression[] = []; if (filter.eq === null) { predicates.push(eb(column, 'is', null)); @@ -787,14 +775,7 @@ function stringEqNeInPredicates( return predicates; } -function stringPatternPredicates( - eb: AssetExpressionBuilder, - column: StringColumn, - filter: StringPatternFilter | undefined, -) { - if (!filter) { - return []; - } +function stringPatternPredicates(eb: AssetExpressionBuilder, column: StringColumn, filter: StringPatternFilter = {}) { const predicates: Expression[] = stringEqNeInPredicates(eb, column, filter); const ref = sql.ref(column); if (filter.like !== undefined) { @@ -817,11 +798,8 @@ type NumberColumn = 'asset_exif.rating' | 'asset_exif.fileSizeInByte'; function numberPredicates( eb: AssetExpressionBuilder, column: NumberColumn, - filter: NumberFilter | NumberFilterNullable | undefined, + filter: NumberFilter | NumberFilterNullable = {}, ) { - if (!filter) { - return []; - } const predicates: Expression[] = []; if (filter.eq === null) { predicates.push(eb(column, 'is', null)); @@ -856,14 +834,7 @@ function numberPredicates( type DateColumn = 'asset.fileCreatedAt' | 'asset.createdAt' | 'asset.updatedAt' | 'asset.deletedAt'; -function datePredicates( - eb: AssetExpressionBuilder, - column: DateColumn, - filter: DateFilter | DateFilterNullable | undefined, -) { - if (!filter) { - return []; - } +function datePredicates(eb: AssetExpressionBuilder, column: DateColumn, filter: DateFilter | DateFilterNullable = {}) { const predicates: Expression[] = []; if (filter.eq === null) { predicates.push(eb(column, 'is', null)); @@ -890,10 +861,7 @@ function datePredicates( return predicates; } -function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter | undefined) { - if (!filter) { - return []; - } +function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter = {}) { const predicates: Expression[] = []; if (filter.eq !== undefined) { predicates.push(eb('asset.checksum', '=', fromChecksum(filter.eq))); From 35c850ddbcacff5654792a27c5e82cbeffaa30f6 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 17:54:17 +0200 Subject: [PATCH 15/16] enhance type safety for enum predicates --- server/src/utils/database.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 94113d4b04..9abe510196 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -36,6 +36,7 @@ import { AssetFileType, AssetOrder, AssetOrderBy, + AssetType, AssetVisibility, DatabaseExtension, ExifOrientation, @@ -717,12 +718,17 @@ function idPredicates( return predicates; } -function enumPredicates( +type EnumColumn = { + 'asset.type': AssetType; + 'asset.visibility': AssetVisibility; +}; + +function enumPredicates( eb: AssetExpressionBuilder, - column: 'asset.type' | 'asset.visibility', - filter: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } = {}, + column: C, + filter: { eq?: EnumColumn[C]; ne?: EnumColumn[C]; in?: EnumColumn[C][]; notIn?: EnumColumn[C][] } = {}, ) { - // `as never`: kysely's column-union type can't narrow to T; SearchFilter enum schemas validate at the boundary. + // casts: kysely's `eb` doesn't distribute its column-value narrowing through the generic const predicates: Expression[] = []; if (filter.eq !== undefined) { predicates.push(eb(column, '=', filter.eq as never)); From b8b802799e0a66a0c494a5f8e08b5833a6424e63 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 2 Jun 2026 18:00:44 +0200 Subject: [PATCH 16/16] alternative enum handling --- server/src/utils/database.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 9abe510196..ca8abddfea 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -723,24 +723,25 @@ type EnumColumn = { 'asset.visibility': AssetVisibility; }; -function enumPredicates( +type EnumValue = EnumColumn[keyof EnumColumn]; + +function enumPredicates( eb: AssetExpressionBuilder, - column: C, - filter: { eq?: EnumColumn[C]; ne?: EnumColumn[C]; in?: EnumColumn[C][]; notIn?: EnumColumn[C][] } = {}, + column: keyof EnumColumn, + filter: { eq?: EnumValue; ne?: EnumValue; in?: EnumValue[]; notIn?: EnumValue[] } = {}, ) { - // casts: kysely's `eb` doesn't distribute its column-value narrowing through the generic const predicates: Expression[] = []; if (filter.eq !== undefined) { - predicates.push(eb(column, '=', filter.eq as never)); + predicates.push(eb(column, '=', filter.eq)); } if (filter.ne !== undefined) { - predicates.push(eb(column, '<>', filter.ne as never)); + predicates.push(eb(column, '<>', filter.ne)); } if (filter.in !== undefined) { - predicates.push(eb(column, 'in', filter.in as never)); + predicates.push(eb(column, 'in', filter.in)); } if (filter.notIn !== undefined) { - predicates.push(eb(column, 'not in', filter.notIn as never)); + predicates.push(eb(column, 'not in', filter.notIn)); } return predicates; }