1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-24 17:05:55 +01: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 { 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<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
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<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
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<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 }) {
return qb
.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'`;
}
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();
/** 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 {
search(ownerId: string): Promise<MemoryEntity[]>;
get(id: string): Promise<MemoryEntity | null>;
get(id: string): Promise<MemoryEntity | undefined>;
create(
memory: Omit<Insertable<Memories>, 'data'> & { data: OnThisDayData },
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 { 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<AssetFaces>)[];
export interface IPersonRepository {
getAll(options?: Partial<PersonEntity>): AsyncIterableIterator<PersonEntity>;
@ -74,10 +74,10 @@ export interface IPersonRepository {
id: string,
relations?: FindOptionsRelations<AssetFaceEntity>,
select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null>;
): Promise<AssetFaceEntity | undefined>;
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getRandomFace(personId: string): Promise<AssetFaceEntity | undefined>;
getStatistics(personId: string): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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<DB, 'albums'>) => {
.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<DB, 'albums'>) => {
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
@InjectKysely() private db: Kysely<DB>,
) {}
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, {}] })
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined> {
return this.db
.selectFrom('albums')

View file

@ -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<AssetEntity[]>;
}
@ -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<AssetEntity[]> {
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)

View file

@ -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<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 {

View file

@ -22,8 +22,8 @@ export class MemoryRepository implements IMemoryRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string): Promise<MemoryEntity | null> {
return this.getByIdBuilder(id).executeTakeFirst() as unknown as Promise<MemoryEntity | null>;
get(id: string): Promise<MemoryEntity | undefined> {
return this.getByIdBuilder(id).executeTakeFirst() as unknown as Promise<MemoryEntity | undefined>;
}
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]] })
async addAssetIds(id: string, assetIds: string[]): Promise<void> {
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<void> {
if (assetIds.length === 0) {
return;
}
await this.db
.deleteFrom('memories_assets_assets')
.where('memoriesId', '=', id)

View file

@ -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<AssetFaceEntity>,
select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null> {
return (this.db
): Promise<AssetFaceEntity | undefined> {
return this.db
.selectFrom('asset_faces')
.$if(!!select, (qb) =>
qb.select(
Object.keys(
_.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined),
) as SelectExpression<DB, 'asset_faces'>[],
),
)
.$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<AssetFaceEntity | null>;
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
@ -335,6 +328,10 @@ export class PersonRepository implements IPersonRepository {
}
async createAll(people: Insertable<Person>[]): Promise<string[]> {
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<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) {
assetIds.push(assetId);
personIds.push(personId);
@ -405,12 +406,12 @@ export class PersonRepository implements IPersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return (this.db
getRandomFace(personId: string): Promise<AssetFaceEntity | undefined> {
return this.db
.selectFrom('asset_faces')
.selectAll('asset_faces')
.where('asset_faces.personId', '=', personId)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
}
@GenerateSql()

View file

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

View file

@ -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();

View file

@ -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;
}