From ee4775bb37b87761aae151194a6bc3029d94078b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Sat, 11 Jan 2025 22:28:55 +0100 Subject: [PATCH] refactor: migrate library repository to kysely --- server/src/interfaces/library.interface.ts | 8 +- server/src/queries/library.repository.sql | 265 +++++++++--------- server/src/repositories/library.repository.ts | 139 +++++---- server/src/services/library.service.spec.ts | 14 +- server/src/services/library.service.ts | 4 +- 5 files changed, 225 insertions(+), 205 deletions(-) diff --git a/server/src/interfaces/library.interface.ts b/server/src/interfaces/library.interface.ts index d8f1a1303116e..66e9a7de29d7e 100644 --- a/server/src/interfaces/library.interface.ts +++ b/server/src/interfaces/library.interface.ts @@ -1,3 +1,5 @@ +import { Insertable, Updateable } from 'kysely'; +import { Libraries } from 'src/db'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryEntity } from 'src/entities/library.entity'; @@ -6,10 +8,10 @@ export const ILibraryRepository = 'ILibraryRepository'; export interface ILibraryRepository { getAll(withDeleted?: boolean): Promise; getAllDeleted(): Promise; - get(id: string, withDeleted?: boolean): Promise; - create(library: Partial): Promise; + get(id: string, withDeleted?: boolean): Promise; + create(library: Insertable): Promise; delete(id: string): Promise; softDelete(id: string): Promise; - update(library: Partial): Promise; + update(id: string, library: Updateable): Promise; getStatistics(id: string): Promise; } diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index a5d6ba05dba1f..347990f04c316 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -1,150 +1,137 @@ -- NOTE: This file is auto generated by ./sql-generator -- LibraryRepository.get -SELECT DISTINCT - "distinctAlias"."LibraryEntity_id" AS "ids_LibraryEntity_id" -FROM +select + "libraries".*, ( - SELECT - "LibraryEntity"."id" AS "LibraryEntity_id", - "LibraryEntity"."name" AS "LibraryEntity_name", - "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", - "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", - "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", - "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", - "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", - "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", - "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", - "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", - "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", - "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId", - "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath", - "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", - "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", - "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", - "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", - "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", - "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" - FROM - "libraries" "LibraryEntity" - LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" - AND ( - "LibraryEntity__LibraryEntity_owner"."deletedAt" IS NULL - ) - WHERE - ((("LibraryEntity"."id" = $1))) - AND ("LibraryEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "LibraryEntity_id" ASC -LIMIT - 1 + select + to_json(obj) + from + ( + select + "users"."id", + "users"."email", + "users"."createdAt", + "users"."profileImagePath", + "users"."isAdmin", + "users"."shouldChangePassword", + "users"."deletedAt", + "users"."oauthId", + "users"."updatedAt", + "users"."storageLabel", + "users"."name", + "users"."quotaSizeInBytes", + "users"."quotaUsageInBytes", + "users"."status", + "users"."profileChangedAt" + from + "users" + where + "users"."id" = "libraries"."ownerId" + ) as obj + ) as "owner" +from + "libraries" +where + "libraries"."id" = $1 + and "libraries"."deletedAt" is null -- LibraryRepository.getAll -SELECT - "LibraryEntity"."id" AS "LibraryEntity_id", - "LibraryEntity"."name" AS "LibraryEntity_name", - "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", - "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", - "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", - "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", - "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", - "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", - "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", - "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", - "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", - "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId", - "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath", - "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", - "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", - "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", - "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", - "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", - "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" -FROM - "libraries" "LibraryEntity" - LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" - AND ( - "LibraryEntity__LibraryEntity_owner"."deletedAt" IS NULL - ) -WHERE - "LibraryEntity"."deletedAt" IS NULL -ORDER BY - "LibraryEntity"."createdAt" ASC +select + "libraries".*, + ( + select + to_json(obj) + from + ( + select + "users"."id", + "users"."email", + "users"."createdAt", + "users"."profileImagePath", + "users"."isAdmin", + "users"."shouldChangePassword", + "users"."deletedAt", + "users"."oauthId", + "users"."updatedAt", + "users"."storageLabel", + "users"."name", + "users"."quotaSizeInBytes", + "users"."quotaUsageInBytes", + "users"."status", + "users"."profileChangedAt" + from + "users" + where + "users"."id" = "libraries"."ownerId" + ) as obj + ) as "owner" +from + "libraries" +where + "libraries"."deletedAt" is null +order by + "createdAt" asc -- LibraryRepository.getAllDeleted -SELECT - "LibraryEntity"."id" AS "LibraryEntity_id", - "LibraryEntity"."name" AS "LibraryEntity_name", - "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", - "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", - "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", - "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", - "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", - "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", - "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", - "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", - "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", - "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId", - "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath", - "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", - "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", - "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", - "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", - "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", - "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" -FROM - "libraries" "LibraryEntity" - LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" -WHERE - ((NOT ("LibraryEntity"."deletedAt" IS NULL))) -ORDER BY - "LibraryEntity"."createdAt" ASC +select + "libraries".*, + ( + select + to_json(obj) + from + ( + select + "users"."id", + "users"."email", + "users"."createdAt", + "users"."profileImagePath", + "users"."isAdmin", + "users"."shouldChangePassword", + "users"."deletedAt", + "users"."oauthId", + "users"."updatedAt", + "users"."storageLabel", + "users"."name", + "users"."quotaSizeInBytes", + "users"."quotaUsageInBytes", + "users"."status", + "users"."profileChangedAt" + from + "users" + where + "users"."id" = "libraries"."ownerId" + ) as obj + ) as "owner" +from + "libraries" +where + "libraries"."deletedAt" is not null +order by + "createdAt" asc -- LibraryRepository.getStatistics -SELECT - "libraries"."id" AS "libraries_id", - "libraries"."name" AS "libraries_name", - "libraries"."ownerId" AS "libraries_ownerId", - "libraries"."importPaths" AS "libraries_importPaths", - "libraries"."exclusionPatterns" AS "libraries_exclusionPatterns", - "libraries"."createdAt" AS "libraries_createdAt", - "libraries"."updatedAt" AS "libraries_updatedAt", - "libraries"."deletedAt" AS "libraries_deletedAt", - "libraries"."refreshedAt" AS "libraries_refreshedAt", - COUNT("assets"."id") FILTER ( - WHERE - "assets"."type" = 'IMAGE' - AND "assets"."isVisible" - ) AS "photos", - COUNT("assets"."id") FILTER ( - WHERE - "assets"."type" = 'VIDEO' - AND "assets"."isVisible" - ) AS "videos", - COALESCE(SUM("exif"."fileSizeInByte"), 0) AS "usage" -FROM - "libraries" "libraries" - LEFT JOIN "assets" "assets" ON "assets"."libraryId" = "libraries"."id" - AND ("assets"."deletedAt" IS NULL) - LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id" -WHERE - ("libraries"."id" = $1) - AND ("libraries"."deletedAt" IS NULL) -GROUP BY +select + count("assets"."id") filter ( + where + ( + "assets"."type" = $1 + and "assets"."isVisible" = $2 + ) + ) as "photos", + count(*) filter ( + where + ( + "assets"."type" = $3 + and "assets"."isVisible" = $4 + ) + ) as "videos", + coalesce(sum("exif"."fileSizeInByte"), $5) as "usage" +from + "libraries" + inner join "assets" on "assets"."libraryId" = "libraries"."id" + inner join "exif" on "exif"."assetId" = "assets"."id" +where + "libraries"."id" = $6 +group by "libraries"."id" diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 1446395854aab..ca279d7dea3fe 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -1,84 +1,122 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB, Libraries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryEntity } from 'src/entities/library.entity'; +import { AssetType } from 'src/enum'; import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IsNull, Not } from 'typeorm'; -import { Repository } from 'typeorm/repository/Repository.js'; + +const userColumns = [ + 'users.id', + 'users.email', + 'users.createdAt', + 'users.profileImagePath', + 'users.isAdmin', + 'users.shouldChangePassword', + 'users.deletedAt', + 'users.oauthId', + 'users.updatedAt', + 'users.storageLabel', + 'users.name', + 'users.quotaSizeInBytes', + 'users.quotaUsageInBytes', + 'users.status', + 'users.profileChangedAt', +] as const; + +const withOwner = (eb: ExpressionBuilder) => { + return jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'libraries.ownerId').select(userColumns)).as( + 'owner', + ); +}; @Injectable() export class LibraryRepository implements ILibraryRepository { - constructor(@InjectRepository(LibraryEntity) private repository: Repository) {} + constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) - get(id: string, withDeleted = false): Promise { - return this.repository.findOneOrFail({ - where: { - id, - }, - relations: { owner: true }, - withDeleted, - }); + get(id: string, withDeleted = false): Promise { + return this.db + .selectFrom('libraries') + .selectAll('libraries') + .select(withOwner) + .where('libraries.id', '=', id) + .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) + .executeTakeFirst() as Promise; } @GenerateSql({ params: [] }) getAll(withDeleted = false): Promise { - return this.repository.find({ - relations: { - owner: true, - }, - order: { - createdAt: 'ASC', - }, - withDeleted, - }); + return this.db + .selectFrom('libraries') + .selectAll('libraries') + .select(withOwner) + .orderBy('createdAt', 'asc') + .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) + .execute() as unknown as Promise; } @GenerateSql() getAllDeleted(): Promise { - return this.repository.find({ - where: { - deletedAt: Not(IsNull()), - }, - relations: { - owner: true, - }, - order: { - createdAt: 'ASC', - }, - withDeleted: true, - }); + return this.db + .selectFrom('libraries') + .selectAll('libraries') + .select(withOwner) + .where('libraries.deletedAt', 'is not', null) + .orderBy('createdAt', 'asc') + .execute() as unknown as Promise; } - create(library: Omit): Promise { - return this.repository.save(library); + create(library: Insertable): Promise { + return this.db + .insertInto('libraries') + .values(library) + .returningAll() + .executeTakeFirstOrThrow() as Promise; } async delete(id: string): Promise { - await this.repository.delete({ id }); + await this.db.deleteFrom('libraries').where('libraries.id', '=', id).execute(); } async softDelete(id: string): Promise { - await this.repository.softDelete({ id }); + await this.db.updateTable('libraries').set({ deletedAt: new Date() }).where('libraries.id', '=', id).execute(); } - async update(library: Partial): Promise { - return this.save(library); + update(id: string, library: Updateable): Promise { + return this.db + .updateTable('libraries') + .set(library) + .where('libraries.id', '=', id) + .returningAll() + .executeTakeFirstOrThrow() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) async getStatistics(id: string): Promise { - const stats = await this.repository - .createQueryBuilder('libraries') - .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos') - .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos') - .addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage') - .leftJoin('libraries.assets', 'assets') - .leftJoin('assets.exifInfo', 'exif') + const stats = await this.db + .selectFrom('libraries') + .innerJoin('assets', 'assets.libraryId', 'libraries.id') + .innerJoin('exif', 'exif.assetId', 'assets.id') + .select((eb) => + eb.fn + .count('assets.id') + .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) + .as('photos'), + ) + .select((eb) => + eb.fn + .countAll() + .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) + .as('videos'), + ) + .select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('exif.fileSizeInByte'), eb.val(0)).as('usage')) .groupBy('libraries.id') - .where('libraries.id = :id', { id }) - .getRawOne(); + .where('libraries.id', '=', id) + .executeTakeFirst(); if (!stats) { return; @@ -91,9 +129,4 @@ export class LibraryRepository implements ILibraryRepository { total: Number(stats.photos) + Number(stats.videos), }; } - - private async save(library: Partial) { - const { id } = await this.repository.save(library); - return this.repository.findOneByOrFail({ id }); - } } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index a8ca795535a1d..a08cb108a5d79 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -87,7 +87,7 @@ describe(LibraryService.name, () => { Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( (library) => library.id === id, - ) || null, + ), ), ); @@ -190,8 +190,6 @@ describe(LibraryService.name, () => { }); it("should fail when library can't be found", async () => { - libraryMock.get.mockResolvedValue(null); - await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); @@ -242,8 +240,6 @@ describe(LibraryService.name, () => { }); it("should fail when library can't be found", async () => { - libraryMock.get.mockResolvedValue(null); - await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); }); @@ -630,7 +626,6 @@ describe(LibraryService.name, () => { }); it('should throw an error when a library is not found', async () => { - libraryMock.get.mockResolvedValue(null); await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); @@ -825,7 +820,10 @@ describe(LibraryService.name, () => { await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual( mapLibrary(libraryStub.externalLibrary1), ); - expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); + expect(libraryMock.update).toHaveBeenCalledWith( + 'library-id', + expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), + ); }); }); @@ -1015,7 +1013,7 @@ describe(LibraryService.name, () => { Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( (library) => library.id === id, - ) || null, + ), ), ); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 0290b2e7fe0d7..dca1dec9e2786 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -311,7 +311,7 @@ export class LibraryService extends BaseService { } } - const library = await this.libraryRepository.update({ id, ...dto }); + const library = await this.libraryRepository.update(id, dto); return mapLibrary(library); } @@ -571,7 +571,7 @@ export class LibraryService extends BaseService { this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); } - await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() }); + await this.libraryRepository.update(job.id, { refreshedAt: new Date() }); return JobStatus.SUCCESS; }