fix(server): query fixes (#15509)

This commit is contained in:
Mert
2025-01-22 15:17:42 -05:00
committed by GitHub
parent 7b882b35e5
commit 49a6961ec6
15 changed files with 275 additions and 165 deletions

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)