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 { 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<ArrayBufferLike> | 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;

View File

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

View File

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

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

View File

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

View File

@ -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<DB, 'asset_stack'>, 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<DB, 'asset_stack'>, 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<DB, 'asset_stack'>, withTags = false)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.where('assets.deletedAt', 'is', null)
.whereRef('assets.stackId', '=', 'asset_stack.id'),
).as('assets');
)
.$castTo<AssetEntity[]>()
.as('assets');
};
@Injectable()
@ -43,17 +46,17 @@ export class StackRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID }] })
search(query: StackSearch): Promise<StackEntity[]> {
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<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) => {
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<StackEntity>;
.executeTakeFirstOrThrow();
});
}
@ -129,23 +132,23 @@ export class StackRepository {
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
.updateTable('asset_stack')
.set(entity)
.where('id', '=', asUuid(id))
.returningAll('asset_stack')
.returning((eb) => withAssets(eb, true))
.executeTakeFirstOrThrow() as unknown as Promise<StackEntity>;
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string): Promise<StackEntity | undefined> {
getById(id: string) {
return this.db
.selectFrom('asset_stack')
.selectAll()
.select((eb) => withAssets(eb, true))
.where('id', '=', asUuid(id))
.executeTakeFirst() as Promise<StackEntity | undefined>;
.executeTakeFirst();
}
}

View File

@ -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 }] })

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 () => {
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 });

View File

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

View File

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