1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-31 21:29:38 +02:00

fix(server): query fixes ()

This commit is contained in:
Mert 2025-01-22 15:17:42 -05:00 committed by GitHub
parent 7b882b35e5
commit 49a6961ec6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 275 additions and 165 deletions

View file

@ -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 { 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 { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
@ -181,15 +181,13 @@ export class AssetEntity {
} }
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo'));
.leftJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo'));
} }
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb return qb
.innerJoin('exif', 'assets.id', 'exif.assetId') .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<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
@ -268,48 +266,6 @@ export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) {
); );
} }
export function withStackedAssets<O>(qb: SelectQueryBuilder<DB, 'assets' | 'asset_stack', O>) {
return qb
.innerJoinLateral(
(eb: ExpressionBuilder<DB, 'assets' | 'asset_stack'>) =>
eb
.selectFrom('assets as stacked')
.select((eb) => eb.fn<Selectable<Assets>[]>('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<O>(
qb: SelectQueryBuilder<DB, 'assets', O>,
{ 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<O>(qb: SelectQueryBuilder<DB, 'assets', O>, { albumId }: { albumId?: string }) { export function withAlbums<O>(qb: SelectQueryBuilder<DB, 'assets', O>, { albumId }: { albumId?: string }) {
return qb return qb
.select((eb) => .select((eb) =>
@ -352,6 +308,18 @@ export function truncatedDate<O>(size: TimeBucketSize) {
return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
} }
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, 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(); const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */ /** TODO: This should only be used for search-related queries, not as a general purpose query builder */

View file

@ -7,7 +7,7 @@ export const IMemoryRepository = 'IMemoryRepository';
export interface IMemoryRepository extends IBulkAsset { export interface IMemoryRepository extends IBulkAsset {
search(ownerId: string): Promise<MemoryEntity[]>; search(ownerId: string): Promise<MemoryEntity[]>;
get(id: string): Promise<MemoryEntity | null>; get(id: string): Promise<MemoryEntity | undefined>;
create( create(
memory: Omit<Insertable<Memories>, 'data'> & { data: OnThisDayData }, memory: Omit<Insertable<Memories>, 'data'> & { data: OnThisDayData },
assetIds: Set<string>, assetIds: Set<string>,

View file

@ -1,4 +1,4 @@
import { Insertable, Updateable } from 'kysely'; import { Insertable, Selectable, Updateable } from 'kysely';
import { AssetFaces, FaceSearch, Person } from 'src/db'; import { AssetFaces, FaceSearch, Person } from 'src/db';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
@ -49,7 +49,7 @@ export interface DeleteFacesOptions {
export type UnassignFacesOptions = DeleteFacesOptions; export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = Partial<{ [K in keyof AssetFaceEntity]: boolean }>; export type SelectFaceOptions = (keyof Selectable<AssetFaces>)[];
export interface IPersonRepository { export interface IPersonRepository {
getAll(options?: Partial<PersonEntity>): AsyncIterableIterator<PersonEntity>; getAll(options?: Partial<PersonEntity>): AsyncIterableIterator<PersonEntity>;
@ -74,10 +74,10 @@ export interface IPersonRepository {
id: string, id: string,
relations?: FindOptionsRelations<AssetFaceEntity>, relations?: FindOptionsRelations<AssetFaceEntity>,
select?: SelectFaceOptions, select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null>; ): Promise<AssetFaceEntity | undefined>;
getFaces(assetId: string): Promise<AssetFaceEntity[]>; getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>; getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>; getRandomFace(personId: string): Promise<AssetFaceEntity | undefined>;
getStatistics(personId: string): Promise<PersonStatistics>; getStatistics(personId: string): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>; reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>; getNumberOfPeople(userId: string): Promise<PeopleStatistics>;

View file

@ -82,11 +82,31 @@ select
where where
"shared_links"."albumId" = "albums"."id" "shared_links"."albumId" = "albums"."id"
) as agg ) 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 from
"albums" "albums"
where where
"albums"."id" = $1 "albums"."id" = $2
and "albums"."deletedAt" is null and "albums"."deletedAt" is null
-- AlbumRepository.getByAssetId -- AlbumRepository.getByAssetId

View file

@ -23,7 +23,7 @@ with
) )
select select
"a".*, "a".*,
to_jsonb("exif") as "exifInfo" to_json("exif") as "exifInfo"
from from
"today" "today"
inner join lateral ( inner join lateral (
@ -56,7 +56,7 @@ select
( (
(now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date (now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date
) / 365 as "yearsAgo", ) / 365 as "yearsAgo",
jsonb_agg("res") as "assets" json_agg("res") as "assets"
from from
"res" "res"
group by group by
@ -109,34 +109,28 @@ select
"assets"."id" = "tag_asset"."assetsId" "assets"."id" = "tag_asset"."assetsId"
) as agg ) as agg
) as "tags", ) as "tags",
to_jsonb("exif") as "exifInfo", to_json("exif") as "exifInfo",
to_jsonb("stacked_assets") as "stack" to_json("stacked_assets") as "stack"
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral ( left join lateral (
select select
"asset_stack".*, "asset_stack".*,
"s"."assets"
from
"asset_stack"
inner join lateral (
select
array_agg("stacked") as "assets" array_agg("stacked") as "assets"
from from
"assets" as "stacked" "assets" as "stacked"
where where
"asset_stack"."id" = "stacked"."stackId" "stacked"."stackId" = "asset_stack"."id"
and "asset_stack"."primaryAssetId" != "stacked"."id" and "stacked"."id" != "asset_stack"."primaryAssetId"
) as "s" on ( and "stacked"."deletedAt" is null
"asset_stack"."primaryAssetId" = "assets"."id" and "stacked"."isArchived" = $1
or "assets"."stackId" is null group by
) "asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where where
"assets"."stackId" = "asset_stack"."id" "assets"."id" = any ($2::uuid [])
) as "stacked_assets" on true
where
"assets"."id" = any ($1::uuid [])
-- AssetRepository.deleteAll -- AssetRepository.deleteAll
delete from "assets" delete from "assets"
@ -278,14 +272,33 @@ order by
-- AssetRepository.getTimeBucket -- AssetRepository.getTimeBucket
select select
"assets".*, "assets".*,
to_jsonb("exif") as "exifInfo" to_json("exif") as "exifInfo",
to_json("stacked_assets") as "stack"
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" 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 where
"assets"."deletedAt" is null "stacked"."stackId" = "asset_stack"."id"
and "assets"."isVisible" = $1 and "stacked"."deletedAt" is null
and date_trunc($2, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $3 and "stacked"."isArchived" = $1
group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where
(
"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 order by
"assets"."localDateTime" desc "assets"."localDateTime" desc
@ -368,25 +381,23 @@ limit
-- AssetRepository.getAllForUserFullSync -- AssetRepository.getAllForUserFullSync
select select
"assets".*, "assets".*,
to_jsonb("exif") as "exifInfo", to_json("exif") as "exifInfo",
to_jsonb("stacked_assets") as "stack" to_json("stacked_assets") as "stack"
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral ( left join lateral (
select select
"asset_stack".*, "asset_stack".*,
( count("stacked") as "assetCount"
select
count(*) as "assetCount"
where
"asset_stack"."id" = "assets"."stackId"
) as "assetCount"
from from
"asset_stack" "assets" as "stacked"
where where
"assets"."stackId" = "asset_stack"."id" "stacked"."stackId" = "asset_stack"."id"
) as "stacked_assets" on true group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where where
"assets"."ownerId" = $1::uuid "assets"."ownerId" = $1::uuid
and "isVisible" = $2 and "isVisible" = $2
@ -400,25 +411,23 @@ limit
-- AssetRepository.getChangedDeltaSync -- AssetRepository.getChangedDeltaSync
select select
"assets".*, "assets".*,
to_jsonb("exif") as "exifInfo", to_json("exif") as "exifInfo",
to_jsonb("stacked_assets") as "stack" to_json("stacked_assets") as "stack"
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral ( left join lateral (
select select
"asset_stack".*, "asset_stack".*,
( count("stacked") as "assetCount"
select
count(*) as "assetCount"
where
"asset_stack"."id" = "assets"."stackId"
) as "assetCount"
from from
"asset_stack" "assets" as "stacked"
where where
"assets"."stackId" = "asset_stack"."id" "stacked"."stackId" = "asset_stack"."id"
) as "stacked_assets" on true group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where where
"assets"."ownerId" = any ($1::uuid []) "assets"."ownerId" = any ($1::uuid [])
and "isVisible" = $2 and "isVisible" = $2

View file

@ -112,7 +112,7 @@ order by
-- LibraryRepository.getStatistics -- LibraryRepository.getStatistics
select select
count("assets"."id") filter ( count(*) filter (
where where
( (
"assets"."type" = $1 "assets"."type" = $1
@ -130,8 +130,17 @@ select
from from
"libraries" "libraries"
inner join "assets" on "assets"."libraryId" = "libraries"."id" 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 where
"libraries"."id" = $6 "libraries"."id" = $6
group by group by
"libraries"."id" "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

View file

@ -14,7 +14,7 @@ where
-- ViewRepository.getAssetsByOriginalPath -- ViewRepository.getAssetsByOriginalPath
select select
"assets".*, "assets".*,
to_jsonb("exif") as "exifInfo" to_json("exif") as "exifInfo"
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"

View file

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; 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 { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { Repository } from 'typeorm';
const userColumns = [ const userColumns = [
'id', 'id',
@ -64,6 +62,8 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.select((eb) => eb.fn.toJson('exif').as('exifInfo')) .select((eb) => eb.fn.toJson('exif').as('exifInfo'))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null)
.where('assets.isArchived', '=', false)
.orderBy('assets.fileCreatedAt', 'desc') .orderBy('assets.fileCreatedAt', 'desc')
.as('asset'), .as('asset'),
) )
@ -73,12 +73,9 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
@Injectable() @Injectable()
export class AlbumRepository implements IAlbumRepository { export class AlbumRepository implements IAlbumRepository {
constructor( constructor(@InjectKysely() private db: Kysely<DB>) {}
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
@InjectKysely() private db: Kysely<DB>,
) {}
@GenerateSql({ params: [DummyValue.UUID, {}] }) @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined> { async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined> {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')

View file

@ -19,7 +19,7 @@ import {
withLibrary, withLibrary,
withOwner, withOwner,
withSmartSearch, withSmartSearch,
withStack, withTagId,
withTags, withTags,
} from 'src/entities/asset.entity'; } from 'src/entities/asset.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
@ -122,13 +122,13 @@ export class AssetRepository implements IAssetRepository {
), ),
) )
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.limit(10) .limit(20)
.as('a'), .as('a'),
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.innerJoin('exif', 'a.id', 'exif.assetId') .innerJoin('exif', 'a.id', 'exif.assetId')
.selectAll('a') .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') .selectFrom('res')
.select( .select(
@ -136,7 +136,7 @@ export class AssetRepository implements IAssetRepository {
'yearsAgo', '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`) .groupBy(sql`("localDateTime" at time zone 'UTC')::date`)
.orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(10) .limit(10)
@ -159,7 +159,29 @@ export class AssetRepository implements IAssetRepository {
.$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch) .$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)) .$if(!!tags, (qb) => qb.select(withTags))
.execute(); .execute();
@ -175,7 +197,22 @@ export class AssetRepository implements IAssetRepository {
.select(withFacesAndPeople) .select(withFacesAndPeople)
.select(withTags) .select(withTags)
.$call(withExif) .$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)) .where('assets.id', '=', anyUuid(ids))
.execute() as any as Promise<AssetEntity[]>; .execute() as any as Promise<AssetEntity[]>;
} }
@ -287,19 +324,25 @@ export class AssetRepository implements IAssetRepository {
.$if(!!stack, (qb) => .$if(!!stack, (qb) =>
qb qb
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') .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( .leftJoinLateral(
(eb) => (eb) =>
eb eb
.selectFrom('assets as stacked') .selectFrom('assets as stacked')
.selectAll('asset_stack') .selectAll('asset_stack')
.select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) .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') .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'), .as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null), (join) => join.on('asset_stack.id', 'is not', null),
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')),
),
) )
.$if(!!files, (qb) => qb.select(withFiles)) .$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags)) .$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.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(!!options.isDuplicate, (qb) => .$if(!!options.isDuplicate, (qb) =>
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
), )
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
) )
.selectFrom('assets') .selectFrom('assets')
.select('timeBucket') .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<AssetEntity[]> { async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
return hasPeople(this.db, options.personId ? [options.personId] : undefined) return hasPeople(this.db, options.personId ? [options.personId] : undefined)
.selectAll('assets') .selectAll('assets')
@ -592,12 +636,33 @@ export class AssetRepository implements IAssetRepository {
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$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.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) => .$if(options.isDuplicate !== undefined, (qb) =>
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
) )
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$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.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, '')) .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
@ -689,7 +754,19 @@ export class AssetRepository implements IAssetRepository {
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.$call(withExif) .$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('assets.ownerId', '=', asUuid(ownerId))
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('updatedAt', '<=', updatedUntil) .where('updatedAt', '<=', updatedUntil)
@ -705,7 +782,19 @@ export class AssetRepository implements IAssetRepository {
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.$call(withExif) .$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('assets.ownerId', '=', anyUuid(options.userIds))
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('updatedAt', '>', options.updatedAfter) .where('updatedAt', '>', options.updatedAfter)

View file

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; 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 { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB, Libraries } from 'src/db'; import { DB, Libraries } from 'src/db';
@ -100,10 +100,10 @@ export class LibraryRepository implements ILibraryRepository {
const stats = await this.db const stats = await this.db
.selectFrom('libraries') .selectFrom('libraries')
.innerJoin('assets', 'assets.libraryId', 'libraries.id') .innerJoin('assets', 'assets.libraryId', 'libraries.id')
.innerJoin('exif', 'exif.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id')
.select((eb) => .select((eb) =>
eb.fn eb.fn
.count('assets.id') .countAll()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
.as('photos'), .as('photos'),
) )
@ -118,8 +118,17 @@ export class LibraryRepository implements ILibraryRepository {
.where('libraries.id', '=', id) .where('libraries.id', '=', id)
.executeTakeFirst(); .executeTakeFirst();
// possibly a new library with 0 assets
if (!stats) { if (!stats) {
return; const zero = sql<number>`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 { return {

View file

@ -22,8 +22,8 @@ export class MemoryRepository implements IMemoryRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
get(id: string): Promise<MemoryEntity | null> { get(id: string): Promise<MemoryEntity | undefined> {
return this.getByIdBuilder(id).executeTakeFirst() as unknown as Promise<MemoryEntity | null>; return this.getByIdBuilder(id).executeTakeFirst() as unknown as Promise<MemoryEntity | undefined>;
} }
async create(memory: Insertable<Memories>, assetIds: Set<string>): Promise<MemoryEntity> { async create(memory: Insertable<Memories>, assetIds: Set<string>): Promise<MemoryEntity> {
@ -71,6 +71,10 @@ export class MemoryRepository implements IMemoryRepository {
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(id: string, assetIds: string[]): Promise<void> { async addAssetIds(id: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await this.db await this.db
.insertInto('memories_assets_assets') .insertInto('memories_assets_assets')
.values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId }))) .values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId })))
@ -80,6 +84,10 @@ export class MemoryRepository implements IMemoryRepository {
@Chunked({ paramIndex: 1 }) @Chunked({ paramIndex: 1 })
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async removeAssetIds(id: string, assetIds: string[]): Promise<void> { async removeAssetIds(id: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await this.db await this.db
.deleteFrom('memories_assets_assets') .deleteFrom('memories_assets_assets')
.where('memoriesId', '=', id) .where('memoriesId', '=', id)

View file

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; 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 { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
@ -212,22 +211,16 @@ export class PersonRepository implements IPersonRepository {
id: string, id: string,
relations?: FindOptionsRelations<AssetFaceEntity>, relations?: FindOptionsRelations<AssetFaceEntity>,
select?: SelectFaceOptions, select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null> { ): Promise<AssetFaceEntity | undefined> {
return (this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
.$if(!!select, (qb) => .$if(!!select, (qb) => qb.select(select!))
qb.select(
Object.keys(
_.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined),
) as SelectExpression<DB, 'asset_faces'>[],
),
)
.$if(!select, (qb) => qb.selectAll('asset_faces')) .$if(!select, (qb) => qb.selectAll('asset_faces'))
.select(withPerson) .select(withPerson)
.select(withAsset) .select(withAsset)
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
.where('asset_faces.id', '=', id) .where('asset_faces.id', '=', id)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>; .executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
@ -335,6 +328,10 @@ export class PersonRepository implements IPersonRepository {
} }
async createAll(people: Insertable<Person>[]): Promise<string[]> { async createAll(people: Insertable<Person>[]): Promise<string[]> {
if (people.length === 0) {
return [];
}
const results = await this.db.insertInto('person').values(people).returningAll().execute(); const results = await this.db.insertInto('person').values(people).returningAll().execute();
return results.map(({ id }) => id); return results.map(({ id }) => id);
} }
@ -387,8 +384,12 @@ export class PersonRepository implements IPersonRepository {
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ChunkedArray() @ChunkedArray()
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> { getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
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) { for (const { assetId, personId } of ids) {
assetIds.push(assetId); assetIds.push(assetId);
personIds.push(personId); personIds.push(personId);
@ -405,12 +406,12 @@ export class PersonRepository implements IPersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personId: string): Promise<AssetFaceEntity | null> { getRandomFace(personId: string): Promise<AssetFaceEntity | undefined> {
return (this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
.selectAll('asset_faces') .selectAll('asset_faces')
.where('asset_faces.personId', '=', personId) .where('asset_faces.personId', '=', personId)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>; .executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
} }
@GenerateSql() @GenerateSql()

View file

@ -38,6 +38,10 @@ export class TrashRepository implements ITrashRepository {
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
async restoreAll(ids: string[]): Promise<number> { async restoreAll(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const { numUpdatedRows } = await this.db const { numUpdatedRows } = await this.db
.updateTable('assets') .updateTable('assets')
.where('status', '=', AssetStatus.TRASHED) .where('status', '=', AssetStatus.TRASHED)

View file

@ -368,7 +368,6 @@ describe(PersonService.name, () => {
personMock.getFaceById.mockResolvedValue(faceStub.face1); personMock.getFaceById.mockResolvedValue(faceStub.face1);
personMock.reassignFace.mockResolvedValue(1); personMock.reassignFace.mockResolvedValue(1);
personMock.getById.mockResolvedValue(personStub.noName); personMock.getById.mockResolvedValue(personStub.noName);
personMock.getRandomFace.mockResolvedValue(null);
await expect( await expect(
sut.reassignFacesById(authStub.admin, personStub.noName.id, { sut.reassignFacesById(authStub.admin, personStub.noName.id, {
id: faceStub.face1.id, id: faceStub.face1.id,
@ -391,7 +390,6 @@ describe(PersonService.name, () => {
personMock.getFaceById.mockResolvedValue(faceStub.face1); personMock.getFaceById.mockResolvedValue(faceStub.face1);
personMock.reassignFace.mockResolvedValue(1); personMock.reassignFace.mockResolvedValue(1);
personMock.getById.mockResolvedValue(personStub.noName); personMock.getById.mockResolvedValue(personStub.noName);
personMock.getRandomFace.mockResolvedValue(null);
await expect( await expect(
sut.reassignFacesById(authStub.admin, personStub.noName.id, { sut.reassignFacesById(authStub.admin, personStub.noName.id, {
id: faceStub.face1.id, id: faceStub.face1.id,
@ -771,8 +769,6 @@ describe(PersonService.name, () => {
describe('handleRecognizeFaces', () => { describe('handleRecognizeFaces', () => {
it('should fail if face does not exist', async () => { 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(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED);
expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled();

View file

@ -145,7 +145,7 @@ export class PersonService extends BaseService {
for (const personId of changeFeaturePhoto) { for (const personId of changeFeaturePhoto) {
const assetFace = await this.personRepository.getRandomFace(personId); const assetFace = await this.personRepository.getRandomFace(personId);
if (assetFace !== null) { if (assetFace) {
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); 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( const face = await this.personRepository.getFaceByIdWithAssets(
id, id,
{ person: true, asset: true, faceSearch: true }, { person: true, asset: true, faceSearch: true },
{ id: true, personId: true, sourceType: true, faceSearch: true }, ['id', 'personId', 'sourceType'],
); );
if (!face || !face.asset) { if (!face || !face.asset) {
this.logger.warn(`Face ${id} not found`); this.logger.warn(`Face ${id} not found`);
@ -544,7 +544,7 @@ export class PersonService extends BaseService {
} }
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); 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`); this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }