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;
};
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 = {
id: string;
permissions: Permission[];

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
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';
export class CreateLibraryDto {
@ -120,7 +120,7 @@ export class LibraryStatsResponseDto {
usage = 0;
}
export function mapLibrary(entity: LibraryEntity): LibraryResponseDto {
export function mapLibrary(entity: Library): LibraryResponseDto {
let assetCount = 0;
if (entity.assets) {
assetCount = entity.assets.length;

View File

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

View File

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

View File

@ -1,31 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
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';
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 {
DO_NOTHING,
UPDATE,
@ -33,72 +13,59 @@ export enum AssetSyncResult {
CHECK_OFFLINE,
}
const withOwner = (eb: ExpressionBuilder<DB, 'libraries'>) => {
return jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'libraries.ownerId').select(userColumns)).as(
'owner',
);
};
@Injectable()
export class LibraryRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string, withDeleted = false): Promise<LibraryEntity | undefined> {
get(id: string, withDeleted = false) {
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<LibraryEntity | undefined>;
.executeTakeFirst();
}
@GenerateSql({ params: [] })
getAll(withDeleted = false): Promise<LibraryEntity[]> {
getAll(withDeleted = false) {
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<LibraryEntity[]>;
.execute();
}
@GenerateSql()
getAllDeleted(): Promise<LibraryEntity[]> {
getAllDeleted() {
return this.db
.selectFrom('libraries')
.selectAll('libraries')
.select(withOwner)
.where('libraries.deletedAt', 'is not', null)
.orderBy('createdAt', 'asc')
.execute() as unknown as Promise<LibraryEntity[]>;
.execute();
}
create(library: Insertable<Libraries>): Promise<LibraryEntity> {
return this.db
.insertInto('libraries')
.values(library)
.returningAll()
.executeTakeFirstOrThrow() as Promise<LibraryEntity>;
create(library: Insertable<Libraries>) {
return this.db.insertInto('libraries').values(library).returningAll().executeTakeFirstOrThrow();
}
async delete(id: string): Promise<void> {
async delete(id: string) {
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();
}
update(id: string, library: Updateable<Libraries>): Promise<LibraryEntity> {
update(id: string, library: Updateable<Libraries>) {
return this.db
.updateTable('libraries')
.set(library)
.where('libraries.id', '=', id)
.returningAll()
.executeTakeFirstOrThrow() as Promise<LibraryEntity>;
.executeTakeFirstOrThrow();
}
@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 { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { libraryStub } from 'test/fixtures/library.stub';
import { userStub } from 'test/fixtures/user.stub';
const previewFile: AssetFileEntity = {
@ -396,7 +395,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -751,7 +749,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
sharedLinks: [],
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 { Asset, AuthUser, User } from 'src/database';
import { Asset, AuthUser, Library, User } from 'src/database';
import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetStatus, AssetType, MemoryType } from 'src/enum';
import { ActivityItem, MemoryItem } from 'src/types';
@ -13,7 +13,11 @@ export const newDate = () => new Date();
export const newUpdateId = () => 'uuid-v7';
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(),
isAdmin: false,
name: 'Test User',
@ -23,7 +27,7 @@ const authUser = (authUser: Partial<AuthUser>) => ({
...authUser,
});
const user = (user: Partial<User>) => ({
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
name: 'Test User',
email: 'test@immich.cloud',
@ -32,75 +36,95 @@ const user = (user: Partial<User>) => ({
...user,
});
export const factory = {
auth: (user: Partial<AuthUser> = {}) => ({
user: authUser(user),
}),
authUser,
user,
asset: (asset: Partial<Asset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUpdateId(),
status: AssetStatus.ACTIVE,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isArchived: false,
isExternal: false,
isFavorite: false,
isOffline: false,
isVisible: true,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `upload/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.IMAGE,
...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> = {}) => ({
const assetFactory = (asset: Partial<Asset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUpdateId(),
status: AssetStatus.ACTIVE,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isArchived: false,
isExternal: false,
isFavorite: false,
isOffline: false,
isVisible: true,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `upload/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.IMAGE,
...asset,
});
const activityFactory = (activity: Partial<ActivityItem> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
assetId: newUuid(),
albumId: 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,
}),
...activity,
};
};
const libraryFactory = (library: Partial<Library> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
deletedAt: null,
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,
};