Compare commits

..

8 Commits

Author SHA1 Message Date
timonrieger 0eb4b3b526 sync sql 2026-05-29 23:39:39 +02:00
timonrieger 32945a01b4 cleanup 2026-05-29 23:39:30 +02:00
timonrieger 3817aec5b1 drop unnecessary exports 2026-05-29 23:28:34 +02:00
timonrieger 82054eb1c7 add query builders 2026-05-29 23:28:34 +02:00
timonrieger dc7f3f5aa4 rename searchAssetBuilder 2026-05-29 23:28:34 +02:00
timonrieger 2c58b32bbc add query helpers 2026-05-29 23:28:34 +02:00
timonrieger 3a5e172262 drop allowed param 2026-05-29 23:28:34 +02:00
timonrieger 7f38183cbb feat: new search filtering schemas 2026-05-29 23:28:34 +02:00
5 changed files with 1452 additions and 11 deletions
+201 -1
View File
@@ -3,7 +3,14 @@ import { Place } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseSchema } from 'src/dtos/album.dto';
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
import {
AssetOrder,
AssetOrderSchema,
AssetTypeSchema,
AssetVisibilitySchema,
SearchOrderField,
SearchOrderFieldSchema,
} from 'src/enum';
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
import z from 'zod';
@@ -141,6 +148,199 @@ const SearchSuggestionRequestSchema = z
})
.meta({ id: 'SearchSuggestionRequestDto' });
// v3 SearchFilter DTOs — new shape introduced alongside the legacy flat DTOs above.
const atLeastOneKey = <T extends z.ZodObject>(schema: T) => {
const keys = Object.keys(schema.shape);
return schema.refine((value) => Object.values(value).some((v) => v !== undefined), {
message: `At least one of the following keys is required: ${keys.join(', ')}`,
});
};
const IdFilterSchema = atLeastOneKey(
z.strictObject({
eq: z.uuidv4().optional(),
ne: z.uuidv4().optional(),
}),
).meta({ id: 'IdFilter' });
const IdFilterNullableSchema = atLeastOneKey(
z.strictObject({
eq: z.uuidv4().nullable().optional(),
ne: z.uuidv4().nullable().optional(),
}),
).meta({ id: 'IdFilterNullable' });
const IdsFilterSchema = atLeastOneKey(
z.strictObject({
any: z.array(z.uuidv4()).min(1).optional(),
all: z.array(z.uuidv4()).min(1).optional(),
none: z.array(z.uuidv4()).min(1).optional(),
}),
).meta({ id: 'IdsFilter' });
const StringFilterSchema = atLeastOneKey(
z.strictObject({
eq: z.string().optional(),
ne: z.string().optional(),
in: z.array(z.string()).min(1).optional(),
notIn: z.array(z.string()).min(1).optional(),
}),
).meta({ id: 'StringFilter' });
const StringFilterNullableSchema = atLeastOneKey(
z.strictObject({
eq: z.string().nullable().optional(),
ne: z.string().nullable().optional(),
in: z.array(z.string()).min(1).optional(),
notIn: z.array(z.string()).min(1).optional(),
}),
).meta({ id: 'StringFilterNullable' });
const StringPatternFilterSchema = atLeastOneKey(
z.strictObject({
eq: z.string().nullable().optional(),
ne: z.string().nullable().optional(),
in: z.array(z.string()).min(1).optional(),
notIn: z.array(z.string()).min(1).optional(),
like: z.string().min(1).optional(),
notLike: z.string().min(1).optional(),
startsWith: z.string().min(1).optional(),
endsWith: z.string().min(1).optional(),
}),
).meta({ id: 'StringPatternFilter' });
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(),
gte: z.number().optional(),
in: z.array(z.number()).min(1).optional(),
notIn: z.array(z.number()).min(1).optional(),
}),
).meta({ id: 'NumberFilter' });
const NumberFilterNullableSchema = atLeastOneKey(
z.strictObject({
eq: z.number().nullable().optional(),
ne: z.number().nullable().optional(),
lt: z.number().optional(),
lte: z.number().optional(),
gt: z.number().optional(),
gte: z.number().optional(),
in: z.array(z.number()).min(1).optional(),
notIn: z.array(z.number()).min(1).optional(),
}),
).meta({ id: 'NumberFilterNullable' });
const DateFilterSchema = atLeastOneKey(
z.strictObject({
eq: isoDatetimeToDate.optional(),
ne: isoDatetimeToDate.optional(),
gt: isoDatetimeToDate.optional(),
gte: isoDatetimeToDate.optional(),
lt: isoDatetimeToDate.optional(),
lte: isoDatetimeToDate.optional(),
}),
).meta({ id: 'DateFilter' });
const DateFilterNullableSchema = atLeastOneKey(
z.strictObject({
eq: isoDatetimeToDate.nullable().optional(),
ne: isoDatetimeToDate.nullable().optional(),
gt: isoDatetimeToDate.optional(),
gte: isoDatetimeToDate.optional(),
lt: isoDatetimeToDate.optional(),
lte: isoDatetimeToDate.optional(),
}),
).meta({ id: 'DateFilterNullable' });
const BoolFilterSchema = z.strictObject({ eq: z.boolean() }).meta({ id: 'BoolFilter' });
const enumFilterSchema = <T extends z.core.util.EnumLike>(values: z.ZodEnum<T>, id: string) =>
atLeastOneKey(
z.strictObject({
eq: values.optional(),
ne: values.optional(),
in: z.array(values).min(1).optional(),
notIn: z.array(values).min(1).optional(),
}),
).meta({ id });
const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType');
const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility');
const StringSimilarityFilterSchema = z
.strictObject({
matches: z.string().min(1),
})
.meta({ id: 'StringSimilarityFilter' });
export const SearchOrderSchema = z
.strictObject({
field: SearchOrderFieldSchema.default(SearchOrderField.FileCreatedAt),
direction: AssetOrderSchema.default(AssetOrder.Desc),
})
.meta({ id: 'SearchOrder' });
const SearchFilterBranchSchema = z
.strictObject({
id: IdFilterSchema.optional(),
libraryId: IdFilterNullableSchema.optional(),
type: EnumFilterAssetTypeSchema.optional(),
visibility: EnumFilterAssetVisibilitySchema.optional(),
isFavorite: BoolFilterSchema.optional(),
isMotion: BoolFilterSchema.optional(),
isOffline: BoolFilterSchema.optional(),
isEncoded: BoolFilterSchema.optional(),
hasAlbums: BoolFilterSchema.optional(),
hasPeople: BoolFilterSchema.optional(),
hasTags: BoolFilterSchema.optional(),
city: StringFilterNullableSchema.optional(),
state: StringFilterNullableSchema.optional(),
country: StringFilterNullableSchema.optional(),
make: StringFilterNullableSchema.optional(),
model: StringFilterNullableSchema.optional(),
lensModel: StringFilterNullableSchema.optional(),
description: StringPatternFilterSchema.optional(),
originalFileName: StringPatternFilterSchema.optional(),
originalPath: StringPatternFilterSchema.optional(),
ocr: StringSimilarityFilterSchema.optional(),
rating: NumberFilterNullableSchema.optional(),
fileSizeInBytes: NumberFilterSchema.optional(),
takenAt: DateFilterSchema.optional(),
createdAt: DateFilterSchema.optional(),
updatedAt: DateFilterSchema.optional(),
trashedAt: DateFilterNullableSchema.optional(),
personIds: IdsFilterSchema.optional(),
tagIds: IdsFilterSchema.optional(),
albumIds: IdsFilterSchema.optional(),
checksum: StringFilterSchema.optional(),
encodedVideoPath: StringFilterSchema.optional(),
})
.meta({ id: 'SearchFilterBranch' });
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
or: z.array(SearchFilterBranchSchema).min(1).optional(),
}).meta({ id: 'SearchFilter' });
export type IdFilter = z.infer<typeof IdFilterSchema>;
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
export type IdsFilter = z.infer<typeof IdsFilterSchema>;
export type StringFilter = z.infer<typeof StringFilterSchema>;
export type StringFilterNullable = z.infer<typeof StringFilterNullableSchema>;
export type StringPatternFilter = z.infer<typeof StringPatternFilterSchema>;
export type NumberFilter = z.infer<typeof NumberFilterSchema>;
export type NumberFilterNullable = z.infer<typeof NumberFilterNullableSchema>;
export type DateFilter = z.infer<typeof DateFilterSchema>;
export type DateFilterNullable = z.infer<typeof DateFilterNullableSchema>;
export type SearchOrder = z.infer<typeof SearchOrderSchema>;
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
+9
View File
@@ -1176,3 +1176,12 @@ export enum WorkflowType {
}
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
export enum SearchOrderField {
FileCreatedAt = 'fileCreatedAt',
LocalDateTime = 'localDateTime',
FileSizeInBytes = 'fileSizeInBytes',
Rating = 'rating',
}
export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' });
+441
View File
@@ -281,3 +281,444 @@ where
and "deletedAt" is null
and "lensModel" is not null
and "lensModel" != $3
-- SearchRepository.searchMetadataV3 (baseline)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and $2
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (empty)
select
"asset".*
from
"asset"
where
$1
order by
"asset"."fileCreatedAt" desc
limit
$2
-- SearchRepository.searchMetadataV3 (or-exif-only)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset_exif"."city" = $2
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (string-eq-null)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset_exif"."city" is null
order by
"asset"."fileCreatedAt" desc
limit
$2
-- SearchRepository.searchMetadataV3 (string-pattern-like)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%')
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (string-pattern-notLike)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%')
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (string-pattern-startsWith)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%')
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (string-similarity-ocr)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and exists (
select
from
"ocr_search"
where
"ocr_search"."assetId" = "asset"."id"
and f_unaccent (ocr_search.text) %>> f_unaccent ($2)
)
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (ids-any)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($2::uuid[])
)
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (ids-all)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and exists (
select
"asset_face"."assetId"
from
"asset_face"
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."personId" = any ($2::uuid[])
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
group by
"asset_face"."assetId"
having
count(distinct "asset_face"."personId") = $4
)
order by
"asset"."fileCreatedAt" desc
limit
$5
-- SearchRepository.searchMetadataV3 (ids-all-single)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($2::uuid[])
)
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (ids-none)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and not exists (
select
from
"tag_asset"
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
where
"tag_asset"."assetId" = "asset"."id"
and "tag_closure"."id_ancestor" = any ($2::uuid[])
)
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (ids-tags-all)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and exists (
select
"tag_asset"."assetId"
from
"tag_asset"
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
where
"tag_asset"."assetId" = "asset"."id"
and "tag_closure"."id_ancestor" = any ($2::uuid[])
group by
"tag_asset"."assetId"
having
count(distinct "tag_closure"."id_ancestor") >= $3
)
order by
"asset"."fileCreatedAt" desc
limit
$4
-- SearchRepository.searchMetadataV3 (has-albums-false)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and not exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
)
order by
"asset"."fileCreatedAt" desc
limit
$2
-- SearchRepository.searchMetadataV3 (is-encoded)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and exists (
select
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $2
)
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (number-range)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset_exif"."fileSizeInByte" <= $2
and "asset_exif"."fileSizeInByte" >= $3
)
order by
"asset"."fileCreatedAt" desc
limit
$4
-- SearchRepository.searchMetadataV3 (date-eq)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset"."fileCreatedAt" = $2
order by
"asset"."fileCreatedAt" desc
limit
$3
-- SearchRepository.searchMetadataV3 (date-range)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."fileCreatedAt" >= $2
and "asset"."fileCreatedAt" < $3
)
order by
"asset"."fileCreatedAt" desc
limit
$4
-- SearchRepository.searchMetadataV3 (order-fileSize-noExif)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and $2
order by
"asset_exif"."fileSizeInByte" desc
limit
$3
-- SearchRepository.searchMetadataV3 (order-rating-withExif)
select
to_json("asset_exif") as "exifInfo",
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and $2
order by
"asset_exif"."rating" asc
limit
$3
-- SearchRepository.searchMetadataV3 (or-branches)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."isFavorite" = $2
or exists (
select
from
"asset_face"
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."personId" = any ($3::uuid[])
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $4
)
)
order by
"asset"."fileCreatedAt" desc
limit
$5
-- SearchRepository.searchMetadataV3 (or-with-top-level)
select
"asset".*
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."fileCreatedAt" >= $2
and "asset"."fileCreatedAt" < $3
and (
"asset"."isFavorite" = $4
or exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($5::uuid[])
)
)
)
order by
"asset"."fileCreatedAt" desc
limit
$6
-- SearchRepository.searchStatisticsV3 (baseline)
select
count(*) as "total"
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and $2
order by
"asset"."fileCreatedAt" desc
-- SearchRepository.searchStatisticsV3 (with-filter)
select
count(*) as "total"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset_exif"."fileSizeInByte" >= $2
and "asset"."fileCreatedAt" >= $3
and "asset"."fileCreatedAt" < $4
)
order by
"asset"."fileCreatedAt" desc
-- SearchRepository.searchStatisticsV3 (with-or)
select
count(*) as "total"
from
"asset"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."isFavorite" = $2
or not exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
)
)
order by
"asset"."fileCreatedAt" desc
+193 -7
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, searchAssetBuilder, 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 &
@@ -195,7 +212,7 @@ export class SearchRepository {
})
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) {
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
const items = await searchAssetBuilder(this.db, options)
const items = await searchAssetBuilderLegacy(this.db, options)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', orderDirection)
.limit(pagination.size + 1)
@@ -216,7 +233,7 @@ export class SearchRepository {
],
})
searchStatistics(options: AssetSearchOptions) {
return searchAssetBuilder(this.db, options)
return searchAssetBuilderLegacy(this.db, options)
.select((qb) => qb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
}
@@ -234,7 +251,7 @@ export class SearchRepository {
],
})
async searchRandom(size: number, options: AssetSearchOptions) {
return searchAssetBuilder(this.db, options)
return searchAssetBuilderLegacy(this.db, options)
.selectAll('asset')
.orderBy(sql`random()`)
.limit(size)
@@ -255,7 +272,7 @@ export class SearchRepository {
})
searchLargeAssets(size: number, options: LargeAssetSearchOptions) {
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
return searchAssetBuilder(this.db, options)
return searchAssetBuilderLegacy(this.db, options)
.selectAll('asset')
.$call(withExifInner)
.where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0)
@@ -284,7 +301,7 @@ export class SearchRepository {
return this.db.transaction().execute(async (trx) => {
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx);
const items = await searchAssetBuilder(trx, options)
const items = await searchAssetBuilderLegacy(trx, options)
.selectAll('asset')
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
@@ -489,6 +506,175 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
@GenerateSql(
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
{ name: 'empty', params: [{ size: 100 }, {}] },
{
name: 'or-exif-only',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
},
{
name: 'string-eq-null',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
},
{
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-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] } } },
],
},
{
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')
+608 -3
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 {
@@ -368,7 +392,7 @@ export function withEdits(eb: ExpressionBuilder<DB, 'asset'>): 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<DB>, options: AssetSearchBuilderOptions) {
export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
@@ -485,6 +509,587 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$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(branch) as (keyof typeof FIELD_BACKING)[]) {
if (FIELD_BACKING[key] === 'asset_exif') {
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'>;
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;
}
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(f.all.length === 1 ? idsAnyExists(eb, kind, f.all) : idsAllExists(eb, kind, f.all));
}
if (f.none) {
preds.push(eb.not(idsAnyExists(eb, kind, f.none)));
}
}
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>[] = [];
pushIdEqNe(p, eb, 'asset.id', b.id);
pushIdEqNe(p, eb, 'asset.libraryId', b.libraryId);
pushEnum(p, eb, 'asset.type', b.type);
pushEnum(p, eb, 'asset.visibility', b.visibility);
if (b.isFavorite) {
p.push(eb('asset.isFavorite', '=', b.isFavorite.eq));
}
if (b.isOffline) {
p.push(eb('asset.isOffline', '=', b.isOffline.eq));
}
if (b.isMotion) {
p.push(eb('asset.livePhotoVideoId', b.isMotion.eq ? 'is not' : 'is', null));
}
if (b.isEncoded) {
p.push(existsEncodedVideo(eb, b.isEncoded.eq));
}
if (b.hasAlbums) {
p.push(existsAlbumLink(eb, b.hasAlbums.eq));
}
if (b.hasPeople) {
p.push(existsPersonLink(eb, b.hasPeople.eq));
}
if (b.hasTags) {
p.push(existsTagLink(eb, b.hasTags.eq));
}
pushStringEqNeInNotIn(p, eb, 'asset_exif.city', b.city);
pushStringEqNeInNotIn(p, eb, 'asset_exif.state', b.state);
pushStringEqNeInNotIn(p, eb, 'asset_exif.country', b.country);
pushStringEqNeInNotIn(p, eb, 'asset_exif.make', b.make);
pushStringEqNeInNotIn(p, eb, 'asset_exif.model', b.model);
pushStringEqNeInNotIn(p, eb, 'asset_exif.lensModel', b.lensModel);
pushStringPattern(p, eb, 'asset_exif.description', b.description);
pushStringPattern(p, eb, 'asset.originalFileName', b.originalFileName);
pushStringPattern(p, eb, 'asset.originalPath', b.originalPath);
if (b.ocr) {
p.push(existsOcrMatch(eb, b.ocr.matches));
}
pushNumber(p, eb, 'asset_exif.rating', b.rating);
pushNumber(p, eb, 'asset_exif.fileSizeInByte', b.fileSizeInBytes);
pushDate(p, eb, 'asset.fileCreatedAt', b.takenAt);
pushDate(p, eb, 'asset.createdAt', b.createdAt);
pushDate(p, eb, 'asset.updatedAt', b.updatedAt);
pushDate(p, eb, 'asset.deletedAt', b.trashedAt);
if (b.albumIds) {
pushIdsFilter(p, eb, 'album', b.albumIds);
}
if (b.personIds) {
pushIdsFilter(p, eb, 'person', b.personIds);
}
if (b.tagIds) {
pushIdsFilter(p, eb, 'tag', b.tagIds);
}
pushChecksum(p, eb, b.checksum);
if (b.encodedVideoPath) {
p.push(...existsEncodedVideoPath(eb, b.encodedVideoPath));
}
return p;
}
function 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;