refactor: migrate library spec to factories (#16711)

This commit is contained in:
Jason Rasmussen 2025-03-08 13:44:36 -05:00 committed by GitHub
parent fd46d43726
commit 1e127ae3a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 407 additions and 535 deletions

View File

@ -10,6 +10,20 @@ export type AuthUser = {
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
}; };
export type Library = {
id: string;
ownerId: string;
createdAt: Date;
updatedAt: Date;
updateId: string;
name: string;
importPaths: string[];
exclusionPatterns: string[];
deletedAt: Date | null;
refreshedAt: Date | null;
assets?: Asset[];
};
export type AuthApiKey = { export type AuthApiKey = {
id: string; id: string;
permissions: Permission[]; permissions: Permission[];

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
import { LibraryEntity } from 'src/entities/library.entity'; import { Library } from 'src/database';
import { Optional, ValidateUUID } from 'src/validation'; import { Optional, ValidateUUID } from 'src/validation';
export class CreateLibraryDto { export class CreateLibraryDto {
@ -120,7 +120,7 @@ export class LibraryStatsResponseDto {
usage = 0; usage = 0;
} }
export function mapLibrary(entity: LibraryEntity): LibraryResponseDto { export function mapLibrary(entity: Library): LibraryResponseDto {
let assetCount = 0; let assetCount = 0;
if (entity.assets) { if (entity.assets) {
assetCount = entity.assets.length; assetCount = entity.assets.length;

View File

@ -26,7 +26,7 @@ export class LibraryEntity {
assets!: AssetEntity[]; assets!: AssetEntity[];
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity; owner?: UserEntity;
@Column() @Column()
ownerId!: string; ownerId!: string;

View File

@ -2,34 +2,7 @@
-- LibraryRepository.get -- LibraryRepository.get
select select
"libraries".*, "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 from
"libraries" "libraries"
where where
@ -38,34 +11,7 @@ where
-- LibraryRepository.getAll -- LibraryRepository.getAll
select select
"libraries".*, "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 from
"libraries" "libraries"
where where
@ -75,34 +21,7 @@ order by
-- LibraryRepository.getAllDeleted -- LibraryRepository.getAllDeleted
select select
"libraries".*, "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 from
"libraries" "libraries"
where where

View File

@ -1,31 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB, Libraries } from 'src/db'; import { DB, Libraries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType } from 'src/enum'; import { AssetType } from 'src/enum';
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;
export enum AssetSyncResult { export enum AssetSyncResult {
DO_NOTHING, DO_NOTHING,
UPDATE, UPDATE,
@ -33,72 +13,59 @@ export enum AssetSyncResult {
CHECK_OFFLINE, CHECK_OFFLINE,
} }
const withOwner = (eb: ExpressionBuilder<DB, 'libraries'>) => {
return jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'libraries.ownerId').select(userColumns)).as(
'owner',
);
};
@Injectable() @Injectable()
export class LibraryRepository { export class LibraryRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
get(id: string, withDeleted = false): Promise<LibraryEntity | undefined> { get(id: string, withDeleted = false) {
return this.db return this.db
.selectFrom('libraries') .selectFrom('libraries')
.selectAll('libraries') .selectAll('libraries')
.select(withOwner)
.where('libraries.id', '=', id) .where('libraries.id', '=', id)
.$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null))
.executeTakeFirst() as Promise<LibraryEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [] }) @GenerateSql({ params: [] })
getAll(withDeleted = false): Promise<LibraryEntity[]> { getAll(withDeleted = false) {
return this.db return this.db
.selectFrom('libraries') .selectFrom('libraries')
.selectAll('libraries') .selectAll('libraries')
.select(withOwner)
.orderBy('createdAt', 'asc') .orderBy('createdAt', 'asc')
.$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null))
.execute() as unknown as Promise<LibraryEntity[]>; .execute();
} }
@GenerateSql() @GenerateSql()
getAllDeleted(): Promise<LibraryEntity[]> { getAllDeleted() {
return this.db return this.db
.selectFrom('libraries') .selectFrom('libraries')
.selectAll('libraries') .selectAll('libraries')
.select(withOwner)
.where('libraries.deletedAt', 'is not', null) .where('libraries.deletedAt', 'is not', null)
.orderBy('createdAt', 'asc') .orderBy('createdAt', 'asc')
.execute() as unknown as Promise<LibraryEntity[]>; .execute();
} }
create(library: Insertable<Libraries>): Promise<LibraryEntity> { create(library: Insertable<Libraries>) {
return this.db return this.db.insertInto('libraries').values(library).returningAll().executeTakeFirstOrThrow();
.insertInto('libraries')
.values(library)
.returningAll()
.executeTakeFirstOrThrow() as Promise<LibraryEntity>;
} }
async delete(id: string): Promise<void> { async delete(id: string) {
await this.db.deleteFrom('libraries').where('libraries.id', '=', id).execute(); await this.db.deleteFrom('libraries').where('libraries.id', '=', id).execute();
} }
async softDelete(id: string): Promise<void> { async softDelete(id: string) {
await this.db.updateTable('libraries').set({ deletedAt: new Date() }).where('libraries.id', '=', id).execute(); await this.db.updateTable('libraries').set({ deletedAt: new Date() }).where('libraries.id', '=', id).execute();
} }
update(id: string, library: Updateable<Libraries>): Promise<LibraryEntity> { update(id: string, library: Updateable<Libraries>) {
return this.db return this.db
.updateTable('libraries') .updateTable('libraries')
.set(library) .set(library)
.where('libraries.id', '=', id) .where('libraries.id', '=', id)
.returningAll() .returningAll()
.executeTakeFirstOrThrow() as Promise<LibraryEntity>; .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ 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';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { libraryStub } from 'test/fixtures/library.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
const previewFile: AssetFileEntity = { const previewFile: AssetFileEntity = {
@ -396,7 +395,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
libraryId: 'library-id', libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
@ -751,7 +749,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
libraryId: 'library-id', libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'photo.jpg', originalFileName: 'photo.jpg',

View File

@ -1,77 +0,0 @@
import { LibraryEntity } from 'src/entities/library.entity';
import { userStub } from 'test/fixtures/user.stub';
export const libraryStub = {
externalLibrary1: Object.freeze<LibraryEntity>({
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: [],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibrary2: Object.freeze<LibraryEntity>({
id: 'library-id2',
name: 'test_library2',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: [],
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2022-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibraryWithImportPaths1: Object.freeze<LibraryEntity>({
id: 'library-id-with-paths1',
name: 'library-with-import-paths1',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: ['/foo', '/bar'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibraryWithImportPaths2: Object.freeze<LibraryEntity>({
id: 'library-id-with-paths2',
name: 'library-with-import-paths2',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
patternPath: Object.freeze<LibraryEntity>({
id: 'library-id1337',
name: 'importpath-exclusion-library1',
assets: [],
owner: userStub.admin,
ownerId: 'user-id',
importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: ['**/dir1/**'],
}),
hasImmichPaths: Object.freeze<LibraryEntity>({
id: 'library-id1337',
name: 'importpath-exclusion-library1',
assets: [],
owner: userStub.admin,
ownerId: 'user-id',
importPaths: ['upload/thumbs', 'xyz', 'upload/library'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: ['**/dir1/**'],
}),
};

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Asset, AuthUser, User } from 'src/database'; import { Asset, AuthUser, Library, User } from 'src/database';
import { OnThisDayData } from 'src/entities/memory.entity'; import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetStatus, AssetType, MemoryType } from 'src/enum'; import { AssetStatus, AssetType, MemoryType } from 'src/enum';
import { ActivityItem, MemoryItem } from 'src/types'; import { ActivityItem, MemoryItem } from 'src/types';
@ -13,7 +13,11 @@ export const newDate = () => new Date();
export const newUpdateId = () => 'uuid-v7'; export const newUpdateId = () => 'uuid-v7';
export const newSha1 = () => Buffer.from('this is a fake hash'); export const newSha1 = () => Buffer.from('this is a fake hash');
const authUser = (authUser: Partial<AuthUser>) => ({ const authFactory = (user: Partial<AuthUser> = {}) => ({
user: authUserFactory(user),
});
const authUserFactory = (authUser: Partial<AuthUser>) => ({
id: newUuid(), id: newUuid(),
isAdmin: false, isAdmin: false,
name: 'Test User', name: 'Test User',
@ -23,7 +27,7 @@ const authUser = (authUser: Partial<AuthUser>) => ({
...authUser, ...authUser,
}); });
const user = (user: Partial<User>) => ({ const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(), id: newUuid(),
name: 'Test User', name: 'Test User',
email: 'test@immich.cloud', email: 'test@immich.cloud',
@ -32,75 +36,95 @@ const user = (user: Partial<User>) => ({
...user, ...user,
}); });
export const factory = { const assetFactory = (asset: Partial<Asset> = {}) => ({
auth: (user: Partial<AuthUser> = {}) => ({ id: newUuid(),
user: authUser(user), createdAt: newDate(),
}), updatedAt: newDate(),
authUser, deletedAt: null,
user, updateId: newUpdateId(),
asset: (asset: Partial<Asset> = {}) => ({ status: AssetStatus.ACTIVE,
id: newUuid(), checksum: newSha1(),
createdAt: newDate(), deviceAssetId: '',
updatedAt: newDate(), deviceId: '',
deletedAt: null, duplicateId: null,
updateId: newUpdateId(), duration: null,
status: AssetStatus.ACTIVE, encodedVideoPath: null,
checksum: newSha1(), fileCreatedAt: newDate(),
deviceAssetId: '', fileModifiedAt: newDate(),
deviceId: '', isArchived: false,
duplicateId: null, isExternal: false,
duration: null, isFavorite: false,
encodedVideoPath: null, isOffline: false,
fileCreatedAt: newDate(), isVisible: true,
fileModifiedAt: newDate(), libraryId: null,
isArchived: false, livePhotoVideoId: null,
isExternal: false, localDateTime: newDate(),
isFavorite: false, originalFileName: 'IMG_123.jpg',
isOffline: false, originalPath: `upload/12/34/IMG_123.jpg`,
isVisible: true, ownerId: newUuid(),
libraryId: null, sidecarPath: null,
livePhotoVideoId: null, stackId: null,
localDateTime: newDate(), thumbhash: null,
originalFileName: 'IMG_123.jpg', type: AssetType.IMAGE,
originalPath: `upload/12/34/IMG_123.jpg`, ...asset,
ownerId: newUuid(), });
sidecarPath: null,
stackId: null, const activityFactory = (activity: Partial<ActivityItem> = {}) => {
thumbhash: null, const userId = activity.userId || newUuid();
type: AssetType.IMAGE, return {
...asset,
}),
activity: (activity: Partial<ActivityItem> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: user({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
...activity,
};
},
memory: (memory: Partial<MemoryItem> = {}) => ({
id: newUuid(), id: newUuid(),
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(), createdAt: newDate(),
updatedAt: newDate(), updatedAt: newDate(),
updateId: newUpdateId(), updateId: newUpdateId(),
deletedAt: null, ...activity,
ownerId: newUuid(), };
type: MemoryType.ON_THIS_DAY, };
data: { year: 2024 } as OnThisDayData,
isSaved: false, const libraryFactory = (library: Partial<Library> = {}) => ({
memoryAt: newDate(), id: newUuid(),
seenAt: null, createdAt: newDate(),
showAt: newDate(), updatedAt: newDate(),
hideAt: newDate(), updateId: newUpdateId(),
assets: [], deletedAt: null,
...memory, refreshedAt: null,
}), name: 'Library',
assets: [],
ownerId: newUuid(),
importPaths: [],
exclusionPatterns: [],
...library,
});
const memoryFactory = (memory: Partial<MemoryItem> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
});
export const factory = {
activity: activityFactory,
asset: assetFactory,
auth: authFactory,
authUser: authUserFactory,
library: libraryFactory,
memory: memoryFactory,
user: userFactory,
}; };