add query builders

pull/28686/head
timonrieger 2026-05-29 18:45:45 +02:00
parent dc7f3f5aa4
commit 82054eb1c7
No known key found for this signature in database
3 changed files with 812 additions and 133 deletions

View File

@ -213,6 +213,7 @@ const StringPatternFilterSchema = atLeastOneKey(
const NumberFilterSchema = atLeastOneKey(
z.strictObject({
eq: z.number().optional(),
ne: z.number().optional(),
lt: z.number().optional(),
lte: z.number().optional(),
gt: z.number().optional(),

View File

@ -2,11 +2,12 @@ import { Injectable } from '@nestjs/common';
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
import { SearchFilter, SearchOrder } from 'src/dtos/search.dto';
import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SearchOrderField, VectorIndex } from 'src/enum';
import { probes } from 'src/repositories/database.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { anyUuid, searchAssetBuilderLegacy, withExifInner } from 'src/utils/database';
import { anyUuid, searchAssetBuilder, searchAssetBuilderLegacy, withExifInner } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
@ -121,6 +122,22 @@ export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
export interface AssetSearchBuilderV3Options {
filter?: SearchFilter;
/** Server-derived ownership scope. Never client-controlled. */
userIds?: string[];
withExif?: boolean;
withFaces?: boolean;
withPeople?: boolean;
withStacked?: boolean;
order?: SearchOrder;
}
export interface AssetSearchPaginationV3Options {
cursor?: string;
size: number;
}
export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
@ -489,6 +506,173 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
// ---------------------------------------------------------------------------
// v3 SQL coverage scaffolding — these methods exist solely to give the SQL
// generator structurally distinct snapshots of the new `searchAssetBuilder`.
// They have no consumer yet. PR 2 rewires the legacy methods above to use
// `searchAssetBuilder` and deletes these scaffolding methods.
// ---------------------------------------------------------------------------
@GenerateSql(
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
{
name: 'string-eq-null',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
},
{
name: 'string-pattern-like',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
},
{
name: 'string-pattern-notLike',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
},
{
name: 'string-pattern-startsWith',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
],
},
{
name: 'string-similarity-ocr',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
},
{
name: 'ids-any',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
},
{
name: 'ids-all',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID] } } },
],
},
{
name: 'ids-none',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
},
{
name: 'ids-tags-all',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID] } } },
],
},
{
name: 'has-albums-false',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
},
{
name: 'is-encoded',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
},
{
name: 'number-range',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
},
{
name: 'date-eq',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
},
{
name: 'date-range',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
},
],
},
{
name: 'order-fileSize-noExif',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
withExif: false,
},
],
},
{
name: 'order-rating-withExif',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
withExif: true,
},
],
},
{
name: 'or-branches',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
filter: {
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
},
},
],
},
{
name: 'or-with-top-level',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
filter: {
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }],
},
},
],
},
)
async searchMetadataV3(pagination: AssetSearchPaginationV3Options, options: AssetSearchBuilderV3Options) {
return await searchAssetBuilder(this.db, options)
.selectAll('asset')
.limit(pagination.size + 1)
.execute();
}
@GenerateSql(
{ name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] },
{
name: 'with-filter',
params: [
{
userIds: [DummyValue.UUID],
filter: {
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
fileSizeInBytes: { gte: 100 },
},
},
],
},
{
name: 'with-or',
params: [
{
userIds: [DummyValue.UUID],
filter: {
or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }],
},
},
],
},
)
searchStatisticsV3(options: AssetSearchBuilderV3Options) {
return searchAssetBuilder(this.db, options)
.select((qb) => qb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
}
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
return this.db
.selectFrom('asset_exif')

View File

@ -11,17 +11,41 @@ import {
SelectQueryBuilder,
ShallowDehydrateObject,
sql,
SqlBool,
} from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import {
DateFilter,
DateFilterNullable,
IdFilter,
IdFilterNullable,
IdsFilter,
NumberFilter,
NumberFilterNullable,
SearchFilter,
SearchFilterBranch,
StringFilter,
StringFilterNullable,
StringPatternFilter,
} from 'src/dtos/search.dto';
import {
AssetFileType,
AssetOrder,
AssetOrderBy,
AssetVisibility,
DatabaseExtension,
ExifOrientation,
SearchOrderField,
} from 'src/enum';
import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
import { fromChecksum } from 'src/utils/request';
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
return {
@ -276,135 +300,6 @@ export function hasTags<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagIds: strin
);
}
export function inAlbumsAny<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('album_asset')
.select('assetId')
.where('albumId', '=', anyUuid(albumIds))
.groupBy('assetId')
.as('in_albums_any'),
(join) => join.onRef('in_albums_any.assetId', '=', 'asset.id'),
);
}
export function inAlbumsAll<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('album_asset')
.select('assetId')
.where('albumId', '=', anyUuid(albumIds))
.groupBy('assetId')
.having((eb) => eb.fn.count('albumId').distinct(), '=', albumIds.length)
.as('in_albums_all'),
(join) => join.onRef('in_albums_all.assetId', '=', 'asset.id'),
);
}
export function inAlbumsNone<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: string[]) {
return qb.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('album_asset')
.select('assetId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('albumId', '=', anyUuid(albumIds)),
),
),
);
}
export function hasPeopleAny<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('asset_face')
.select('assetId')
.where('personId', '=', anyUuid(personIds))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true)
.groupBy('assetId')
.as('has_people_any'),
(join) => join.onRef('has_people_any.assetId', '=', 'asset.id'),
);
}
export function hasPeopleAll<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('asset_face')
.select('assetId')
.where('personId', '=', anyUuid(personIds))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true)
.groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people_all'),
(join) => join.onRef('has_people_all.assetId', '=', 'asset.id'),
);
}
export function hasPeopleNone<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
return qb.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('asset_face')
.select('assetId')
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('personId', '=', anyUuid(personIds))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true),
),
),
);
}
export function hasTagsAny<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('tag_asset')
.select('assetId')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
.where('tag_closure.id_ancestor', '=', anyUuid(tagIds))
.groupBy('assetId')
.as('has_tags_any'),
(join) => join.onRef('has_tags_any.assetId', '=', 'asset.id'),
);
}
export function hasTagsAll<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('tag_asset')
.select('assetId')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
.where('tag_closure.id_ancestor', '=', anyUuid(tagIds))
.groupBy('assetId')
.having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', tagIds.length)
.as('has_tags_all'),
(join) => join.onRef('has_tags_all.assetId', '=', 'asset.id'),
);
}
export function hasTagsNone<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagIds: string[]) {
return qb.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('tag_asset')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
.select('tag_asset.assetId')
.whereRef('tag_asset.assetId', '=', 'asset.id')
.where('tag_closure.id_ancestor', '=', anyUuid(tagIds)),
),
),
);
}
export function withOwner(eb: ExpressionBuilder<DB, 'asset'>) {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'asset.ownerId')).as(
'owner',
@ -614,6 +509,605 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
}
/**
* Join strategy each `SearchFilterBranch` field needs against the database.
* - `asset`: column on the `asset` table; simple WHERE; no join.
* - `asset_exif`: column on `asset_exif`; inner join
* - `asset_file`: column on `asset_file`; SQL is field-dependent
* - `ocr_search`: specialised trigram-indexed search column
* - `membership`: not a column; junction-table membership; SQL is operator-dependent
*/
type Backing = 'asset' | 'asset_exif' | 'asset_file' | 'ocr_search' | 'membership';
/**
* Exhaustive `SearchFilterBranch` backing map
*/
const FIELD_BACKING: Record<keyof Omit<SearchFilterBranch, 'or'>, Backing> = {
id: 'asset',
libraryId: 'asset',
type: 'asset',
visibility: 'asset',
isFavorite: 'asset',
isMotion: 'asset',
isOffline: 'asset',
isEncoded: 'asset_file',
hasAlbums: 'membership',
hasPeople: 'membership',
hasTags: 'membership',
city: 'asset_exif',
state: 'asset_exif',
country: 'asset_exif',
make: 'asset_exif',
model: 'asset_exif',
lensModel: 'asset_exif',
description: 'asset_exif',
originalFileName: 'asset',
originalPath: 'asset',
ocr: 'ocr_search',
rating: 'asset_exif',
fileSizeInBytes: 'asset_exif',
takenAt: 'asset',
createdAt: 'asset',
updatedAt: 'asset',
trashedAt: 'asset',
personIds: 'membership',
tagIds: 'membership',
albumIds: 'membership',
checksum: 'asset',
encodedVideoPath: 'asset_file',
};
function branchNeedsExifJoin(branch: SearchFilterBranch): boolean {
for (const key of Object.keys(FIELD_BACKING) as (keyof typeof FIELD_BACKING)[]) {
if (FIELD_BACKING[key] === 'asset_exif' && branch[key] !== undefined) {
return true;
}
}
return false;
}
/**
* Exhaustive `SearchOrderField` backing map
*/
const ORDER_BACKING = {
[SearchOrderField.FileCreatedAt]: 'asset',
[SearchOrderField.LocalDateTime]: 'asset',
[SearchOrderField.FileSizeInBytes]: 'asset_exif',
[SearchOrderField.Rating]: 'asset_exif',
} satisfies Record<SearchOrderField, Backing>;
/**
* `asset_exif` join is needed when either any filter or order field needs `asset_exif`
*/
function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): boolean {
if (ORDER_BACKING[orderField] === 'asset_exif') {
return true;
}
if (branchNeedsExifJoin(filter)) {
return true;
}
return filter.or?.some((branch) => branchNeedsExifJoin(branch)) ?? false;
}
/**
* EB type used by `buildBranchPredicates`. The runtime invariant is that
* whenever a predicate references an `asset_exif` column, the `asset_exif`
* join has already been planted at the top of the builder chain (guaranteed
* by `exifJoinRequired`). `searchAssetBuilder` casts its `eb` into this type
* because TS can't see through the conditional `.$if(needsExifJoin, …)`.
*/
type AssetEB = ExpressionBuilder<DB, 'asset' | 'asset_exif'>;
// ---- EXISTS expression helpers (returned as Expression<SqlBool>) ----
function existsAlbumLink(eb: AssetEB, want: boolean): Expression<SqlBool> {
const e = eb.exists((eb2) => eb2.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id'));
return want ? e : eb.not(e);
}
function existsPersonLink(eb: AssetEB, want: boolean): Expression<SqlBool> {
const e = eb.exists((eb2) =>
eb2
.selectFrom('asset_face')
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true),
);
return want ? e : eb.not(e);
}
function existsTagLink(eb: AssetEB, want: boolean): Expression<SqlBool> {
const e = eb.exists((eb2) => eb2.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id'));
return want ? e : eb.not(e);
}
function existsEncodedVideo(eb: AssetEB, want: boolean): Expression<SqlBool> {
const e = eb.exists((eb2) =>
eb2
.selectFrom('asset_file')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.EncodedVideo),
);
return want ? e : eb.not(e);
}
function existsOcrMatch(eb: AssetEB, matches: string): Expression<SqlBool> {
const tokens = tokenizeForSearch(matches).join(' ');
return eb.exists((eb2) =>
eb2
.selectFrom('ocr_search')
.whereRef('ocr_search.assetId', '=', 'asset.id')
.where(sql<SqlBool>`f_unaccent(ocr_search.text) %>> f_unaccent(${tokens})`),
);
}
const encodedVideoFileBase = (eb2: ExpressionBuilder<DB, 'asset' | 'asset_exif'>) =>
eb2
.selectFrom('asset_file')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.EncodedVideo)
.where('asset_file.isEdited', '=', false);
function existsEncodedVideoPath(eb: AssetEB, f: StringFilter): Expression<SqlBool>[] {
const out: Expression<SqlBool>[] = [];
if (f.eq !== undefined) {
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '=', f.eq!)));
}
if (f.ne !== undefined) {
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '<>', f.ne!)));
}
if (f.in !== undefined) {
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'in', f.in!)));
}
if (f.notIn !== undefined) {
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'not in', f.notIn!)));
}
return out;
}
// ---- IdsFilter EXISTS helpers ----
type IdsKind = 'album' | 'person' | 'tag';
function idsAnyExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression<SqlBool> {
switch (kind) {
case 'album': {
return eb.exists((eb2) =>
eb2
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album_asset.albumId', '=', anyUuid(ids)),
);
}
case 'person': {
return eb.exists((eb2) =>
eb2
.selectFrom('asset_face')
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('asset_face.personId', '=', anyUuid(ids))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true),
);
}
case 'tag': {
return eb.exists((eb2) =>
eb2
.selectFrom('tag_asset')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
.whereRef('tag_asset.assetId', '=', 'asset.id')
.where('tag_closure.id_ancestor', '=', anyUuid(ids)),
);
}
}
}
function idsAllExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression<SqlBool> {
switch (kind) {
case 'album': {
return eb.exists((eb2) =>
eb2
.selectFrom('album_asset')
.select('album_asset.assetId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album_asset.albumId', '=', anyUuid(ids))
.groupBy('album_asset.assetId')
.having((e3) => e3.fn.count('album_asset.albumId').distinct(), '=', ids.length),
);
}
case 'person': {
return eb.exists((eb2) =>
eb2
.selectFrom('asset_face')
.select('asset_face.assetId')
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('asset_face.personId', '=', anyUuid(ids))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.groupBy('asset_face.assetId')
.having((e3) => e3.fn.count('asset_face.personId').distinct(), '=', ids.length),
);
}
case 'tag': {
return eb.exists((eb2) =>
eb2
.selectFrom('tag_asset')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
.select('tag_asset.assetId')
.whereRef('tag_asset.assetId', '=', 'asset.id')
.where('tag_closure.id_ancestor', '=', anyUuid(ids))
.groupBy('tag_asset.assetId')
.having((e3) => e3.fn.count('tag_closure.id_ancestor').distinct(), '>=', ids.length),
);
}
}
}
function pushIdsFilter(preds: Expression<SqlBool>[], eb: AssetEB, kind: IdsKind, f: IdsFilter) {
if (f.any) {
preds.push(idsAnyExists(eb, kind, f.any));
}
if (f.all) {
preds.push(idsAllExists(eb, kind, f.all));
}
if (f.none) {
preds.push(eb.not(idsAnyExists(eb, kind, f.none)));
}
}
// ---- Per-filter-family pushers ----
function pushIdEqNe(
preds: Expression<SqlBool>[],
eb: AssetEB,
column: 'asset.id' | 'asset.libraryId',
f: IdFilter | IdFilterNullable | undefined,
) {
if (!f) {
return;
}
if (f.eq === null) {
preds.push(eb(column, 'is', null));
} else if (f.eq !== undefined) {
preds.push(eb(column, '=', asUuid(f.eq)));
}
if (f.ne === null) {
preds.push(eb(column, 'is not', null));
} else if (f.ne !== undefined) {
preds.push(eb(column, '<>', asUuid(f.ne)));
}
}
function pushEnum<T extends string>(
preds: Expression<SqlBool>[],
eb: AssetEB,
column: 'asset.type' | 'asset.visibility',
f: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined,
) {
if (!f) {
return;
}
// Values cast: column type unions across asset.type / asset.visibility resolve
// to `AssetType | AssetVisibility` and TS can't narrow to the caller's T.
// The caller side (the SearchFilter enum schemas) is what guarantees validity.
if (f.eq !== undefined) {
preds.push(eb(column, '=', f.eq as never));
}
if (f.ne !== undefined) {
preds.push(eb(column, '<>', f.ne as never));
}
if (f.in !== undefined) {
preds.push(eb(column, 'in', f.in as never));
}
if (f.notIn !== undefined) {
preds.push(eb(column, 'not in', f.notIn as never));
}
}
type StringColumn =
| 'asset_exif.city'
| 'asset_exif.state'
| 'asset_exif.country'
| 'asset_exif.make'
| 'asset_exif.model'
| 'asset_exif.lensModel'
| 'asset_exif.description'
| 'asset.originalFileName'
| 'asset.originalPath';
function pushStringEqNeInNotIn(
preds: Expression<SqlBool>[],
eb: AssetEB,
column: StringColumn,
f: StringFilterNullable | StringPatternFilter | undefined,
) {
if (!f) {
return;
}
if (f.eq === null) {
preds.push(eb(column, 'is', null));
} else if (f.eq !== undefined) {
preds.push(eb(column, '=', f.eq));
}
if (f.ne === null) {
preds.push(eb(column, 'is not', null));
} else if (f.ne !== undefined) {
preds.push(eb(column, '<>', f.ne));
}
if (f.in !== undefined) {
preds.push(eb(column, 'in', f.in));
}
if (f.notIn !== undefined) {
preds.push(eb(column, 'not in', f.notIn));
}
}
function pushStringPattern(
preds: Expression<SqlBool>[],
eb: AssetEB,
column: StringColumn,
f: StringPatternFilter | undefined,
) {
if (!f) {
return;
}
pushStringEqNeInNotIn(preds, eb, column, f);
const ref = sql.ref(column);
if (f.like !== undefined) {
preds.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.like}) || '%')`);
}
if (f.notLike !== undefined) {
preds.push(sql<SqlBool>`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${f.notLike}) || '%')`);
}
if (f.startsWith !== undefined) {
preds.push(sql<SqlBool>`f_unaccent(${ref}) ilike (f_unaccent(${f.startsWith}) || '%')`);
}
if (f.endsWith !== undefined) {
preds.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.endsWith}))`);
}
}
type NumberColumn = 'asset_exif.rating' | 'asset_exif.fileSizeInByte';
function pushNumber(
preds: Expression<SqlBool>[],
eb: AssetEB,
column: NumberColumn,
f: NumberFilter | NumberFilterNullable | undefined,
) {
if (!f) {
return;
}
if (f.eq === null) {
preds.push(eb(column, 'is', null));
} else if (f.eq !== undefined) {
preds.push(eb(column, '=', f.eq));
}
if (f.ne === null) {
preds.push(eb(column, 'is not', null));
} else if (f.ne !== undefined) {
preds.push(eb(column, '<>', f.ne));
}
if (f.lt !== undefined) {
preds.push(eb(column, '<', f.lt));
}
if (f.lte !== undefined) {
preds.push(eb(column, '<=', f.lte));
}
if (f.gt !== undefined) {
preds.push(eb(column, '>', f.gt));
}
if (f.gte !== undefined) {
preds.push(eb(column, '>=', f.gte));
}
if (f.in !== undefined) {
preds.push(eb(column, 'in', f.in));
}
if (f.notIn !== undefined) {
preds.push(eb(column, 'not in', f.notIn));
}
}
type DateColumn = 'asset.fileCreatedAt' | 'asset.createdAt' | 'asset.updatedAt' | 'asset.deletedAt';
function pushDate(
preds: Expression<SqlBool>[],
eb: AssetEB,
column: DateColumn,
f: DateFilter | DateFilterNullable | undefined,
) {
if (!f) {
return;
}
if (f.eq === null) {
preds.push(eb(column, 'is', null));
} else if (f.eq !== undefined) {
preds.push(eb(column, '=', f.eq));
}
if (f.ne === null) {
preds.push(eb(column, 'is not', null));
} else if (f.ne !== undefined) {
preds.push(eb(column, '<>', f.ne));
}
if (f.gt !== undefined) {
preds.push(eb(column, '>', f.gt));
}
if (f.gte !== undefined) {
preds.push(eb(column, '>=', f.gte));
}
if (f.lt !== undefined) {
preds.push(eb(column, '<', f.lt));
}
if (f.lte !== undefined) {
preds.push(eb(column, '<=', f.lte));
}
}
function pushChecksum(preds: Expression<SqlBool>[], eb: AssetEB, f: StringFilter | undefined) {
if (!f) {
return;
}
if (f.eq !== undefined) {
preds.push(eb('asset.checksum', '=', fromChecksum(f.eq)));
}
if (f.ne !== undefined) {
preds.push(eb('asset.checksum', '<>', fromChecksum(f.ne)));
}
if (f.in !== undefined) {
preds.push(
eb(
'asset.checksum',
'in',
f.in.map((c: string) => fromChecksum(c)),
),
);
}
if (f.notIn !== undefined) {
preds.push(
eb(
'asset.checksum',
'not in',
f.notIn.map((c: string) => fromChecksum(c)),
),
);
}
}
function buildBranchPredicates(eb: AssetEB, b: SearchFilterBranch): Expression<SqlBool>[] {
const p: Expression<SqlBool>[] = [];
// id / libraryId
pushIdEqNe(p, eb, 'asset.id', b.id);
pushIdEqNe(p, eb, 'asset.libraryId', b.libraryId);
// enums
pushEnum(p, eb, 'asset.type', b.type);
pushEnum(p, eb, 'asset.visibility', b.visibility);
// bools on asset
if (b.isFavorite) {
p.push(eb('asset.isFavorite', '=', b.isFavorite.eq));
}
if (b.isOffline) {
p.push(eb('asset.isOffline', '=', b.isOffline.eq));
}
if (b.isMotion) {
p.push(eb('asset.livePhotoVideoId', b.isMotion.eq ? 'is not' : 'is', null));
}
if (b.isEncoded) {
p.push(existsEncodedVideo(eb, b.isEncoded.eq));
}
// membership presence
if (b.hasAlbums) {
p.push(existsAlbumLink(eb, b.hasAlbums.eq));
}
if (b.hasPeople) {
p.push(existsPersonLink(eb, b.hasPeople.eq));
}
if (b.hasTags) {
p.push(existsTagLink(eb, b.hasTags.eq));
}
// EXIF string columns (nullable)
pushStringEqNeInNotIn(p, eb, 'asset_exif.city', b.city);
pushStringEqNeInNotIn(p, eb, 'asset_exif.state', b.state);
pushStringEqNeInNotIn(p, eb, 'asset_exif.country', b.country);
pushStringEqNeInNotIn(p, eb, 'asset_exif.make', b.make);
pushStringEqNeInNotIn(p, eb, 'asset_exif.model', b.model);
pushStringEqNeInNotIn(p, eb, 'asset_exif.lensModel', b.lensModel);
// StringPattern columns
pushStringPattern(p, eb, 'asset_exif.description', b.description);
pushStringPattern(p, eb, 'asset.originalFileName', b.originalFileName);
pushStringPattern(p, eb, 'asset.originalPath', b.originalPath);
// ocr similarity (EXISTS over ocr_search — no top-level join)
if (b.ocr) {
p.push(existsOcrMatch(eb, b.ocr.matches));
}
// numbers
pushNumber(p, eb, 'asset_exif.rating', b.rating);
pushNumber(p, eb, 'asset_exif.fileSizeInByte', b.fileSizeInBytes);
// dates
pushDate(p, eb, 'asset.fileCreatedAt', b.takenAt);
pushDate(p, eb, 'asset.createdAt', b.createdAt);
pushDate(p, eb, 'asset.updatedAt', b.updatedAt);
pushDate(p, eb, 'asset.deletedAt', b.trashedAt);
// IdsFilter — EXISTS-based, composable with OR
if (b.albumIds) {
pushIdsFilter(p, eb, 'album', b.albumIds);
}
if (b.personIds) {
pushIdsFilter(p, eb, 'person', b.personIds);
}
if (b.tagIds) {
pushIdsFilter(p, eb, 'tag', b.tagIds);
}
// checksum (bytea, decoded from string on the wire)
pushChecksum(p, eb, b.checksum);
// encodedVideoPath — EXISTS over asset_file with path predicate
if (b.encodedVideoPath) {
p.push(...existsEncodedVideoPath(eb, b.encodedVideoPath));
}
return p;
}
function applySearchOrder<O>(
qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', O>,
field: SearchOrderField,
direction: AssetOrder,
) {
switch (field) {
case SearchOrderField.FileCreatedAt: {
return qb.orderBy('asset.fileCreatedAt', direction);
}
case SearchOrderField.LocalDateTime: {
return qb.orderBy('asset.localDateTime', direction);
}
case SearchOrderField.FileSizeInBytes: {
return qb.orderBy('asset_exif.fileSizeInByte', direction);
}
case SearchOrderField.Rating: {
return qb.orderBy('asset_exif.rating', direction);
}
}
}
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options) {
const filter = options.filter ?? {};
const orderField = options.order?.field ?? SearchOrderField.FileCreatedAt;
const orderDirection = options.order?.direction ?? AssetOrder.Desc;
const needsExifJoin = exifJoinRequired(filter, orderField);
return kysely
.withPlugin(joinDeduplicationPlugin)
.selectFrom('asset')
.$if(needsExifJoin && !options.withExif, (qb) => qb.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId'))
.$if(!!options.withExif && needsExifJoin, withExifInner)
.$if(!!options.withExif && !needsExifJoin, withExif)
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.where((eb) => {
const top = buildBranchPredicates(eb, filter);
if (filter.or && filter.or.length > 0) {
top.push(eb.or(filter.or.map((branch) => eb.and(buildBranchPredicates(eb, branch)))));
}
return top.length > 0 ? eb.and(top) : eb.val(true);
})
.$call((qb) =>
applySearchOrder(qb as SelectQueryBuilder<DB, 'asset' | 'asset_exif', unknown>, orderField, orderDirection),
);
}
export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;