From f24a128319529fcd9f5d751cb71b70b13a5d8903 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Jul 2026 00:33:23 +0200 Subject: [PATCH] feat(server): new search schemas and query builders (#28686) --- .../src/controllers/memory.controller.spec.ts | 6 +- server/src/database.ts | 30 + server/src/decorators.ts | 2 + server/src/dtos/search.dto.ts | 181 ++- server/src/enum.ts | 9 + server/src/queries/search.repository.sql | 1219 ++++++++++++++++- server/src/repositories/search.repository.ts | 68 +- server/src/utils/database.ts | 496 ++++++- server/src/validation.ts | 2 +- 9 files changed, 1980 insertions(+), 33 deletions(-) diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index 64d225f155..a3839793eb 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -106,7 +106,11 @@ describe(MemoryController.name, () => { it('should require at least one field', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }])); + expect(body).toEqual( + errorDto.validationError([ + { path: [], message: 'At least one of the following fields is required: isSaved, seenAt, memoryAt' }, + ]), + ); }); }); diff --git a/server/src/database.ts b/server/src/database.ts index 1770b9d720..100ba451e7 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -304,6 +304,36 @@ export const columns = { 'asset.height', 'asset.isEdited', ], + searchAsset: [ + 'asset.id', + 'asset.updateId', + 'asset.createdAt', + 'asset.updatedAt', + 'asset.deletedAt', + 'asset.status', + 'asset.checksum', + 'asset.checksumAlgorithm', + 'asset.duplicateId', + 'asset.duration', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.isExternal', + 'asset.isFavorite', + 'asset.isOffline', + 'asset.isEdited', + 'asset.visibility', + 'asset.libraryId', + 'asset.livePhotoVideoId', + 'asset.localDateTime', + 'asset.originalFileName', + 'asset.originalPath', + 'asset.ownerId', + 'asset.stackId', + 'asset.thumbhash', + 'asset.type', + 'asset.width', + 'asset.height', + ], workflowAssetV1: [ 'asset.id', 'asset.ownerId', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index f89de14610..07398b0058 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -108,9 +108,11 @@ export function ChunkedSet(options?: { paramIndex?: number; chunkSize?: number } } const UUID = '00000000-0000-4000-a000-000000000000'; +const UUID_1 = '00000000-0000-4000-a000-000000000001'; export const DummyValue = { UUID, + UUID_1, UUID_SET: new Set([UUID]), PAGINATION: { take: 10, skip: 0 }, EMAIL: 'user@immich.app', diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ec4d58dae3..7911f92f44 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -3,8 +3,15 @@ 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 { isoDatetimeToDate, stringToBool } from 'src/validation'; +import { + AssetOrder, + AssetOrderSchema, + AssetTypeSchema, + AssetVisibilitySchema, + SearchOrderField, + SearchOrderFieldSchema, +} from 'src/enum'; +import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation'; import z from 'zod'; const BaseSearchSchema = z.object({ @@ -142,6 +149,176 @@ const SearchSuggestionRequestSchema = z }) .meta({ id: 'SearchSuggestionRequestDto' }); +const IdFilterSchema = nonEmptyPartial({ + eq: z.uuidv4(), + ne: z.uuidv4(), +}).meta({ id: 'IdFilter' }); + +const IdFilterNullableSchema = nonEmptyPartial({ + eq: z.uuidv4().nullable(), + ne: z.uuidv4().nullable(), +}).meta({ id: 'IdFilterNullable' }); + +const IdsFilterSchema = nonEmptyPartial({ + any: z.array(z.uuidv4()).min(1), + all: z.array(z.uuidv4()).min(1), + none: z.array(z.uuidv4()).min(1), +}).meta({ id: 'IdsFilter' }); + +const stringListShape = { + in: z.array(z.string()).min(1), + notIn: z.array(z.string()).min(1), +}; + +const StringFilterSchema = nonEmptyPartial({ + eq: z.string(), + ne: z.string(), + ...stringListShape, +}).meta({ id: 'StringFilter' }); + +const stringNullableShape = { + eq: z.string().nullable(), + ne: z.string().nullable(), + ...stringListShape, +}; + +const StringFilterNullableSchema = nonEmptyPartial(stringNullableShape).meta({ id: 'StringFilterNullable' }); + +const StringPatternFilterSchema = nonEmptyPartial({ + ...stringNullableShape, + like: z.string().min(1), + notLike: z.string().min(1), + startsWith: z.string().min(1), + endsWith: z.string().min(1), +}).meta({ id: 'StringPatternFilter' }); + +const numberRangeShape = { + lt: z.number(), + lte: z.number(), + gt: z.number(), + gte: z.number(), + in: z.array(z.number()).min(1), + notIn: z.array(z.number()).min(1), +}; + +const NumberFilterSchema = nonEmptyPartial({ + eq: z.number(), + ne: z.number(), + ...numberRangeShape, +}).meta({ id: 'NumberFilter' }); + +const NumberFilterNullableSchema = nonEmptyPartial({ + eq: z.number().nullable(), + ne: z.number().nullable(), + ...numberRangeShape, +}).meta({ id: 'NumberFilterNullable' }); + +const dateRangeShape = { + gt: isoDatetimeToDate, + gte: isoDatetimeToDate, + lt: isoDatetimeToDate, + lte: isoDatetimeToDate, +}; + +const DateFilterSchema = nonEmptyPartial({ + eq: isoDatetimeToDate, + ne: isoDatetimeToDate, + ...dateRangeShape, +}).meta({ id: 'DateFilter' }); + +const DateFilterNullableSchema = nonEmptyPartial({ + eq: isoDatetimeToDate.nullable(), + ne: isoDatetimeToDate.nullable(), + ...dateRangeShape, +}).meta({ id: 'DateFilterNullable' }); + +const BoolFilterSchema = z.object({ eq: z.boolean() }).meta({ id: 'BoolFilter' }); + +const enumFilterSchema = (values: z.ZodEnum, id: string) => + nonEmptyPartial({ + eq: values, + ne: values, + in: z.array(values).min(1), + notIn: z.array(values).min(1), + }).meta({ id }); + +const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType'); +const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility'); + +const StringSimilarityFilterSchema = z + .object({ + matches: z.string().min(1), + }) + .meta({ id: 'StringSimilarityFilter' }); + +export const DEFAULT_SEARCH_ORDER = { + field: SearchOrderField.FileCreatedAt, + direction: AssetOrder.Desc, +}; + +export const SearchOrderSchema = z + .object({ + field: SearchOrderFieldSchema.default(DEFAULT_SEARCH_ORDER.field), + direction: AssetOrderSchema.default(DEFAULT_SEARCH_ORDER.direction), + }) + .meta({ id: 'SearchOrder' }); + +const SearchFilterBranchSchema = z + .object({ + id: IdFilterSchema, + libraryId: IdFilterNullableSchema, + type: EnumFilterAssetTypeSchema, + visibility: EnumFilterAssetVisibilitySchema, + isFavorite: BoolFilterSchema, + isMotion: BoolFilterSchema, + isOffline: BoolFilterSchema, + isEncoded: BoolFilterSchema, + hasAlbums: BoolFilterSchema, + hasPeople: BoolFilterSchema, + hasTags: BoolFilterSchema, + city: StringFilterNullableSchema, + state: StringFilterNullableSchema, + country: StringFilterNullableSchema, + make: StringFilterNullableSchema, + model: StringFilterNullableSchema, + lensModel: StringFilterNullableSchema, + description: StringPatternFilterSchema, + originalFileName: StringPatternFilterSchema, + originalPath: StringPatternFilterSchema, + ocr: StringSimilarityFilterSchema, + rating: NumberFilterNullableSchema, + fileSizeInBytes: NumberFilterSchema, + takenAt: DateFilterSchema, + createdAt: DateFilterSchema, + updatedAt: DateFilterSchema, + trashedAt: DateFilterNullableSchema, + personIds: IdsFilterSchema, + tagIds: IdsFilterSchema, + albumIds: IdsFilterSchema, + checksum: StringFilterSchema, + encodedVideoPath: StringFilterSchema, + }) + .partial() + .meta({ id: 'SearchFilterBranch' }); + +export const SearchFilterSchema = SearchFilterBranchSchema.extend({ + or: z.array(SearchFilterBranchSchema).min(1).optional(), +}).meta({ id: 'SearchFilter' }); + +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 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 0996abe6fc..0d29244e09 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1234,3 +1234,12 @@ export enum CalendarHeatmapType { Upload = 'Upload', Taken = 'Taken', } + +export enum SearchOrderField { + FileCreatedAt = 'fileCreatedAt', + LocalDateTime = 'localDateTime', + FileSizeInBytes = 'fileSizeInBytes', + Rating = 'rating', +} + +export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' }); diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 25b8566375..efd7236bb5 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -2,7 +2,34 @@ -- SearchRepository.searchMetadata select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -13,7 +40,8 @@ where and "asset"."isFavorite" = $4 and "asset"."deletedAt" is null order by - "asset"."fileCreatedAt" desc + "asset"."fileCreatedAt" desc, + "asset"."id" desc limit $5 offset @@ -34,7 +62,34 @@ where -- SearchRepository.searchRandom select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -51,7 +106,34 @@ limit -- SearchRepository.searchLargeAssets select - "asset".*, + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", to_json("asset_exif") as "exifInfo" from "asset" @@ -73,7 +155,34 @@ begin set local vchordrq.probes = 1 select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -85,7 +194,8 @@ where and "asset"."isFavorite" = $4 and "asset"."deletedAt" is null order by - smart_search.embedding <=> $5 + smart_search.embedding <=> $5, + "asset"."id" asc limit $6 offset @@ -203,7 +313,34 @@ with recursive ) ) select - "asset".*, + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", to_jsonb("asset_exif") as "exifInfo" from "asset" @@ -276,3 +413,1071 @@ where and "deletedAt" is null and "lensModel" is not null and "lensModel" != $3 + +-- SearchRepository.searchMetadataV3 (baseline) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (empty) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + true +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $1 + +-- SearchRepository.searchMetadataV3 (or-exif-only) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left 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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-eq-null) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left 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, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (string-pattern-like) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left 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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-notLike) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left 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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-startsWith) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-similarity-ocr) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-any) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-all) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "asset_face"."assetId" + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 + and "asset_face"."personId" = any ($3::uuid[]) + group by + "asset_face"."assetId" + having + count(distinct "asset_face"."personId") = $4 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (ids-all-single) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-none) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-tags-all) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (has-albums-false) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (is-encoded) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (number-range) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left 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, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (date-eq) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset"."fileCreatedAt" = $2 +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (date-range) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" < $2 + and "asset"."fileCreatedAt" >= $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (order-fileSize-noExif) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset_exif"."fileSizeInByte" desc nulls last, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (order-rating-withExif) +select + to_json("asset_exif") as "exifInfo", + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset_exif"."rating" asc nulls last, + "asset"."id" asc +limit + $2 + +-- SearchRepository.searchMetadataV3 (or-branches) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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"."deletedAt" is null + and "asset_face"."isVisible" = $3 + and "asset_face"."personId" = any ($4::uuid[]) + ) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (or-with-top-level) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +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, + "asset"."id" desc +limit + $6 + +-- SearchRepository.searchStatisticsV3 (baseline) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true + +-- SearchRepository.searchStatisticsV3 (with-filter) +select + count(*) as "total" +from + "asset" + left 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 + ) + +-- SearchRepository.searchStatisticsV3 (with-or) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) + ) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 7faa19f7cd..70ba09ef2c 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,12 +1,23 @@ import { Injectable } from '@nestjs/common'; import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; +import { MapAsset } from 'src/dtos/asset-response.dto'; +import { SearchFilter, SearchOrder } from 'src/dtos/search.dto'; 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, + searchAssetBuilder, + searchAssetBuilderLegacy, + searchMetadataV3Examples, + searchStatisticsV3Examples, + withExifInner, + withSearchOrder, +} from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import z from 'zod'; @@ -122,6 +133,21 @@ export type AssetSearchOptions = Omit & 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 { + size: number; +} + export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & @@ -196,9 +222,10 @@ export class SearchRepository { }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - const items = await searchAssetBuilder(this.db, options) - .selectAll('asset') + const items = await searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .orderBy('asset.fileCreatedAt', orderDirection) + .orderBy('asset.id', orderDirection) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); @@ -217,7 +244,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(); } @@ -235,8 +262,8 @@ export class SearchRepository { ], }) async searchRandom(size: number, options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) - .selectAll('asset') + return searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .orderBy(sql`random()`) .limit(size) .execute(); @@ -256,8 +283,8 @@ export class SearchRepository { }) searchLargeAssets(size: number, options: LargeAssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - return searchAssetBuilder(this.db, options) - .selectAll('asset') + return searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .$call(withExifInner) .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) .orderBy('asset_exif.fileSizeInByte', orderDirection) @@ -285,10 +312,11 @@ 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) - .selectAll('asset') + const items = await searchAssetBuilderLegacy(trx, options) + .select(columns.searchAsset) .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) + .orderBy('asset.id', 'asc') .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); @@ -417,7 +445,7 @@ export class SearchRepository { .selectFrom('asset') .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .innerJoin('cte', 'asset.id', 'cte.assetId') - .selectAll('asset') + .select(columns.searchAsset) .select((eb) => eb .fn('to_jsonb', [eb.table('asset_exif')]) @@ -490,6 +518,24 @@ export class SearchRepository { return res.map((row) => row.lensModel!); } + @GenerateSql(...searchMetadataV3Examples) + searchMetadataV3( + pagination: AssetSearchPaginationV3Options, + options: AssetSearchBuilderV3Options, + ): Promise { + return withSearchOrder(searchAssetBuilder(this.db, options), options.order) + .select(columns.searchAsset) + .limit(pagination.size) + .execute(); + } + + @GenerateSql(...searchStatisticsV3Examples) + 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 ffd5a603e4..0f4d8775b6 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -7,21 +7,42 @@ import { Kysely, KyselyConfig, NotNull, + OperandValueExpression, + ReferenceExpression, Selectable, 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 { DummyValue, GenerateSqlQueries } from 'src/decorators'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; -import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { + DEFAULT_SEARCH_ORDER, + IdsFilter, + SearchFilterBranch, + SearchOrder, + StringFilter, + 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 { @@ -54,6 +75,8 @@ export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyCon }; }; +const uniqueIds = (ids: string[]) => [...new Set(ids)]; + export const asUuid = (id: string | Expression) => sql`${id}::uuid`; export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; @@ -85,16 +108,15 @@ export function withDefaultVisibility(qb: SelectQueryBuilder) return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); } +const selectExifInfo = (eb: AssetExpressionBuilder) => + eb.fn + .toJson(eb.table('asset_exif')) + .$castTo> | null>() + .as('exifInfo'); + // TODO come up with a better query that only selects the fields we need export function withExif(qb: SelectQueryBuilder) { - return qb - .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => - eb.fn - .toJson(eb.table('asset_exif')) - .$castTo> | null>() - .as('exifInfo'), - ); + return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo); } export function withExifInner(qb: SelectQueryBuilder) { @@ -373,7 +395,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); return kysely @@ -493,6 +515,458 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } +type AssetExpressionBuilder = ExpressionBuilder; + +const albumAssets = (eb: AssetExpressionBuilder) => + eb.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id'); + +const visibleFaces = (eb: AssetExpressionBuilder) => + eb + .selectFrom('asset_face') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true); + +const tagAssets = (eb: AssetExpressionBuilder) => + eb.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id'); + +// shared any/all/none mechanics; `matchesAll` only receives deduplicated multi-id lists, +// so its `count(distinct id) = ids.length` check stays satisfiable +function idsPredicates( + eb: AssetExpressionBuilder, + { any, all, none }: IdsFilter = {}, + ops: { + matchesAny: (ids: string[]) => Expression; + matchesAll: (ids: string[]) => Expression; + }, +) { + const predicates: Expression[] = []; + if (any) { + predicates.push(ops.matchesAny(any)); + } + if (all) { + const ids = uniqueIds(all); + predicates.push(ids.length === 1 ? ops.matchesAny(ids) : ops.matchesAll(ids)); + } + if (none) { + predicates.push(eb.not(ops.matchesAny(none))); + } + return predicates; +} + +function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => albumAssets(eb).where('album_asset.albumId', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('album_asset.assetId') + .groupBy('album_asset.assetId') + .having((eb) => eb.fn.count('album_asset.albumId').distinct(), '=', ids.length), + ), + }); +} + +function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('asset_face.assetId') + .groupBy('asset_face.assetId') + .having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length), + ), + }); +} + +function tagIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => + tagAssets(eb) + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .where('tag_closure.id_ancestor', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('tag_asset.assetId') + .groupBy('tag_asset.assetId') + .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '=', ids.length), + ), + }); +} + +type ComparisonFilter = { + eq?: T | null; + ne?: T | null; + lt?: T; + lte?: T; + gt?: T; + gte?: T; + in?: T[]; + notIn?: T[]; +}; + +// one operator dispatch for every filter shape; the DTO schemas constrain which +// operators (and null literals) each filter can actually carry +function comparisonPredicates>( + eb: ExpressionBuilder, + column: RE, + filter: ComparisonFilter> = {}, +) { + const predicates: Expression[] = []; + if (filter.eq !== undefined) { + predicates.push(filter.eq === null ? eb(column, 'is', null) : eb(column, '=', filter.eq)); + } + if (filter.ne !== undefined) { + predicates.push(filter.ne === null ? eb(column, 'is not', null) : eb(column, '!=', filter.ne)); + } + if (filter.lt !== undefined) { + predicates.push(eb(column, '<', filter.lt)); + } + if (filter.lte !== undefined) { + predicates.push(eb(column, '<=', filter.lte)); + } + if (filter.gt !== undefined) { + predicates.push(eb(column, '>', filter.gt)); + } + if (filter.gte !== undefined) { + predicates.push(eb(column, '>=', filter.gte)); + } + if (filter.in !== undefined) { + predicates.push(eb(column, 'in', filter.in)); + } + if (filter.notIn !== undefined) { + predicates.push(eb(column, 'not in', filter.notIn)); + } + return predicates; +} + +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 stringPatternPredicates(eb: AssetExpressionBuilder, column: StringColumn, filter: StringPatternFilter = {}) { + const ref = sql.ref(column); + const predicates = comparisonPredicates(eb, column, filter); + if (filter.like !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`); + } + if (filter.notLike !== undefined) { + predicates.push(sql`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`); + } + if (filter.startsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`); + } + if (filter.endsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.endsWith}))`); + } + return predicates; +} + +function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter = {}) { + return comparisonPredicates(eb, 'asset.checksum', { + eq: filter.eq === undefined ? undefined : fromChecksum(filter.eq), + ne: filter.ne === undefined ? undefined : fromChecksum(filter.ne), + in: filter.in?.map((checksum) => fromChecksum(checksum)), + notIn: filter.notIn?.map((checksum) => fromChecksum(checksum)), + }); +} + +const encodedVideoFiles = (eb: AssetExpressionBuilder) => + eb + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.EncodedVideo); + +function existsPredicates( + eb: AssetExpressionBuilder, + filter: { eq: boolean } | undefined, + subquery: () => Expression, +): Expression[] { + if (!filter) { + return []; + } + const exists = eb.exists(subquery()); + return [filter.eq ? exists : eb.not(exists)]; +} + +// predicates are collected as expressions rather than chained `where` calls so the same +// helpers can build each `or` branch, which must compose into eb.and/eb.or +function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) { + const { encodedVideoPath } = branch; + return [ + ...comparisonPredicates(eb, 'asset.id', branch.id), + ...comparisonPredicates(eb, 'asset.libraryId', branch.libraryId), + ...comparisonPredicates(eb, 'asset.type', branch.type), + ...comparisonPredicates(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)] : []), + ...existsPredicates(eb, branch.isEncoded, () => encodedVideoFiles(eb)), + ...existsPredicates(eb, branch.hasAlbums, () => albumAssets(eb)), + ...existsPredicates(eb, branch.hasPeople, () => visibleFaces(eb)), + ...existsPredicates(eb, branch.hasTags, () => tagAssets(eb)), + ...comparisonPredicates(eb, 'asset_exif.city', branch.city), + ...comparisonPredicates(eb, 'asset_exif.state', branch.state), + ...comparisonPredicates(eb, 'asset_exif.country', branch.country), + ...comparisonPredicates(eb, 'asset_exif.make', branch.make), + ...comparisonPredicates(eb, 'asset_exif.model', branch.model), + ...comparisonPredicates(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 + ? [ + eb.exists( + eb + .selectFrom('ocr_search') + .whereRef('ocr_search.assetId', '=', 'asset.id') + .where( + sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(branch.ocr.matches).join(' ')})`, + ), + ), + ] + : []), + ...comparisonPredicates(eb, 'asset_exif.rating', branch.rating), + ...comparisonPredicates(eb, 'asset_exif.fileSizeInByte', branch.fileSizeInBytes), + ...comparisonPredicates(eb, 'asset.fileCreatedAt', branch.takenAt), + ...comparisonPredicates(eb, 'asset.createdAt', branch.createdAt), + ...comparisonPredicates(eb, 'asset.updatedAt', branch.updatedAt), + ...comparisonPredicates(eb, 'asset.deletedAt', branch.trashedAt), + ...albumIdsPredicates(eb, branch.albumIds), + ...personIdsPredicates(eb, branch.personIds), + ...tagIdsPredicates(eb, branch.tagIds), + ...checksumPredicates(eb, branch.checksum), + ...(encodedVideoPath + ? [ + eb.exists( + encodedVideoFiles(eb) + .where('asset_file.isEdited', '=', false) + .where((eb) => eb.and(comparisonPredicates(eb, 'asset_file.path', encodedVideoPath))), + ), + ] + : []), + ]; +} + +// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats) +// can compose the same filters without stripping an order by +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options) { + const filter = options.filter ?? {}; + + return ( + kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('asset') + // postgres eliminates the left join when no exif column is referenced, so unused joins are free + .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .$if(!!options.withExif, (qb) => qb.select(selectExifInfo)) + .$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 predicates = branchPredicates(eb, filter); + if (filter.or && filter.or.length > 0) { + predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch))))); + } + return predicates.length > 0 ? eb.and(predicates) : eb.lit(true); + }) + ); +} + +const searchOrderColumns = { + [SearchOrderField.FileCreatedAt]: { column: 'asset.fileCreatedAt', nullable: false }, + [SearchOrderField.LocalDateTime]: { column: 'asset.localDateTime', nullable: false }, + [SearchOrderField.FileSizeInBytes]: { column: 'asset_exif.fileSizeInByte', nullable: true }, + [SearchOrderField.Rating]: { column: 'asset_exif.rating', nullable: true }, +} as const; + +export function withSearchOrder(qb: ReturnType, order?: SearchOrder) { + const { field, direction } = order ?? DEFAULT_SEARCH_ORDER; + const { column, nullable } = searchOrderColumns[field]; + return ( + qb + .orderBy(column, (ob) => { + const ordered = direction === AssetOrder.Asc ? ob.asc() : ob.desc(); + // nulls last: assets without an asset_exif row would otherwise lead descending results + return nullable ? ordered.nullsLast() : ordered; + }) + // id tie-break for deterministic pagination + .orderBy('asset.id', direction) + ); +} + +export const searchMetadataV3Examples: GenerateSqlQueries[] = [ + { 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 } } }], + }, + { + 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_1] } } }, + ], + }, + { + 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] } } }], + }, + { + name: 'ids-tags-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + ], + }, + { + 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] } }], + }, + }, + ], + }, +]; + +export const searchStatisticsV3Examples: GenerateSqlQueries[] = [ + { 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 } }], + }, + }, + ], + }, +]; + export type ReindexVectorIndexOptions = { indexName: string; lists?: number }; type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions; diff --git a/server/src/validation.ts b/server/src/validation.ts index 7188de1bed..f8de2a68ff 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -42,7 +42,7 @@ export function nonEmptyPartial(shape: T) { .object(shape) .partial() .refine((data) => Object.values(data as Record).some((value) => value !== undefined), { - message: 'At least one field must be provided', + message: `At least one of the following fields is required: ${Object.keys(shape).join(', ')}`, }); }