From e49cb7057f44c6b89703e5aafafc8d6cda8d6eff Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:01:47 -0500 Subject: [PATCH] Use `tagId` option fix time bucket stack relation fix library statistics fix empty array updates more fixes early return for `getFacesByIds` remove unused import update types extract tag id fragment to helper method generate sql inline `withStack` --- server/src/entities/asset.entity.ts | 64 ++------ server/src/interfaces/memory.interface.ts | 2 +- server/src/interfaces/person.interface.ts | 8 +- server/src/queries/album.repository.sql | 24 ++- server/src/queries/asset.repository.sql | 103 +++++++------ server/src/queries/library.repository.sql | 13 +- server/src/queries/view.repository.sql | 2 +- server/src/repositories/album.repository.ts | 11 +- server/src/repositories/asset.repository.ts | 137 +++++++++++++++--- server/src/repositories/library.repository.ts | 17 ++- server/src/repositories/memory.repository.ts | 12 +- server/src/repositories/person.repository.ts | 33 +++-- server/src/repositories/trash.repository.ts | 4 + server/src/services/person.service.spec.ts | 4 - server/src/services/person.service.ts | 6 +- 15 files changed, 275 insertions(+), 165 deletions(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 69d4345f4442e..b7d3e7d4ab98d 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,6 +1,6 @@ -import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, Selectable, SelectQueryBuilder, sql } from 'kysely'; +import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { Assets, DB } from 'src/db'; +import { DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; @@ -181,15 +181,13 @@ export class AssetEntity { } export function withExif(qb: SelectQueryBuilder) { - return qb - .leftJoin('exif', 'assets.id', 'exif.assetId') - .select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo')); + return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); } export function withExifInner(qb: SelectQueryBuilder) { return qb .innerJoin('exif', 'assets.id', 'exif.assetId') - .select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo')); + .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); } export function withSmartSearch(qb: SelectQueryBuilder) { @@ -268,48 +266,6 @@ export function withLibrary(eb: ExpressionBuilder) { ); } -export function withStackedAssets(qb: SelectQueryBuilder) { - return qb - .innerJoinLateral( - (eb: ExpressionBuilder) => - eb - .selectFrom('assets as stacked') - .select((eb) => eb.fn[]>('array_agg', [eb.table('stacked')]).as('assets')) - .whereRef('asset_stack.id', '=', 'stacked.stackId') - .whereRef('asset_stack.primaryAssetId', '!=', 'stacked.id') - .as('s'), - (join) => - join.on((eb) => - eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]), - ), - ) - .select('s.assets'); -} - -export function withStack( - qb: SelectQueryBuilder, - { assets, count }: { assets: boolean; count: boolean }, -) { - return qb - .leftJoinLateral( - (eb) => - eb - .selectFrom('asset_stack') - .selectAll('asset_stack') - .whereRef('assets.stackId', '=', 'asset_stack.id') - .$if(assets, withStackedAssets) - .$if(count, (qb) => - // There is no `selectNoFrom` method for expression builders - qb.select( - sql`(select count(*) as "assetCount" where "asset_stack"."id" = "assets"."stackId")`.as('assetCount'), - ), - ) - .as('stacked_assets'), - (join) => join.onTrue(), - ) - .select((eb) => eb.fn('to_jsonb', [eb.table('stacked_assets')]).as('stack')); -} - export function withAlbums(qb: SelectQueryBuilder, { albumId }: { albumId?: string }) { return qb .select((eb) => @@ -352,6 +308,18 @@ export function truncatedDate(size: TimeBucketSize) { return sql`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; } +export function withTagId(qb: SelectQueryBuilder, tagId: string) { + return qb.where((eb) => + eb.exists( + eb + .selectFrom('tags_closure') + .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant') + .whereRef('tag_asset.assetsId', '=', 'assets.id') + .where('tags_closure.id_ancestor', '=', tagId), + ), + ); +} + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ diff --git a/server/src/interfaces/memory.interface.ts b/server/src/interfaces/memory.interface.ts index b1dbcbef8562a..327822adc1286 100644 --- a/server/src/interfaces/memory.interface.ts +++ b/server/src/interfaces/memory.interface.ts @@ -7,7 +7,7 @@ export const IMemoryRepository = 'IMemoryRepository'; export interface IMemoryRepository extends IBulkAsset { search(ownerId: string): Promise; - get(id: string): Promise; + get(id: string): Promise; create( memory: Omit, 'data'> & { data: OnThisDayData }, assetIds: Set, diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index d1404d829a2b6..4719f047eca5c 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,4 +1,4 @@ -import { Insertable, Updateable } from 'kysely'; +import { Insertable, Selectable, Updateable } from 'kysely'; import { AssetFaces, FaceSearch, Person } from 'src/db'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; @@ -49,7 +49,7 @@ export interface DeleteFacesOptions { export type UnassignFacesOptions = DeleteFacesOptions; -export type SelectFaceOptions = Partial<{ [K in keyof AssetFaceEntity]: boolean }>; +export type SelectFaceOptions = (keyof Selectable)[]; export interface IPersonRepository { getAll(options?: Partial): AsyncIterableIterator; @@ -74,10 +74,10 @@ export interface IPersonRepository { id: string, relations?: FindOptionsRelations, select?: SelectFaceOptions, - ): Promise; + ): Promise; getFaces(assetId: string): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; - getRandomFace(personId: string): Promise; + getRandomFace(personId: string): Promise; getStatistics(personId: string): Promise; reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 89c9e3b4a9c88..8f171466338c0 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -82,11 +82,31 @@ select where "shared_links"."albumId" = "albums"."id" ) as agg - ) as "sharedLinks" + ) as "sharedLinks", + ( + select + json_agg("asset") as "assets" + from + ( + select + "assets".*, + to_json("exif") as "exifInfo" + from + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" + inner join "albums_assets_assets" on "albums_assets_assets"."assetsId" = "assets"."id" + where + "albums_assets_assets"."albumsId" = "albums"."id" + and "assets"."deletedAt" is null + and "assets"."isArchived" = $1 + order by + "assets"."fileCreatedAt" desc + ) as "asset" + ) as "assets" from "albums" where - "albums"."id" = $1 + "albums"."id" = $2 and "albums"."deletedAt" is null -- AlbumRepository.getByAssetId diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index d50069f0a9298..d540b90f18fb1 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -23,7 +23,7 @@ with ) select "a".*, - to_jsonb("exif") as "exifInfo" + to_json("exif") as "exifInfo" from "today" inner join lateral ( @@ -56,7 +56,7 @@ select ( (now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date ) / 365 as "yearsAgo", - jsonb_agg("res") as "assets" + json_agg("res") as "assets" from "res" group by @@ -109,34 +109,28 @@ select "assets"."id" = "tag_asset"."assetsId" ) as agg ) as "tags", - to_jsonb("exif") as "exifInfo", - to_jsonb("stacked_assets") as "stack" + to_json("exif") as "exifInfo", + to_json("stacked_assets") as "stack" from "assets" left join "exif" on "assets"."id" = "exif"."assetId" + left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" left join lateral ( select "asset_stack".*, - "s"."assets" + array_agg("stacked") as "assets" from - "asset_stack" - inner join lateral ( - select - array_agg("stacked") as "assets" - from - "assets" as "stacked" - where - "asset_stack"."id" = "stacked"."stackId" - and "asset_stack"."primaryAssetId" != "stacked"."id" - ) as "s" on ( - "asset_stack"."primaryAssetId" = "assets"."id" - or "assets"."stackId" is null - ) + "assets" as "stacked" where - "assets"."stackId" = "asset_stack"."id" - ) as "stacked_assets" on true + "stacked"."stackId" = "asset_stack"."id" + and "stacked"."id" != "asset_stack"."primaryAssetId" + and "stacked"."deletedAt" is null + and "stacked"."isArchived" = $1 + group by + "asset_stack"."id" + ) as "stacked_assets" on "asset_stack"."id" is not null where - "assets"."id" = any ($1::uuid []) + "assets"."id" = any ($2::uuid []) -- AssetRepository.deleteAll delete from "assets" @@ -278,14 +272,33 @@ order by -- AssetRepository.getTimeBucket select "assets".*, - to_jsonb("exif") as "exifInfo" + to_json("exif") as "exifInfo", + to_json("stacked_assets") as "stack" from "assets" left join "exif" on "assets"."id" = "exif"."assetId" + left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" + left join lateral ( + select + "asset_stack".*, + count("stacked") as "assetCount" + from + "assets" as "stacked" + where + "stacked"."stackId" = "asset_stack"."id" + and "stacked"."deletedAt" is null + and "stacked"."isArchived" = $1 + group by + "asset_stack"."id" + ) as "stacked_assets" on "asset_stack"."id" is not null where - "assets"."deletedAt" is null - and "assets"."isVisible" = $1 - and date_trunc($2, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $3 + ( + "asset_stack"."primaryAssetId" = "assets"."id" + or "assets"."stackId" is null + ) + and "assets"."deletedAt" is null + and "assets"."isVisible" = $2 + and date_trunc($3, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4 order by "assets"."localDateTime" desc @@ -368,25 +381,23 @@ limit -- AssetRepository.getAllForUserFullSync select "assets".*, - to_jsonb("exif") as "exifInfo", - to_jsonb("stacked_assets") as "stack" + to_json("exif") as "exifInfo", + to_json("stacked_assets") as "stack" from "assets" left join "exif" on "assets"."id" = "exif"."assetId" + left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" left join lateral ( select "asset_stack".*, - ( - select - count(*) as "assetCount" - where - "asset_stack"."id" = "assets"."stackId" - ) as "assetCount" + count("stacked") as "assetCount" from - "asset_stack" + "assets" as "stacked" where - "assets"."stackId" = "asset_stack"."id" - ) as "stacked_assets" on true + "stacked"."stackId" = "asset_stack"."id" + group by + "asset_stack"."id" + ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = $1::uuid and "isVisible" = $2 @@ -400,25 +411,23 @@ limit -- AssetRepository.getChangedDeltaSync select "assets".*, - to_jsonb("exif") as "exifInfo", - to_jsonb("stacked_assets") as "stack" + to_json("exif") as "exifInfo", + to_json("stacked_assets") as "stack" from "assets" left join "exif" on "assets"."id" = "exif"."assetId" + left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" left join lateral ( select "asset_stack".*, - ( - select - count(*) as "assetCount" - where - "asset_stack"."id" = "assets"."stackId" - ) as "assetCount" + count("stacked") as "assetCount" from - "asset_stack" + "assets" as "stacked" where - "assets"."stackId" = "asset_stack"."id" - ) as "stacked_assets" on true + "stacked"."stackId" = "asset_stack"."id" + group by + "asset_stack"."id" + ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = any ($1::uuid []) and "isVisible" = $2 diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 347990f04c316..b0b20fd8a2458 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -112,7 +112,7 @@ order by -- LibraryRepository.getStatistics select - count("assets"."id") filter ( + count(*) filter ( where ( "assets"."type" = $1 @@ -130,8 +130,17 @@ select from "libraries" inner join "assets" on "assets"."libraryId" = "libraries"."id" - inner join "exif" on "exif"."assetId" = "assets"."id" + left join "exif" on "exif"."assetId" = "assets"."id" where "libraries"."id" = $6 group by "libraries"."id" +select + 0::int as "photos", + 0::int as "videos", + 0::int as "usage", + 0::int as "total" +from + "libraries" +where + "libraries"."id" = $1 diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index 948f60fd4d6ec..b368684caeb80 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -14,7 +14,7 @@ where -- ViewRepository.getAssetsByOriginalPath select "assets".*, - to_jsonb("exif") as "exifInfo" + to_json("exif") as "exifInfo" from "assets" left join "exif" on "assets"."id" = "exif"."assetId" diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index bae91349f5ddd..e32a53e82d2cd 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; @@ -8,7 +7,6 @@ import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/ import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; -import { Repository } from 'typeorm'; const userColumns = [ 'id', @@ -64,6 +62,8 @@ const withAssets = (eb: ExpressionBuilder) => { .select((eb) => eb.fn.toJson('exif').as('exifInfo')) .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id') + .where('assets.deletedAt', 'is', null) + .where('assets.isArchived', '=', false) .orderBy('assets.fileCreatedAt', 'desc') .as('asset'), ) @@ -73,12 +73,9 @@ const withAssets = (eb: ExpressionBuilder) => { @Injectable() export class AlbumRepository implements IAlbumRepository { - constructor( - @InjectRepository(AlbumEntity) private repository: Repository, - @InjectKysely() private db: Kysely, - ) {} + constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID, {}] }) + @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] }) async getById(id: string, options: AlbumInfoOptions): Promise { return this.db .selectFrom('albums') diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ed1ef73a084c5..a53b1dbd92aa5 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -19,7 +19,7 @@ import { withLibrary, withOwner, withSmartSearch, - withStack, + withTagId, withTags, } from 'src/entities/asset.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; @@ -122,13 +122,13 @@ export class AssetRepository implements IAssetRepository { ), ) .where('assets.deletedAt', 'is', null) - .limit(10) + .limit(20) .as('a'), (join) => join.onTrue(), ) .innerJoin('exif', 'a.id', 'exif.assetId') .selectAll('a') - .select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo')), + .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')), ) .selectFrom('res') .select( @@ -136,7 +136,7 @@ export class AssetRepository implements IAssetRepository { 'yearsAgo', ), ) - .select((eb) => eb.fn('jsonb_agg', [eb.table('res')]).as('assets')) + .select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets')) .groupBy(sql`("localDateTime" at time zone 'UTC')::date`) .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .limit(10) @@ -159,7 +159,29 @@ export class AssetRepository implements IAssetRepository { .$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!smartSearch, withSmartSearch) - .$if(!!stack, (qb) => withStack(qb, { assets: !!stack!.assets, count: false })) + .$if(!!stack, (qb) => + qb + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .$if(!stack!.assets, (qb) => qb.select((eb) => eb.fn.toJson(eb.table('asset_stack')).as('stack'))) + .$if(!!stack!.assets, (qb) => + qb + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.isArchived', '=', false) + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), + ), + ) .$if(!!tags, (qb) => qb.select(withTags)) .execute(); @@ -175,7 +197,22 @@ export class AssetRepository implements IAssetRepository { .select(withFacesAndPeople) .select(withTags) .$call(withExif) - .$call((qb) => withStack(qb, { assets: true, count: false })) + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.isArchived', '=', false) + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.id', '=', anyUuid(ids)) .execute() as any as Promise; } @@ -287,19 +324,25 @@ export class AssetRepository implements IAssetRepository { .$if(!!stack, (qb) => qb .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') - .leftJoinLateral( - (eb) => - eb - .selectFrom('assets as stacked') - .selectAll('asset_stack') - .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) - .where('stacked.deletedAt', 'is', null) - .whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId') - .whereRef('stacked.stackId', '=', 'asset_stack.id') - .as('stacked_assets'), - (join) => join.on('asset_stack.id', 'is not', null), - ) - .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), + .$if(!stack!.assets, (qb) => qb.select((eb) => eb.fn.toJson(eb.table('asset_stack')).as('stack'))) + .$if(!!stack!.assets, (qb) => + qb + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.isArchived', '=', false) + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), + ), ) .$if(!!files, (qb) => qb.select(withFiles)) .$if(!!tags, (qb) => qb.select(withTags)) @@ -567,7 +610,8 @@ export class AssetRepository implements IAssetRepository { .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .$if(!!options.isDuplicate, (qb) => qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), - ), + ) + .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), ) .selectFrom('assets') .select('timeBucket') @@ -583,7 +627,7 @@ export class AssetRepository implements IAssetRepository { ); } - @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH }] }) + @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { return hasPeople(this.db, options.personId ? [options.personId] : undefined) .selectAll('assets') @@ -592,12 +636,33 @@ export class AssetRepository implements IAssetRepository { .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) - .$if(!!options.withStacked, (qb) => withStack(qb, { assets: true, count: false })) // TODO: optimize this; it's a huge performance hit + .$if(!!options.withStacked, (qb) => + qb + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .where((eb) => + eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]), + ) + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .where('stacked.deletedAt', 'is', null) + .where('stacked.isArchived', '=', false) + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), + ) .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) => qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), ) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) + .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.isVisible', '=', true) .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, '')) @@ -689,7 +754,19 @@ export class AssetRepository implements IAssetRepository { .selectFrom('assets') .selectAll('assets') .$call(withExif) - .$call((qb) => withStack(qb, { assets: false, count: true })) + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.ownerId', '=', asUuid(ownerId)) .where('isVisible', '=', true) .where('updatedAt', '<=', updatedUntil) @@ -705,7 +782,19 @@ export class AssetRepository implements IAssetRepository { .selectFrom('assets') .selectAll('assets') .$call(withExif) - .$call((qb) => withStack(qb, { assets: false, count: true })) + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.ownerId', '=', anyUuid(options.userIds)) .where('isVisible', '=', true) .where('updatedAt', '>', options.updatedAfter) diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index ca279d7dea3fe..0e1ec94c32f86 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Libraries } from 'src/db'; @@ -100,10 +100,10 @@ export class LibraryRepository implements ILibraryRepository { const stats = await this.db .selectFrom('libraries') .innerJoin('assets', 'assets.libraryId', 'libraries.id') - .innerJoin('exif', 'exif.assetId', 'assets.id') + .leftJoin('exif', 'exif.assetId', 'assets.id') .select((eb) => eb.fn - .count('assets.id') + .countAll() .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .as('photos'), ) @@ -118,8 +118,17 @@ export class LibraryRepository implements ILibraryRepository { .where('libraries.id', '=', id) .executeTakeFirst(); + // possibly a new library with 0 assets if (!stats) { - return; + const zero = sql`0::int`; + return this.db + .selectFrom('libraries') + .select(zero.as('photos')) + .select(zero.as('videos')) + .select(zero.as('usage')) + .select(zero.as('total')) + .where('libraries.id', '=', id) + .executeTakeFirst(); } return { diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 7e59b92e68b55..3a32e750b7bf3 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -22,8 +22,8 @@ export class MemoryRepository implements IMemoryRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - get(id: string): Promise { - return this.getByIdBuilder(id).executeTakeFirst() as unknown as Promise; + get(id: string): Promise { + return this.getByIdBuilder(id).executeTakeFirst() as unknown as Promise; } async create(memory: Insertable, assetIds: Set): Promise { @@ -71,6 +71,10 @@ export class MemoryRepository implements IMemoryRepository { @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) async addAssetIds(id: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; + } + await this.db .insertInto('memories_assets_assets') .values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId }))) @@ -80,6 +84,10 @@ export class MemoryRepository implements IMemoryRepository { @Chunked({ paramIndex: 1 }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) async removeAssetIds(id: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; + } + await this.db .deleteFrom('memories_assets_assets') .where('memoriesId', '=', id) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index c810b0def21e4..fdcecd9d0af93 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, SelectExpression, sql } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, sql } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; -import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; @@ -212,22 +211,16 @@ export class PersonRepository implements IPersonRepository { id: string, relations?: FindOptionsRelations, select?: SelectFaceOptions, - ): Promise { - return (this.db + ): Promise { + return this.db .selectFrom('asset_faces') - .$if(!!select, (qb) => - qb.select( - Object.keys( - _.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined), - ) as SelectExpression[], - ), - ) + .$if(!!select, (qb) => qb.select(select!)) .$if(!select, (qb) => qb.selectAll('asset_faces')) .select(withPerson) .select(withAsset) .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) .where('asset_faces.id', '=', id) - .executeTakeFirst() ?? null) as Promise; + .executeTakeFirst() as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @@ -335,6 +328,10 @@ export class PersonRepository implements IPersonRepository { } async createAll(people: Insertable[]): Promise { + if (people.length === 0) { + return []; + } + const results = await this.db.insertInto('person').values(people).returningAll().execute(); return results.map(({ id }) => id); } @@ -387,8 +384,12 @@ export class PersonRepository implements IPersonRepository { @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @ChunkedArray() getFacesByIds(ids: AssetFaceId[]): Promise { - const { assetIds, personIds }: { assetIds: string[]; personIds: string[] } = { assetIds: [], personIds: [] }; + if (ids.length === 0) { + return Promise.resolve([]); + } + const assetIds: string[] = []; + const personIds: string[] = []; for (const { assetId, personId } of ids) { assetIds.push(assetId); personIds.push(personId); @@ -405,12 +406,12 @@ export class PersonRepository implements IPersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getRandomFace(personId: string): Promise { - return (this.db + getRandomFace(personId: string): Promise { + return this.db .selectFrom('asset_faces') .selectAll('asset_faces') .where('asset_faces.personId', '=', personId) - .executeTakeFirst() ?? null) as Promise; + .executeTakeFirst() as Promise; } @GenerateSql() diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts index c1db31a3db38d..06e75a8d2e036 100644 --- a/server/src/repositories/trash.repository.ts +++ b/server/src/repositories/trash.repository.ts @@ -38,6 +38,10 @@ export class TrashRepository implements ITrashRepository { @GenerateSql({ params: [[DummyValue.UUID]] }) async restoreAll(ids: string[]): Promise { + if (ids.length === 0) { + return 0; + } + const { numUpdatedRows } = await this.db .updateTable('assets') .where('status', '=', AssetStatus.TRASHED) diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index b18eb7dfd86bd..3f454f178fd0e 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -368,7 +368,6 @@ describe(PersonService.name, () => { personMock.getFaceById.mockResolvedValue(faceStub.face1); personMock.reassignFace.mockResolvedValue(1); personMock.getById.mockResolvedValue(personStub.noName); - personMock.getRandomFace.mockResolvedValue(null); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, @@ -391,7 +390,6 @@ describe(PersonService.name, () => { personMock.getFaceById.mockResolvedValue(faceStub.face1); personMock.reassignFace.mockResolvedValue(1); personMock.getById.mockResolvedValue(personStub.noName); - personMock.getRandomFace.mockResolvedValue(null); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, @@ -771,8 +769,6 @@ describe(PersonService.name, () => { describe('handleRecognizeFaces', () => { it('should fail if face does not exist', async () => { - personMock.getFaceByIdWithAssets.mockResolvedValue(null); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); expect(personMock.reassignFaces).not.toHaveBeenCalled(); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 45732c4e7ce46..b1a7ec75a25f1 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -145,7 +145,7 @@ export class PersonService extends BaseService { for (const personId of changeFeaturePhoto) { const assetFace = await this.personRepository.getRandomFace(personId); - if (assetFace !== null) { + if (assetFace) { await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); } @@ -444,7 +444,7 @@ export class PersonService extends BaseService { const face = await this.personRepository.getFaceByIdWithAssets( id, { person: true, asset: true, faceSearch: true }, - { id: true, personId: true, sourceType: true, faceSearch: true }, + ['id', 'personId', 'sourceType'], ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); @@ -544,7 +544,7 @@ export class PersonService extends BaseService { } const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); - if (face === null) { + if (!face) { this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`); return JobStatus.FAILED; }