refactor: migrate stacks (#17559)

chore: migrate stacks
This commit is contained in:
Daniel Dietzler 2025-04-12 14:33:35 +02:00 committed by GitHub
parent 5dac315af7
commit a373034629
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 153 additions and 63 deletions

View File

@ -1,5 +1,6 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { Exif as DatabaseExif } from 'src/db'; import { Exif as DatabaseExif } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity';
import { import {
AlbumUserRole, AlbumUserRole,
AssetFileType, AssetFileType,
@ -147,6 +148,7 @@ export type Asset = {
originalPath: string; originalPath: string;
ownerId: string; ownerId: string;
sidecarPath: string | null; sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null; stackId: string | null;
thumbhash: Buffer<ArrayBufferLike> | null; thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType; type: AssetType;
@ -159,6 +161,15 @@ export type SidecarWriteAsset = {
tags: Array<{ value: string }>; tags: Array<{ value: string }>;
}; };
export type Stack = {
id: string;
primaryAssetId: string;
owner?: User;
ownerId: string;
assets: AssetEntity[];
assetCount?: number;
};
export type AuthSharedLink = { export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;
@ -276,7 +287,7 @@ export const columns = {
'quotaSizeInBytes', 'quotaSizeInBytes',
'quotaUsageInBytes', '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'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
syncAsset: [ syncAsset: [
'id', 'id',
@ -292,6 +303,7 @@ export const columns = {
'isVisible', 'isVisible',
'updateId', 'updateId',
], ],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [ syncAssetExif: [
'exif.assetId', 'exif.assetId',
'exif.description', 'exif.description',
@ -320,4 +332,35 @@ export const columns = {
'exif.fps', 'exif.fps',
'exif.updateId', '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; } as const;

View File

@ -1,7 +1,7 @@
import { ArrayMinSize } from 'class-validator'; import { ArrayMinSize } from 'class-validator';
import { Stack } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { StackEntity } from 'src/entities/stack.entity';
import { ValidateUUID } from 'src/validation'; import { ValidateUUID } from 'src/validation';
export class StackCreateDto { export class StackCreateDto {
@ -27,7 +27,7 @@ export class StackResponseDto {
assets!: AssetResponseDto[]; 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 primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId);
const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId); const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId);

View File

@ -1,11 +1,10 @@
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, 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 { AssetFace, AssetFile, Exif, Tag, User } from 'src/database'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
@ -50,7 +49,7 @@ export class AssetEntity {
albums?: AlbumEntity[]; albums?: AlbumEntity[];
faces!: AssetFace[]; faces!: AssetFace[];
stackId?: string | null; stackId?: string | null;
stack?: StackEntity | null; stack?: Stack | null;
jobStatus?: AssetJobStatusEntity; jobStatus?: AssetJobStatusEntity;
duplicateId!: string | null; duplicateId!: string | null;
} }

View File

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

View File

@ -15,7 +15,35 @@ select
"assets" "assets"
inner join lateral ( inner join lateral (
select 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 from
"exif" "exif"
where where
@ -52,7 +80,12 @@ select
from from
( (
select select
"tags".* "tags"."id",
"tags"."value",
"tags"."createdAt",
"tags"."updatedAt",
"tags"."color",
"tags"."parentId"
from from
"tags" "tags"
inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId" inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
@ -65,7 +98,35 @@ select
"assets" "assets"
inner join lateral ( inner join lateral (
select 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 from
"exif" "exif"
where where

View File

@ -2,12 +2,12 @@
-- TagRepository.get -- TagRepository.get
select select
"id", "tags"."id",
"value", "tags"."value",
"createdAt", "tags"."createdAt",
"updatedAt", "tags"."updatedAt",
"color", "tags"."color",
"parentId" "tags"."parentId"
from from
"tags" "tags"
where where
@ -15,12 +15,12 @@ where
-- TagRepository.getByValue -- TagRepository.getByValue
select select
"id", "tags"."id",
"value", "tags"."value",
"createdAt", "tags"."createdAt",
"updatedAt", "tags"."updatedAt",
"color", "tags"."color",
"parentId" "tags"."parentId"
from from
"tags" "tags"
where where
@ -42,12 +42,12 @@ rollback
-- TagRepository.getAll -- TagRepository.getAll
select select
"id", "tags"."id",
"value", "tags"."value",
"createdAt", "tags"."createdAt",
"updatedAt", "tags"."updatedAt",
"color", "tags"."color",
"parentId" "tags"."parentId"
from from
"tags" "tags"
where where

View File

@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Kysely, Updateable } from 'kysely'; import { ExpressionBuilder, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; 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 { 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'; import { asUuid } from 'src/utils/database';
export interface StackSearch { export interface StackSearch {
@ -18,7 +19,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false)
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.innerJoinLateral( .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(), (join) => join.onTrue(),
) )
.$if(withTags, (eb) => .$if(withTags, (eb) =>
@ -26,7 +27,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false)
jsonArrayFrom( jsonArrayFrom(
eb eb
.selectFrom('tags') .selectFrom('tags')
.selectAll('tags') .select(columns.tag)
.innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId') .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
.whereRef('tag_asset.assetsId', '=', 'assets.id'), .whereRef('tag_asset.assetsId', '=', 'assets.id'),
).as('tags'), ).as('tags'),
@ -35,7 +36,9 @@ const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.whereRef('assets.stackId', '=', 'asset_stack.id'), .whereRef('assets.stackId', '=', 'asset_stack.id'),
).as('assets'); )
.$castTo<AssetEntity[]>()
.as('assets');
}; };
@Injectable() @Injectable()
@ -43,17 +46,17 @@ export class StackRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] })
search(query: StackSearch): Promise<StackEntity[]> { search(query: StackSearch) {
return this.db return this.db
.selectFrom('asset_stack') .selectFrom('asset_stack')
.selectAll('asset_stack') .selectAll('asset_stack')
.select(withAssets) .select(withAssets)
.where('asset_stack.ownerId', '=', query.ownerId) .where('asset_stack.ownerId', '=', query.ownerId)
.$if(!!query.primaryAssetId, (eb) => eb.where('asset_stack.primaryAssetId', '=', query.primaryAssetId!)) .$if(!!query.primaryAssetId, (eb) => eb.where('asset_stack.primaryAssetId', '=', query.primaryAssetId!))
.execute() as unknown as Promise<StackEntity[]>; .execute();
} }
async create(entity: { ownerId: string; assetIds: string[] }): Promise<StackEntity> { async create(entity: { ownerId: string; assetIds: string[] }) {
return this.db.transaction().execute(async (tx) => { return this.db.transaction().execute(async (tx) => {
const stacks = await tx const stacks = await tx
.selectFrom('asset_stack') .selectFrom('asset_stack')
@ -116,7 +119,7 @@ export class StackRepository {
.selectAll('asset_stack') .selectAll('asset_stack')
.select(withAssets) .select(withAssets)
.where('id', '=', newRecord.id) .where('id', '=', newRecord.id)
.executeTakeFirst() as unknown as Promise<StackEntity>; .executeTakeFirstOrThrow();
}); });
} }
@ -129,23 +132,23 @@ export class StackRepository {
await this.db.deleteFrom('asset_stack').where('id', 'in', ids).execute(); await this.db.deleteFrom('asset_stack').where('id', 'in', ids).execute();
} }
update(id: string, entity: Updateable<StackEntity>): Promise<StackEntity> { update(id: string, entity: Updateable<AssetStack>) {
return this.db return this.db
.updateTable('asset_stack') .updateTable('asset_stack')
.set(entity) .set(entity)
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.returningAll('asset_stack') .returningAll('asset_stack')
.returning((eb) => withAssets(eb, true)) .returning((eb) => withAssets(eb, true))
.executeTakeFirstOrThrow() as unknown as Promise<StackEntity>; .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getById(id: string): Promise<StackEntity | undefined> { getById(id: string) {
return this.db return this.db
.selectFrom('asset_stack') .selectFrom('asset_stack')
.selectAll() .selectAll()
.select((eb) => withAssets(eb, true)) .select((eb) => withAssets(eb, true))
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.executeTakeFirst() as Promise<StackEntity | undefined>; .executeTakeFirst();
} }
} }

View File

@ -17,14 +17,14 @@ export class TagRepository {
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
get(id: string) { 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] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByValue(userId: string, value: string) { getByValue(userId: string, value: string) {
return this.db return this.db
.selectFrom('tags') .selectFrom('tags')
.select(columns.tagDto) .select(columns.tag)
.where('userId', '=', userId) .where('userId', '=', userId)
.where('value', '=', value) .where('value', '=', value)
.executeTakeFirst(); .executeTakeFirst();
@ -68,12 +68,7 @@ export class TagRepository {
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getAll(userId: string) { getAll(userId: string) {
return this.db return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value asc').execute();
.selectFrom('tags')
.select(columns.tagDto)
.where('userId', '=', userId)
.orderBy('value asc')
.execute();
} }
@GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] }) @GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] })

View File

@ -592,8 +592,8 @@ describe(AssetService.name, () => {
}); });
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { 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.stack.update.mockResolvedValue(factory.stack() as any);
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });

View File

@ -203,7 +203,7 @@ export class AssetService extends BaseService {
// Replace the parent of the stack children with a new asset // Replace the parent of the stack children with a new asset
if (asset.stack?.primaryAssetId === id) { 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) { if (stackAssetIds.length > 2) {
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
await this.stackRepository.update(asset.stack.id, { await this.stackRepository.update(asset.stack.id, {

View File

@ -1,6 +1,5 @@
import { AssetFile, Exif } from 'src/database'; import { AssetFile, Exif } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { StorageAsset } from 'src/types'; import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -27,7 +26,7 @@ const fullsizeFile: AssetFile = {
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { export const stackStub = (stackId: string, assets: AssetEntity[]) => {
return { return {
id: stackId, id: stackId,
assets, assets,