import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools'; import { AliasedRawBuilder, DeduplicateJoinsPlugin, Expression, ExpressionBuilder, 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 { 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 { dialect: new PostgresJSDialect({ postgres: createPostgres({ connection, onNotice: (notice: Notice) => { if (notice['severity'] !== 'NOTICE') { console.warn('Postgres notice:', notice); } }, }), }), log(event) { if (event.level !== 'error') { return; } if (isAssetChecksumConstraint(event.error)) { return; } console.error('Query failed :', { durationMs: event.queryDurationMillis, error: event.error, sql: event.query.sql, params: event.query.parameters, }); }, }; }; 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[])`; export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; export const unnest = (array: string[]) => sql>`unnest(array[${sql.join(array)}]::text[])`; export const removeUndefinedKeys = (update: T, template: unknown) => { for (const key in update) { if ((template as T)[key] === undefined) { delete update[key]; } } return update; }; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export const VIDEO_STREAM_SESSION_PK_CONSTRAINT = 'video_stream_session_pkey'; export const isAssetChecksumConstraint = (error: unknown) => (error as PostgresError)?.constraint_name === ASSET_CHECKSUM_CONSTRAINT; export const isVideoStreamSessionPkConstraint = (error: unknown) => (error as PostgresError)?.constraint_name === VIDEO_STREAM_SESSION_PK_CONSTRAINT; 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(selectExifInfo); } export function withExifInner(qb: SelectQueryBuilder) { return qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')) .$narrowType<{ exifInfo: NotNull }>(); } export const dummy = sql`(select 1)`.as('dummy'); export function withAudioStream(eb: ExpressionBuilder) { return jsonObjectFrom( eb .selectFrom(dummy) .select(['asset_audio.index', 'asset_audio.codecName', 'asset_audio.profile', 'asset_audio.bitrate']) .where('asset_audio.assetId', 'is not', sql.lit(null)) .$castTo(), ); } export function withVideoStream(eb: ExpressionBuilder) { return jsonObjectFrom( eb .selectFrom(dummy) .select((eb) => [ 'asset_video.index', 'asset_video.codecName', 'asset_video.profile', 'asset_video.level', 'asset_video.bitrate', 'asset_exif.exifImageWidth as width', 'asset_exif.exifImageHeight as height', 'asset_video.pixelFormat', 'asset_video.frameCount', 'asset_exif.fps as frameRate', 'asset_video.timeBase', eb .case() .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate90CW.toString())) .then(sql.lit(-90)) .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate270CW.toString())) .then(sql.lit(90)) .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate180.toString())) .then(sql.lit(180)) .else(0) .end() .as('rotation'), 'asset_video.colorPrimaries', 'asset_video.colorMatrix', 'asset_video.colorTransfer', 'asset_video.dvProfile', 'asset_video.dvLevel', 'asset_video.dvBlSignalCompatibilityId', ]) .where('asset_video.assetId', 'is not', sql.lit(null)), ).$castTo<(VideoStreamInfo & { timeBase: number }) | null>(); } export function withVideoFormat(eb: ExpressionBuilder) { return jsonObjectFrom( eb .selectFrom(dummy) .select(['asset_video.formatName', 'asset_video.formatLongName', 'asset.duration', 'asset_video.bitrate']) .where('asset_video.assetId', 'is not', sql.lit(null)), ).$castTo(); } export function withVideoPackets(eb: ExpressionBuilder) { return jsonObjectFrom( eb .selectFrom(dummy) .where('asset_keyframe.assetId', 'is not', sql.lit(null)) .select([ 'asset_keyframe.pts as keyframePts', 'asset_keyframe.accDuration as keyframeAccDuration', 'asset_keyframe.ownDuration as keyframeOwnDuration', 'asset_keyframe.totalDuration', 'asset_keyframe.packetCount', 'asset_keyframe.outputFrames', ]), ).$castTo(); } export function withSmartSearch(qb: SelectQueryBuilder) { return qb .leftJoin('smart_search', 'asset.id', 'smart_search.assetId') .select((eb) => jsonObjectFrom(eb.table('smart_search')).as('smartSearch')); } export function withFaces(eb: ExpressionBuilder, withHidden?: boolean, withDeletedFace?: boolean) { return jsonArrayFrom( eb .selectFrom('asset_face') .selectAll('asset_face') .whereRef('asset_face.assetId', '=', 'asset.id') .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', '=', true)), ).as('faces'); } export function withFiles(eb: ExpressionBuilder, type?: AssetFileType) { return jsonArrayFrom( eb .selectFrom('asset_file') .select(columns.assetFiles) .whereRef('asset_file.assetId', '=', 'asset.id') .$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)), ).as('files'); } export function withFilePath(eb: ExpressionBuilder, type: AssetFileType, isEdited = false) { return eb .selectFrom('asset_file') .select('asset_file.path') .whereRef('asset_file.assetId', '=', 'asset.id') .where('asset_file.type', '=', sql.lit(type)) .where('asset_file.isEdited', '=', sql.lit(isEdited)); } export function withFacesAndPeople( eb: ExpressionBuilder, withHidden?: boolean, withDeletedFace?: boolean, ) { return jsonArrayFrom( eb .selectFrom('asset_face') .leftJoinLateral( (eb) => eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), (join) => join.onTrue(), ) .selectAll('asset_face') .select((eb) => eb.table('person').$castTo>().as('person')) .whereRef('asset_face.assetId', '=', 'asset.id') .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), ).as('faces'); } export function hasPeople(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'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'), ); } export function inAlbums(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('has_album'), (join) => join.onRef('has_album.assetId', '=', 'asset.id'), ); } export function hasTags(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'), (join) => join.onRef('has_tags.assetId', '=', 'asset.id'), ); } export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'asset.ownerId')).as( 'owner', ); } export function withLibrary(eb: ExpressionBuilder) { return jsonObjectFrom( eb.selectFrom('library').selectAll('library').whereRef('library.id', '=', 'asset.libraryId'), ).as('library'); } export function withTags(eb: ExpressionBuilder) { return jsonArrayFrom( eb .selectFrom('tag') .select(columns.tag) .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') .whereRef('asset.id', '=', 'tag_asset.assetId'), ).as('tags'); } export function truncatedDate(order: AssetOrderBy = AssetOrderBy.TakenAt, size?: 'DAY' | 'MONTH') { return sql`date_trunc(${sql.lit(size ?? 'MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; } export function withTagId(qb: SelectQueryBuilder, tagId: string) { return qb.where((eb) => eb.exists( eb .selectFrom('tag_closure') .innerJoin('tag_asset', 'tag_asset.tagId', 'tag_closure.id_descendant') .whereRef('tag_asset.assetId', '=', 'asset.id') .where('tag_closure.id_ancestor', '=', tagId), ), ); } const isCJK = (c: number): boolean => (c >= 0x4e_00 && c <= 0x9f_ff) || (c >= 0xac_00 && c <= 0xd7_af) || (c >= 0x30_40 && c <= 0x30_9f) || (c >= 0x30_a0 && c <= 0x30_ff) || (c >= 0x34_00 && c <= 0x4d_bf); export const tokenizeForSearch = (text: string): string[] => { /* eslint-disable unicorn/prefer-code-point */ const tokens: string[] = []; let i = 0; while (i < text.length) { const c = text.charCodeAt(i); if (c <= 32) { i++; continue; } const start = i; if (isCJK(c)) { while (i < text.length && isCJK(text.charCodeAt(i))) { i++; } if (i - start === 1) { tokens.push(text[start]); } else { for (let k = start; k < i - 1; k++) { tokens.push(text[k] + text[k + 1]); } } } else { while (i < text.length && text.charCodeAt(i) > 32 && !isCJK(text.charCodeAt(i))) { i++; } tokens.push(text.slice(start, i)); } } return tokens; }; // needed to properly type the return with the EditActionItem discriminated union type type AliasedEditActions = AliasedRawBuilder; export function withEdits(eb: ExpressionBuilder): AliasedEditActions { return jsonArrayFrom( eb .selectFrom('asset_edit') .select(['asset_edit.action', 'asset_edit.parameters']) .whereRef('asset_edit.assetId', '=', 'asset.id'), ).as('edits') as AliasedEditActions; } const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearchBuilderOptions) { options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); return kysely .withPlugin(joinDeduplicationPlugin) .selectFrom('asset') .$if(!!options.visibility, (qb) => options.visibility === 'not-locked' ? qb.where('asset.visibility', '!=', AssetVisibility.Locked) : qb.where('asset.visibility', '=', options.visibility!), ) .$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!)) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(options.tagIds === null, (qb) => qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetId', '=', 'asset.id')))), ) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('asset.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('asset.createdAt', '>=', options.createdAfter!)) .$if(!!options.updatedBefore, (qb) => qb.where('asset.updatedAt', '<=', options.updatedBefore!)) .$if(!!options.updatedAfter, (qb) => qb.where('asset.updatedAt', '>=', options.updatedAfter!)) .$if(!!options.trashedBefore, (qb) => qb.where('asset.deletedAt', '<=', options.trashedBefore!)) .$if(!!options.trashedAfter, (qb) => qb.where('asset.deletedAt', '>=', options.trashedAfter!)) .$if(!!options.takenBefore, (qb) => qb.where('asset.fileCreatedAt', '<=', options.takenBefore!)) .$if(!!options.takenAfter, (qb) => qb.where('asset.fileCreatedAt', '>=', options.takenAfter!)) .$if(options.city !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.city', options.city === null ? 'is' : '=', options.city!), ) .$if(options.state !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.state', options.state === null ? 'is' : '=', options.state!), ) .$if(options.country !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.country', options.country === null ? 'is' : '=', options.country!), ) .$if(options.make !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.make', options.make === null ? 'is' : '=', options.make!), ) .$if(options.model !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.model', options.model === null ? 'is' : '=', options.model!), ) .$if(options.lensModel !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), ) .$if(options.rating !== undefined, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('asset_exif.rating', options.rating === null ? 'is' : '=', options.rating!), ) .$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!)) .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.encodedVideoPath, (qb) => qb .innerJoin('asset_file', (join) => join .onRef('asset.id', '=', 'asset_file.assetId') .on('asset_file.type', '=', AssetFileType.EncodedVideo) .on('asset_file.isEdited', '=', false), ) .where('asset_file.path', '=', options.encodedVideoPath!), ) .$if(!!options.originalPath, (qb) => qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), ) .$if(!!options.originalFileName, (qb) => qb.where( sql`f_unaccent(asset."originalFileName")`, 'ilike', sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, ), ) .$if(!!options.description, (qb) => qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where(sql`f_unaccent(asset_exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`), ) .$if(!!options.ocr, (qb) => qb .innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId') .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`), ) .$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => qb.where((eb) => { const exists = eb.exists((eb) => eb .selectFrom('asset_file') .whereRef('assetId', '=', 'asset.id') .where('type', '=', AssetFileType.EncodedVideo), ); return options.isEncoded ? exists : eb.not(exists); }), ) .$if(options.isMotion !== undefined, (qb) => qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), ) .$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) => qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))), ) .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .$if(!!options.withExif, withExifInner) .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) .$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; export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string { switch (vectorExtension) { case DatabaseExtension.VectorChord: { return ` CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$ residual_quantization = false [build.internal] lists = [${lists ?? 1}] spherical_centroids = true build_threads = 4 sampling_factor = 1024 $$)`; } case DatabaseExtension.Vector: { return ` CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)`; } default: { throw new Error(`Unsupported vector extension: '${vectorExtension}'`); } } } export const updateLockedColumns = & { lockedProperties?: LockableProperty[] }>( exif: T, ) => { exif.lockedProperties = lockableProperties.filter((property) => Object.hasOwn(exif, property)); return exif; };