From a373034629d859c9e43a5da2809be4e2a622ad39 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 12 Apr 2025 14:33:35 +0200 Subject: [PATCH] refactor: migrate stacks (#17559) chore: migrate stacks --- server/src/database.ts | 45 +++++++++++++- server/src/dtos/stack.dto.ts | 4 +- server/src/entities/asset.entity.ts | 5 +- server/src/entities/stack.entity.ts | 10 --- server/src/queries/stack.repository.sql | 67 ++++++++++++++++++++- server/src/queries/tag.repository.sql | 36 +++++------ server/src/repositories/stack.repository.ts | 29 +++++---- server/src/repositories/tag.repository.ts | 11 +--- server/src/services/asset.service.spec.ts | 4 +- server/src/services/asset.service.ts | 2 +- server/test/fixtures/asset.stub.ts | 3 +- 11 files changed, 153 insertions(+), 63 deletions(-) delete mode 100644 server/src/entities/stack.entity.ts diff --git a/server/src/database.ts b/server/src/database.ts index 38b3ca3a1d..927b9e5f7b 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,5 +1,6 @@ import { Selectable } from 'kysely'; import { Exif as DatabaseExif } from 'src/db'; +import { AssetEntity } from 'src/entities/asset.entity'; import { AlbumUserRole, AssetFileType, @@ -147,6 +148,7 @@ export type Asset = { originalPath: string; ownerId: string; sidecarPath: string | null; + stack?: Stack | null; stackId: string | null; thumbhash: Buffer | null; type: AssetType; @@ -159,6 +161,15 @@ export type SidecarWriteAsset = { tags: Array<{ value: string }>; }; +export type Stack = { + id: string; + primaryAssetId: string; + owner?: User; + ownerId: string; + assets: AssetEntity[]; + assetCount?: number; +}; + export type AuthSharedLink = { id: string; expiresAt: Date | null; @@ -276,7 +287,7 @@ export const columns = { 'quotaSizeInBytes', 'quotaUsageInBytes', ], - tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'], + tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], syncAsset: [ 'id', @@ -292,6 +303,7 @@ export const columns = { 'isVisible', 'updateId', ], + stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], syncAssetExif: [ 'exif.assetId', 'exif.description', @@ -320,4 +332,35 @@ export const columns = { 'exif.fps', 'exif.updateId', ], + exif: [ + 'exif.assetId', + 'exif.autoStackId', + 'exif.bitsPerSample', + 'exif.city', + 'exif.colorspace', + 'exif.country', + 'exif.dateTimeOriginal', + 'exif.description', + 'exif.exifImageHeight', + 'exif.exifImageWidth', + 'exif.exposureTime', + 'exif.fileSizeInByte', + 'exif.fNumber', + 'exif.focalLength', + 'exif.fps', + 'exif.iso', + 'exif.latitude', + 'exif.lensModel', + 'exif.livePhotoCID', + 'exif.longitude', + 'exif.make', + 'exif.model', + 'exif.modifyDate', + 'exif.orientation', + 'exif.profileDescription', + 'exif.projectionType', + 'exif.rating', + 'exif.state', + 'exif.timeZone', + ], } as const; diff --git a/server/src/dtos/stack.dto.ts b/server/src/dtos/stack.dto.ts index 49845dcf51..17037dd892 100644 --- a/server/src/dtos/stack.dto.ts +++ b/server/src/dtos/stack.dto.ts @@ -1,7 +1,7 @@ import { ArrayMinSize } from 'class-validator'; +import { Stack } from 'src/database'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { StackEntity } from 'src/entities/stack.entity'; import { ValidateUUID } from 'src/validation'; export class StackCreateDto { @@ -27,7 +27,7 @@ export class StackResponseDto { assets!: AssetResponseDto[]; } -export const mapStack = (stack: StackEntity, { auth }: { auth?: AuthDto }) => { +export const mapStack = (stack: Stack, { auth }: { auth?: AuthDto }) => { const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId); const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId); diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 9cf04f8d3b..981e5dc800 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,11 +1,10 @@ import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { AssetFace, AssetFile, Exif, Tag, User } from 'src/database'; +import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database'; import { DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { StackEntity } from 'src/entities/stack.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; @@ -50,7 +49,7 @@ export class AssetEntity { albums?: AlbumEntity[]; faces!: AssetFace[]; stackId?: string | null; - stack?: StackEntity | null; + stack?: Stack | null; jobStatus?: AssetJobStatusEntity; duplicateId!: string | null; } diff --git a/server/src/entities/stack.entity.ts b/server/src/entities/stack.entity.ts deleted file mode 100644 index b0dc79f7eb..0000000000 --- a/server/src/entities/stack.entity.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export class StackEntity { - id!: string; - ownerId!: string; - assets!: AssetEntity[]; - primaryAsset!: AssetEntity; - primaryAssetId!: string; - assetCount?: number; -} diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index 9fff558192..1f0c940101 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -15,7 +15,35 @@ select "assets" inner join lateral ( select - "exif".* + "exif"."assetId", + "exif"."autoStackId", + "exif"."bitsPerSample", + "exif"."city", + "exif"."colorspace", + "exif"."country", + "exif"."dateTimeOriginal", + "exif"."description", + "exif"."exifImageHeight", + "exif"."exifImageWidth", + "exif"."exposureTime", + "exif"."fileSizeInByte", + "exif"."fNumber", + "exif"."focalLength", + "exif"."fps", + "exif"."iso", + "exif"."latitude", + "exif"."lensModel", + "exif"."livePhotoCID", + "exif"."longitude", + "exif"."make", + "exif"."model", + "exif"."modifyDate", + "exif"."orientation", + "exif"."profileDescription", + "exif"."projectionType", + "exif"."rating", + "exif"."state", + "exif"."timeZone" from "exif" where @@ -52,7 +80,12 @@ select from ( select - "tags".* + "tags"."id", + "tags"."value", + "tags"."createdAt", + "tags"."updatedAt", + "tags"."color", + "tags"."parentId" from "tags" inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId" @@ -65,7 +98,35 @@ select "assets" inner join lateral ( select - "exif".* + "exif"."assetId", + "exif"."autoStackId", + "exif"."bitsPerSample", + "exif"."city", + "exif"."colorspace", + "exif"."country", + "exif"."dateTimeOriginal", + "exif"."description", + "exif"."exifImageHeight", + "exif"."exifImageWidth", + "exif"."exposureTime", + "exif"."fileSizeInByte", + "exif"."fNumber", + "exif"."focalLength", + "exif"."fps", + "exif"."iso", + "exif"."latitude", + "exif"."lensModel", + "exif"."livePhotoCID", + "exif"."longitude", + "exif"."make", + "exif"."model", + "exif"."modifyDate", + "exif"."orientation", + "exif"."profileDescription", + "exif"."projectionType", + "exif"."rating", + "exif"."state", + "exif"."timeZone" from "exif" where diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql index 6c97d7843f..c3eb1424fd 100644 --- a/server/src/queries/tag.repository.sql +++ b/server/src/queries/tag.repository.sql @@ -2,12 +2,12 @@ -- TagRepository.get select - "id", - "value", - "createdAt", - "updatedAt", - "color", - "parentId" + "tags"."id", + "tags"."value", + "tags"."createdAt", + "tags"."updatedAt", + "tags"."color", + "tags"."parentId" from "tags" where @@ -15,12 +15,12 @@ where -- TagRepository.getByValue select - "id", - "value", - "createdAt", - "updatedAt", - "color", - "parentId" + "tags"."id", + "tags"."value", + "tags"."createdAt", + "tags"."updatedAt", + "tags"."color", + "tags"."parentId" from "tags" where @@ -42,12 +42,12 @@ rollback -- TagRepository.getAll select - "id", - "value", - "createdAt", - "updatedAt", - "color", - "parentId" + "tags"."id", + "tags"."value", + "tags"."createdAt", + "tags"."updatedAt", + "tags"."color", + "tags"."parentId" from "tags" where diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 501067072d..75dd9b497f 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Kysely, Updateable } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { DB } from 'src/db'; +import { columns } from 'src/database'; +import { AssetStack, DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { StackEntity } from 'src/entities/stack.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { asUuid } from 'src/utils/database'; export interface StackSearch { @@ -18,7 +19,7 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) .selectFrom('assets') .selectAll('assets') .innerJoinLateral( - (eb) => eb.selectFrom('exif').selectAll('exif').whereRef('exif.assetId', '=', 'assets.id').as('exifInfo'), + (eb) => eb.selectFrom('exif').select(columns.exif).whereRef('exif.assetId', '=', 'assets.id').as('exifInfo'), (join) => join.onTrue(), ) .$if(withTags, (eb) => @@ -26,7 +27,7 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) jsonArrayFrom( eb .selectFrom('tags') - .selectAll('tags') + .select(columns.tag) .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId') .whereRef('tag_asset.assetsId', '=', 'assets.id'), ).as('tags'), @@ -35,7 +36,9 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) .where('assets.deletedAt', 'is', null) .whereRef('assets.stackId', '=', 'asset_stack.id'), - ).as('assets'); + ) + .$castTo() + .as('assets'); }; @Injectable() @@ -43,17 +46,17 @@ export class StackRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) - search(query: StackSearch): Promise { + search(query: StackSearch) { return this.db .selectFrom('asset_stack') .selectAll('asset_stack') .select(withAssets) .where('asset_stack.ownerId', '=', query.ownerId) .$if(!!query.primaryAssetId, (eb) => eb.where('asset_stack.primaryAssetId', '=', query.primaryAssetId!)) - .execute() as unknown as Promise; + .execute(); } - async create(entity: { ownerId: string; assetIds: string[] }): Promise { + async create(entity: { ownerId: string; assetIds: string[] }) { return this.db.transaction().execute(async (tx) => { const stacks = await tx .selectFrom('asset_stack') @@ -116,7 +119,7 @@ export class StackRepository { .selectAll('asset_stack') .select(withAssets) .where('id', '=', newRecord.id) - .executeTakeFirst() as unknown as Promise; + .executeTakeFirstOrThrow(); }); } @@ -129,23 +132,23 @@ export class StackRepository { await this.db.deleteFrom('asset_stack').where('id', 'in', ids).execute(); } - update(id: string, entity: Updateable): Promise { + update(id: string, entity: Updateable) { return this.db .updateTable('asset_stack') .set(entity) .where('id', '=', asUuid(id)) .returningAll('asset_stack') .returning((eb) => withAssets(eb, true)) - .executeTakeFirstOrThrow() as unknown as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string): Promise { + getById(id: string) { return this.db .selectFrom('asset_stack') .selectAll() .select((eb) => withAssets(eb, true)) .where('id', '=', asUuid(id)) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } } diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index c0ca6ebf37..1236d80efa 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -17,14 +17,14 @@ export class TagRepository { @GenerateSql({ params: [DummyValue.UUID] }) get(id: string) { - return this.db.selectFrom('tags').select(columns.tagDto).where('id', '=', id).executeTakeFirst(); + return this.db.selectFrom('tags').select(columns.tag).where('id', '=', id).executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByValue(userId: string, value: string) { return this.db .selectFrom('tags') - .select(columns.tagDto) + .select(columns.tag) .where('userId', '=', userId) .where('value', '=', value) .executeTakeFirst(); @@ -68,12 +68,7 @@ export class TagRepository { @GenerateSql({ params: [DummyValue.UUID] }) getAll(userId: string) { - return this.db - .selectFrom('tags') - .select(columns.tagDto) - .where('userId', '=', userId) - .orderBy('value asc') - .execute(); + return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value asc').execute(); } @GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] }) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 4f4b88c320..a02324a3ae 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -592,8 +592,8 @@ describe(AssetService.name, () => { }); it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { - mocks.stack.update.mockResolvedValue(factory.stack() as unknown as any); - mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); + mocks.stack.update.mockResolvedValue(factory.stack() as any); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 51e54adf5b..6ab3a76f28 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -203,7 +203,7 @@ export class AssetService extends BaseService { // Replace the parent of the stack children with a new asset if (asset.stack?.primaryAssetId === id) { - const stackAssetIds = asset.stack.assets.map((a) => a.id); + const stackAssetIds = asset.stack?.assets.map((a) => a.id) ?? []; if (stackAssetIds.length > 2) { const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; await this.stackRepository.update(asset.stack.id, { diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index b388c31e73..34d75fc762 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,6 +1,5 @@ import { AssetFile, Exif } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; -import { StackEntity } from 'src/entities/stack.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { StorageAsset } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; @@ -27,7 +26,7 @@ const fullsizeFile: AssetFile = { const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; -export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { +export const stackStub = (stackId: string, assets: AssetEntity[]) => { return { id: stackId, assets,