2026-02-23 15:50:16 +01:00
import { createPostgres , DatabaseConnectionParams } from '@immich/sql-tools' ;
2025-04-15 13:26:56 -04:00
import {
2026-01-09 17:59:52 -05:00
AliasedRawBuilder ,
2025-04-18 23:39:56 +02:00
DeduplicateJoinsPlugin ,
2025-04-15 13:26:56 -04:00
Expression ,
ExpressionBuilder ,
2025-04-18 23:39:56 +02:00
Kysely ,
2025-04-15 13:26:56 -04:00
KyselyConfig ,
2026-03-11 16:17:31 +01:00
NotNull ,
2026-07-23 00:33:23 +02:00
OperandValueExpression ,
ReferenceExpression ,
2025-04-15 13:26:56 -04:00
Selectable ,
2025-04-18 23:39:56 +02:00
SelectQueryBuilder ,
2026-03-11 16:17:31 +01:00
ShallowDehydrateObject ,
2025-04-15 13:26:56 -04:00
sql ,
2026-07-23 00:33:23 +02:00
SqlBool ,
2025-04-15 13:26:56 -04:00
} from 'kysely' ;
import { PostgresJSDialect } from 'kysely-postgres-js' ;
2025-04-18 23:39:56 +02:00
import { jsonArrayFrom , jsonObjectFrom } from 'kysely/helpers/postgres' ;
2026-02-23 15:50:16 +01:00
import { Notice , PostgresError } from 'postgres' ;
2026-03-11 16:17:31 +01:00
import { columns , lockableProperties , LockableProperty , Person } from 'src/database' ;
2026-07-23 00:33:23 +02:00
import { DummyValue , GenerateSqlQueries } from 'src/decorators' ;
2026-01-09 17:59:52 -05:00
import { AssetEditActionItem } from 'src/dtos/editing.dto' ;
2026-07-23 00:33:23 +02:00
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' ;
2025-06-30 13:19:16 -04:00
import { DB } from 'src/schema' ;
2026-03-11 16:17:31 +01:00
import { AssetExifTable } from 'src/schema/tables/asset-exif.table' ;
2026-05-04 09:04:29 -04:00
import { AudioStreamInfo , VectorExtension , VideoFormat , VideoPacketInfo , VideoStreamInfo } from 'src/types' ;
2026-07-23 00:33:23 +02:00
import { fromChecksum } from 'src/utils/request' ;
2025-04-15 13:26:56 -04:00
2026-02-23 15:50:16 +01:00
export const getKyselyConfig = ( connection : DatabaseConnectionParams ) : KyselyConfig = > {
2025-04-15 13:26:56 -04:00
return {
dialect : new PostgresJSDialect ( {
2026-02-23 15:50:16 +01:00
postgres : createPostgres ( {
connection ,
onNotice : ( notice : Notice ) = > {
2025-04-15 13:26:56 -04:00
if ( notice [ 'severity' ] !== 'NOTICE' ) {
console . warn ( 'Postgres notice:' , notice ) ;
}
} ,
} ) ,
} ) ,
log ( event ) {
2026-07-20 23:47:14 -04:00
if ( event . level !== 'error' ) {
return ;
}
2026-02-10 15:43:55 -05:00
2026-07-20 23:47:14 -04:00
if ( isAssetChecksumConstraint ( event . error ) ) {
return ;
2025-04-15 13:26:56 -04:00
}
2026-07-20 23:47:14 -04:00
console . error ( 'Query failed :' , {
durationMs : event.queryDurationMillis ,
error : event.error ,
sql : event.query.sql ,
params : event.query.parameters ,
} ) ;
2025-04-15 13:26:56 -04:00
} ,
} ;
} ;
2023-12-08 11:15:46 -05:00
2026-07-23 00:33:23 +02:00
const uniqueIds = ( ids : string [ ] ) = > [ . . . new Set ( ids ) ] ;
2025-01-09 11:15:41 -05:00
export const asUuid = ( id : string | Expression < string > ) = > sql < string > ` ${ id } ::uuid ` ;
2024-02-17 11:00:55 -06:00
2025-01-09 11:15:41 -05:00
export const anyUuid = ( ids : string [ ] ) = > sql < string > ` any( ${ ` { ${ ids } } ` } ::uuid[]) ` ;
2024-02-12 20:50:47 -05:00
2025-03-06 16:00:18 +01:00
export const unnest = ( array : string [ ] ) = > sql < Record < string , string > > ` unnest(array[ ${ sql . join ( array ) } ]::text[]) ` ;
2025-03-06 13:33:24 -05:00
export const removeUndefinedKeys = < T extends object > ( update : T , template : unknown ) = > {
for ( const key in update ) {
if ( ( template as T ) [ key ] === undefined ) {
delete update [ key ] ;
}
}
return update ;
} ;
2025-04-11 14:44:45 -04:00
2025-04-18 23:39:56 +02:00
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum' ;
2026-06-01 14:52:29 -04:00
export const VIDEO_STREAM_SESSION_PK_CONSTRAINT = 'video_stream_session_pkey' ;
2025-04-18 23:39:56 +02:00
2026-06-01 14:52:29 -04:00
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 ;
2025-08-27 15:10:55 -04:00
2025-07-14 10:13:06 -04:00
export function withDefaultVisibility < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > ) {
2025-07-15 14:50:13 -04:00
return qb . where ( 'asset.visibility' , 'in' , [ sql . lit ( AssetVisibility . Archive ) , sql . lit ( AssetVisibility . Timeline ) ] ) ;
2025-05-06 12:12:48 -05:00
}
2026-07-23 00:33:23 +02:00
const selectExifInfo = ( eb : AssetExpressionBuilder ) = >
eb . fn
. toJson ( eb . table ( 'asset_exif' ) )
. $castTo < ShallowDehydrateObject < Selectable < AssetExifTable > > | null > ( )
. as ( 'exifInfo' ) ;
2025-06-02 10:33:08 -04:00
// TODO come up with a better query that only selects the fields we need
2025-07-14 10:13:06 -04:00
export function withExif < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > ) {
2026-07-23 00:33:23 +02:00
return qb . leftJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' ) . select ( selectExifInfo ) ;
2025-04-18 23:39:56 +02:00
}
2025-07-14 10:13:06 -04:00
export function withExifInner < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > ) {
2025-04-18 23:39:56 +02:00
return qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
2026-03-11 16:17:31 +01:00
. select ( ( eb ) = > eb . fn . toJson ( eb . table ( 'asset_exif' ) ) . as ( 'exifInfo' ) )
. $narrowType < { exifInfo : NotNull } > ( ) ;
2025-04-18 23:39:56 +02:00
}
2026-05-01 13:03:49 -04:00
export const dummy = sql ` (select 1) ` . as ( 'dummy' ) ;
export function withAudioStream ( eb : ExpressionBuilder < DB , 'asset_exif' | 'asset_audio' > ) {
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 < AudioStreamInfo | null > ( ) ,
) ;
}
export function withVideoStream ( eb : ExpressionBuilder < DB , 'asset_exif' | 'asset_video' > ) {
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 ) ) ,
2026-05-04 09:04:29 -04:00
) . $castTo < ( VideoStreamInfo & { timeBase : number } ) | null > ( ) ;
2026-05-01 13:03:49 -04:00
}
export function withVideoFormat ( eb : ExpressionBuilder < DB , 'asset' | 'asset_video' > ) {
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 < VideoFormat | null > ( ) ;
}
2026-05-04 09:04:29 -04:00
export function withVideoPackets ( eb : ExpressionBuilder < DB , 'asset' | 'asset_keyframe' > ) {
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 < VideoPacketInfo | null > ( ) ;
}
2025-07-14 10:13:06 -04:00
export function withSmartSearch < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > ) {
2025-04-18 23:39:56 +02:00
return qb
2025-07-14 10:13:06 -04:00
. leftJoin ( 'smart_search' , 'asset.id' , 'smart_search.assetId' )
2026-03-11 16:17:31 +01:00
. select ( ( eb ) = > jsonObjectFrom ( eb . table ( 'smart_search' ) ) . as ( 'smartSearch' ) ) ;
2025-04-18 23:39:56 +02:00
}
2026-01-09 17:59:52 -05:00
export function withFaces ( eb : ExpressionBuilder < DB , 'asset' > , withHidden? : boolean , withDeletedFace? : boolean ) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom (
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'asset_face' )
. selectAll ( 'asset_face' )
. whereRef ( 'asset_face.assetId' , '=' , 'asset.id' )
2026-01-09 17:59:52 -05:00
. $if ( ! withDeletedFace , ( qb ) = > qb . where ( 'asset_face.deletedAt' , 'is' , null ) )
. $if ( ! withHidden , ( qb ) = > qb . where ( 'asset_face.isVisible' , '=' , true ) ) ,
2025-04-18 23:39:56 +02:00
) . as ( 'faces' ) ;
}
2025-07-14 10:13:06 -04:00
export function withFiles ( eb : ExpressionBuilder < DB , 'asset' > , type ? : AssetFileType ) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom (
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'asset_file' )
2025-04-18 23:39:56 +02:00
. select ( columns . assetFiles )
2025-07-14 10:13:06 -04:00
. whereRef ( 'asset_file.assetId' , '=' , 'asset.id' )
. $if ( ! ! type , ( qb ) = > qb . where ( 'asset_file.type' , '=' , type ! ) ) ,
2025-04-18 23:39:56 +02:00
) . as ( 'files' ) ;
}
2026-04-01 07:19:38 -05:00
export function withFilePath ( eb : ExpressionBuilder < DB , 'asset' > , type : AssetFileType , isEdited = false ) {
2025-10-27 22:09:55 +08:00
return eb
. selectFrom ( 'asset_file' )
. select ( 'asset_file.path' )
. whereRef ( 'asset_file.assetId' , '=' , 'asset.id' )
2026-04-01 07:19:38 -05:00
. where ( 'asset_file.type' , '=' , sql . lit ( type ) )
. where ( 'asset_file.isEdited' , '=' , sql . lit ( isEdited ) ) ;
2025-10-27 22:09:55 +08:00
}
2026-01-09 17:59:52 -05:00
export function withFacesAndPeople (
eb : ExpressionBuilder < DB , 'asset' > ,
withHidden? : boolean ,
withDeletedFace? : boolean ,
) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom (
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'asset_face' )
2025-04-18 23:39:56 +02:00
. leftJoinLateral (
( eb ) = >
2025-07-14 10:13:06 -04:00
eb . selectFrom ( 'person' ) . selectAll ( 'person' ) . whereRef ( 'asset_face.personId' , '=' , 'person.id' ) . as ( 'person' ) ,
2025-04-18 23:39:56 +02:00
( join ) = > join . onTrue ( ) ,
)
2025-07-14 10:13:06 -04:00
. selectAll ( 'asset_face' )
2026-03-11 16:17:31 +01:00
. select ( ( eb ) = > eb . table ( 'person' ) . $castTo < ShallowDehydrateObject < Person > > ( ) . as ( 'person' ) )
2025-07-14 10:13:06 -04:00
. whereRef ( 'asset_face.assetId' , '=' , 'asset.id' )
2026-01-09 17:59:52 -05:00
. $if ( ! withDeletedFace , ( qb ) = > qb . where ( 'asset_face.deletedAt' , 'is' , null ) )
. $if ( ! withHidden , ( qb ) = > qb . where ( 'asset_face.isVisible' , 'is' , true ) ) ,
2025-04-18 23:39:56 +02:00
) . as ( 'faces' ) ;
}
2025-07-14 10:13:06 -04:00
export function hasPeople < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > , personIds : string [ ] ) {
2025-04-18 23:39:56 +02:00
return qb . innerJoin (
( eb ) = >
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'asset_face' )
2025-04-18 23:39:56 +02:00
. select ( 'assetId' )
. where ( 'personId' , '=' , anyUuid ( personIds ! ) )
. where ( 'deletedAt' , 'is' , null )
2026-01-09 17:59:52 -05:00
. where ( 'isVisible' , 'is' , true )
2025-04-18 23:39:56 +02:00
. groupBy ( 'assetId' )
. having ( ( eb ) = > eb . fn . count ( 'personId' ) . distinct ( ) , '=' , personIds . length )
. as ( 'has_people' ) ,
2025-07-14 10:13:06 -04:00
( join ) = > join . onRef ( 'has_people.assetId' , '=' , 'asset.id' ) ,
2025-04-18 23:39:56 +02:00
) ;
}
2025-07-14 10:13:06 -04:00
export function inAlbums < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > , albumIds : string [ ] ) {
2025-06-09 11:11:43 -04:00
return qb . innerJoin (
( eb ) = >
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'album_asset' )
2025-11-04 16:03:21 -05:00
. select ( 'assetId' )
. where ( 'albumId' , '=' , anyUuid ( albumIds ! ) )
. groupBy ( 'assetId' )
. having ( ( eb ) = > eb . fn . count ( 'albumId' ) . distinct ( ) , '=' , albumIds . length )
2025-06-09 11:11:43 -04:00
. as ( 'has_album' ) ,
2025-11-04 16:03:21 -05:00
( join ) = > join . onRef ( 'has_album.assetId' , '=' , 'asset.id' ) ,
2025-06-09 11:11:43 -04:00
) ;
}
2025-07-14 10:13:06 -04:00
export function hasTags < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > , tagIds : string [ ] ) {
2025-04-18 23:39:56 +02:00
return qb . innerJoin (
( eb ) = >
eb
. selectFrom ( 'tag_asset' )
2025-11-04 16:03:21 -05:00
. select ( 'assetId' )
. innerJoin ( 'tag_closure' , 'tag_asset.tagId' , 'tag_closure.id_descendant' )
2025-07-14 10:13:06 -04:00
. where ( 'tag_closure.id_ancestor' , '=' , anyUuid ( tagIds ) )
2025-11-04 16:03:21 -05:00
. groupBy ( 'assetId' )
2025-07-14 10:13:06 -04:00
. having ( ( eb ) = > eb . fn . count ( 'tag_closure.id_ancestor' ) . distinct ( ) , '>=' , tagIds . length )
2025-04-18 23:39:56 +02:00
. as ( 'has_tags' ) ,
2025-11-04 16:03:21 -05:00
( join ) = > join . onRef ( 'has_tags.assetId' , '=' , 'asset.id' ) ,
2025-04-18 23:39:56 +02:00
) ;
}
2025-07-14 10:13:06 -04:00
export function withOwner ( eb : ExpressionBuilder < DB , 'asset' > ) {
return jsonObjectFrom ( eb . selectFrom ( 'user' ) . select ( columns . user ) . whereRef ( 'user.id' , '=' , 'asset.ownerId' ) ) . as (
2025-04-18 23:39:56 +02:00
'owner' ,
) ;
}
2025-07-14 10:13:06 -04:00
export function withLibrary ( eb : ExpressionBuilder < DB , 'asset' > ) {
2025-04-18 23:39:56 +02:00
return jsonObjectFrom (
2025-07-14 10:13:06 -04:00
eb . selectFrom ( 'library' ) . selectAll ( 'library' ) . whereRef ( 'library.id' , '=' , 'asset.libraryId' ) ,
2025-04-18 23:39:56 +02:00
) . as ( 'library' ) ;
}
2025-07-14 10:13:06 -04:00
export function withTags ( eb : ExpressionBuilder < DB , 'asset' > ) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom (
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'tag' )
2025-04-18 23:39:56 +02:00
. select ( columns . tag )
2025-11-04 16:03:21 -05:00
. innerJoin ( 'tag_asset' , 'tag.id' , 'tag_asset.tagId' )
. whereRef ( 'asset.id' , '=' , 'tag_asset.assetId' ) ,
2025-04-18 23:39:56 +02:00
) . as ( 'tags' ) ;
}
2026-06-04 15:36:09 -04:00
export function truncatedDate < O > ( order : AssetOrderBy = AssetOrderBy . TakenAt , size ? : 'DAY' | 'MONTH' ) {
return sql < O > ` date_trunc( ${ sql . lit ( size ? ? 'MONTH' ) } , ${ sql . ref ( order === AssetOrderBy . CreatedAt ? 'asset.createdAt' : 'localDateTime' ) } AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' ` ;
2025-04-18 23:39:56 +02:00
}
2025-07-14 10:13:06 -04:00
export function withTagId < O > ( qb : SelectQueryBuilder < DB , 'asset' , O > , tagId : string ) {
2025-04-18 23:39:56 +02:00
return qb . where ( ( eb ) = >
eb . exists (
eb
2025-07-14 10:13:06 -04:00
. selectFrom ( 'tag_closure' )
2025-11-04 16:03:21 -05:00
. innerJoin ( 'tag_asset' , 'tag_asset.tagId' , 'tag_closure.id_descendant' )
. whereRef ( 'tag_asset.assetId' , '=' , 'asset.id' )
2025-07-14 10:13:06 -04:00
. where ( 'tag_closure.id_ancestor' , '=' , tagId ) ,
2025-04-18 23:39:56 +02:00
) ,
) ;
}
2025-05-19 17:40:48 -04:00
2025-12-01 12:24:37 -05:00
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 ;
} ;
2026-01-09 17:59:52 -05:00
// needed to properly type the return with the EditActionItem discriminated union type
type AliasedEditActions = AliasedRawBuilder < AssetEditActionItem [ ] , 'edits' > ;
export function withEdits ( eb : ExpressionBuilder < DB , 'asset' > ) : AliasedEditActions {
return jsonArrayFrom (
eb
. selectFrom ( 'asset_edit' )
. select ( [ 'asset_edit.action' , 'asset_edit.parameters' ] )
. whereRef ( 'asset_edit.assetId' , '=' , 'asset.id' ) ,
) . as ( 'edits' ) as AliasedEditActions ;
}
2025-04-18 23:39:56 +02:00
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin ( ) ;
2026-08-18 17:55:26 +02:00
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
2025-04-18 23:39:56 +02:00
2026-07-23 00:33:23 +02:00
export function searchAssetBuilderLegacy ( kysely : Kysely < DB > , options : AssetSearchBuilderOptions ) {
2025-04-18 23:39:56 +02:00
options . withDeleted || = ! ! ( options . trashedAfter || options . trashedBefore || options . isOffline ) ;
2025-05-06 12:12:48 -05:00
2025-04-18 23:39:56 +02:00
return kysely
. withPlugin ( joinDeduplicationPlugin )
2025-07-14 10:13:06 -04:00
. selectFrom ( 'asset' )
2026-06-29 22:00:02 +02:00
. $if ( ! ! options . visibility , ( qb ) = >
options . visibility === 'not-locked'
? qb . where ( 'asset.visibility' , '!=' , AssetVisibility . Locked )
: qb . where ( 'asset.visibility' , '=' , options . visibility ! ) ,
)
2025-06-09 11:11:43 -04:00
. $if ( ! ! options . albumIds && options . albumIds . length > 0 , ( qb ) = > inAlbums ( qb , options . albumIds ! ) )
2025-04-18 23:39:56 +02:00
. $if ( ! ! options . tagIds && options . tagIds . length > 0 , ( qb ) = > hasTags ( qb , options . tagIds ! ) )
2025-07-10 16:28:20 +02:00
. $if ( options . tagIds === null , ( qb ) = >
2025-11-04 16:03:21 -05:00
qb . where ( ( eb ) = > eb . not ( eb . exists ( ( eb ) = > eb . selectFrom ( 'tag_asset' ) . whereRef ( 'assetId' , '=' , 'asset.id' ) ) ) ) ,
2025-07-10 16:28:20 +02:00
)
2025-04-18 23:39:56 +02:00
. $if ( ! ! options . personIds && options . personIds . length > 0 , ( qb ) = > hasPeople ( qb , options . personIds ! ) )
2025-07-14 10:13:06 -04:00
. $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 ! ) )
2025-04-18 23:39:56 +02:00
. $if ( options . city !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.city' , options . city === null ? 'is' : '=' , options . city ! ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . state !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.state' , options . state === null ? 'is' : '=' , options . state ! ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . country !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.country' , options . country === null ? 'is' : '=' , options . country ! ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . make !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.make' , options . make === null ? 'is' : '=' , options . make ! ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . model !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.model' , options . model === null ? 'is' : '=' , options . model ! ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . lensModel !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.lensModel' , options . lensModel === null ? 'is' : '=' , options . lensModel ! ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . rating !== undefined , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( 'asset_exif.rating' , options . rating === null ? 'is' : '=' , options . rating ! ) ,
2025-04-18 23:39:56 +02:00
)
2025-07-14 10:13:06 -04:00
. $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 ! ) ) )
2026-03-12 15:15:21 -05:00
. $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 ! ) ,
)
2025-04-18 23:39:56 +02:00
. $if ( ! ! options . originalPath , ( qb ) = >
2025-07-14 10:13:06 -04:00
qb . where ( sql ` f_unaccent(asset."originalPath") ` , 'ilike' , sql ` '%' || f_unaccent( ${ options . originalPath } ) || '%' ` ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( ! ! options . originalFileName , ( qb ) = >
qb . where (
2025-07-14 10:13:06 -04:00
sql ` f_unaccent(asset."originalFileName") ` ,
2025-04-18 23:39:56 +02:00
'ilike' ,
sql ` '%' || f_unaccent( ${ options . originalFileName } ) || '%' ` ,
) ,
)
. $if ( ! ! options . description , ( qb ) = >
qb
2025-07-14 10:13:06 -04:00
. innerJoin ( 'asset_exif' , 'asset.id' , 'asset_exif.assetId' )
. where ( sql ` f_unaccent(asset_exif.description) ` , 'ilike' , sql ` '%' || f_unaccent( ${ options . description } ) || '%' ` ) ,
2025-04-18 23:39:56 +02:00
)
2025-10-27 22:09:55 +08:00
. $if ( ! ! options . ocr , ( qb ) = >
qb
. innerJoin ( 'ocr_search' , 'asset.id' , 'ocr_search.assetId' )
2025-12-01 12:24:37 -05:00
. where ( ( ) = > sql ` f_unaccent(ocr_search.text) %>> f_unaccent( ${ tokenizeForSearch ( options . ocr ! ) . join ( ' ' ) } ) ` ) ,
2025-10-27 22:09:55 +08:00
)
2025-07-14 10:13:06 -04:00
. $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 ! ) )
2025-04-18 23:39:56 +02:00
. $if ( options . isEncoded !== undefined , ( qb ) = >
2026-03-12 15:15:21 -05:00
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 ) ;
} ) ,
2025-04-18 23:39:56 +02:00
)
. $if ( options . isMotion !== undefined , ( qb ) = >
2025-07-14 10:13:06 -04:00
qb . where ( 'asset.livePhotoVideoId' , options . isMotion ? 'is not' : 'is' , null ) ,
2025-04-18 23:39:56 +02:00
)
2025-06-09 11:11:43 -04:00
. $if ( ! ! options . isNotInAlbum && ( ! options . albumIds || options . albumIds . length === 0 ) , ( qb ) = >
2025-11-04 16:03:21 -05:00
qb . where ( ( eb ) = > eb . not ( eb . exists ( ( eb ) = > eb . selectFrom ( 'album_asset' ) . whereRef ( 'assetId' , '=' , 'asset.id' ) ) ) ) ,
2025-04-18 23:39:56 +02:00
)
2026-03-03 06:41:29 -05:00
. $if ( options . withStacked === false , ( qb ) = > qb . where ( 'asset.stackId' , 'is' , null ) )
2025-04-18 23:39:56 +02:00
. $if ( ! ! options . withExif , withExifInner )
2026-01-06 21:54:12 +08:00
. $if ( ! ! ( options . withFaces || options . withPeople ) , ( qb ) = > qb . select ( withFacesAndPeople ) )
2025-07-14 10:13:06 -04:00
. $if ( ! options . withDeleted , ( qb ) = > qb . where ( 'asset.deletedAt' , 'is' , null ) ) ;
2025-04-18 23:39:56 +02:00
}
2025-04-29 19:23:01 +01:00
2026-07-23 00:33:23 +02:00
type AssetExpressionBuilder = ExpressionBuilder < DB , 'asset' | 'asset_exif' > ;
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 < SqlBool > ;
matchesAll : ( ids : string [ ] ) = > Expression < SqlBool > ;
} ,
) {
const predicates : Expression < SqlBool > [ ] = [ ] ;
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 < T > = {
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 < TB extends keyof DB , RE extends ReferenceExpression < DB , TB > > (
eb : ExpressionBuilder < DB , TB > ,
column : RE ,
filter : ComparisonFilter < OperandValueExpression < DB , TB , RE > > = { } ,
) {
const predicates : Expression < SqlBool > [ ] = [ ] ;
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 < SqlBool > ` f_unaccent( ${ ref } ) ilike ('%' || f_unaccent( ${ filter . like } ) || '%') ` ) ;
}
if ( filter . notLike !== undefined ) {
predicates . push ( sql < SqlBool > ` f_unaccent( ${ ref } ) not ilike ('%' || f_unaccent( ${ filter . notLike } ) || '%') ` ) ;
}
if ( filter . startsWith !== undefined ) {
predicates . push ( sql < SqlBool > ` f_unaccent( ${ ref } ) ilike (f_unaccent( ${ filter . startsWith } ) || '%') ` ) ;
}
if ( filter . endsWith !== undefined ) {
predicates . push ( sql < SqlBool > ` 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 < unknown > ,
) : Expression < SqlBool > [ ] {
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 < SqlBool > ` 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 < DB > , 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 < typeof searchAssetBuilder > , 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 } } ] ,
} ,
} ,
] ,
} ,
] ;
2025-05-20 09:36:43 -04:00
export type ReindexVectorIndexOptions = { indexName : string ; lists? : number } ;
2025-04-29 19:23:01 +01:00
2025-05-20 09:36:43 -04:00
type VectorIndexQueryOptions = { table : string ; vectorExtension : VectorExtension } & ReindexVectorIndexOptions ;
export function vectorIndexQuery ( { vectorExtension , table , indexName , lists } : VectorIndexQueryOptions ) : string {
2025-04-29 19:23:01 +01:00
switch ( vectorExtension ) {
2025-07-15 14:50:13 -04:00
case DatabaseExtension . VectorChord : {
2025-05-20 09:36:43 -04:00
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
$ $ ) ` ;
}
2025-07-15 14:50:13 -04:00
case DatabaseExtension . Vector : {
2025-04-29 19:23:01 +01:00
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 } ' ` ) ;
}
}
}
2025-12-17 09:23:13 -06:00
export const updateLockedColumns = < T extends Record < string , unknown > & { lockedProperties ? : LockableProperty [ ] } > (
exif : T ,
) = > {
2026-07-20 23:47:14 -04:00
exif . lockedProperties = lockableProperties . filter ( ( property ) = > Object . hasOwn ( exif , property ) ) ;
2025-12-17 09:23:13 -06:00
return exif ;
} ;