mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 18:19:10 +03:00
refactor: migrate user repository to kysely (#15296)
* refactor: migrate user repository to kysely * refactor: migrate user repository to kysely * refactor: migrate user repository to kysely * refactor: migrate user repository to kysely * fix: test * clean up * fix: metadata retrieval bug * use correct typeing for upsert metadata * pr feedback * pr feedback * fix: add deletedAt check * fix: get non deleted user by default * remove console.log * fix: stop kysely after command finishes * final clean up --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import AsyncLock from 'async-lock';
|
||||
import { sql } from 'kysely';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import semver from 'semver';
|
||||
import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
|
||||
import { DB } from 'src/db';
|
||||
@@ -27,6 +28,7 @@ export class DatabaseRepository implements IDatabaseRepository {
|
||||
private readonly asyncLock = new AsyncLock();
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private db: Kysely<DB>,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(IConfigRepository) configRepository: IConfigRepository,
|
||||
@@ -35,6 +37,10 @@ export class DatabaseRepository implements IDatabaseRepository {
|
||||
this.logger.setContext(DatabaseRepository.name);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
await this.db.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
for (const metadata of this.dataSource.entityMetadatas) {
|
||||
const table = metadata.tableName as keyof DB;
|
||||
|
||||
@@ -1,127 +1,212 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { UserMetadata, UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { UserMetadata } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity, withMetadata } from 'src/entities/user.entity';
|
||||
import {
|
||||
IUserRepository,
|
||||
UserFindOptions,
|
||||
UserListFilter,
|
||||
UserStatsQueryResponse,
|
||||
} from 'src/interfaces/user.interface';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
|
||||
const columns = [
|
||||
'id',
|
||||
'email',
|
||||
'createdAt',
|
||||
'profileImagePath',
|
||||
'isAdmin',
|
||||
'shouldChangePassword',
|
||||
'deletedAt',
|
||||
'oauthId',
|
||||
'updatedAt',
|
||||
'storageLabel',
|
||||
'name',
|
||||
'quotaSizeInBytes',
|
||||
'quotaUsageInBytes',
|
||||
'status',
|
||||
'profileChangedAt',
|
||||
] as const;
|
||||
|
||||
type Upsert = Insertable<DbUserMetadata>;
|
||||
|
||||
@Injectable()
|
||||
export class UserRepository implements IUserRepository {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(UserMetadataEntity) private metadataRepository: Repository<UserMetadataEntity>,
|
||||
) {}
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> {
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
|
||||
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> {
|
||||
options = options || {};
|
||||
return this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
withDeleted: options.withDeleted,
|
||||
relations: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.select(withMetadata)
|
||||
.where('users.id', '=', userId)
|
||||
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
async getAdmin(): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { isAdmin: true } });
|
||||
getAdmin(): Promise<UserEntity | undefined> {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.where('users.isAdmin', '=', true)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
async hasAdmin(): Promise<boolean> {
|
||||
return this.userRepository.exists({ where: { isAdmin: true } });
|
||||
const admin = await this.db
|
||||
.selectFrom('users')
|
||||
.select('users.id')
|
||||
.where('users.isAdmin', '=', true)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return !!admin;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.EMAIL] })
|
||||
async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
|
||||
const builder = this.userRepository.createQueryBuilder('user').where({ email });
|
||||
|
||||
if (withPassword) {
|
||||
builder.addSelect('user.password');
|
||||
}
|
||||
|
||||
return builder.getOne();
|
||||
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.$if(!!withPassword, (eb) => eb.select('password'))
|
||||
.where('email', '=', email)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { storageLabel } });
|
||||
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.where('users.storageLabel', '=', storageLabel)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { oauthId } });
|
||||
getByOAuthId(oauthId: string): Promise<UserEntity | undefined> {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.where('users.oauthId', '=', oauthId)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
}
|
||||
|
||||
async getDeletedUsers(): Promise<UserEntity[]> {
|
||||
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
|
||||
getDeletedUsers(): Promise<UserEntity[]> {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.where('users.deletedAt', 'is not', null)
|
||||
.execute() as unknown as Promise<UserEntity[]>;
|
||||
}
|
||||
|
||||
async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
|
||||
return this.userRepository.find({
|
||||
withDeleted,
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
relations: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns)
|
||||
.select(withMetadata)
|
||||
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute() as unknown as Promise<UserEntity[]>;
|
||||
}
|
||||
|
||||
create(user: Partial<UserEntity>): Promise<UserEntity> {
|
||||
return this.save(user);
|
||||
async create(dto: Insertable<Users>): Promise<UserEntity> {
|
||||
return this.db
|
||||
.insertInto('users')
|
||||
.values(dto)
|
||||
.returning(columns)
|
||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
||||
}
|
||||
|
||||
// TODO change to (user: Partial<UserEntity>)
|
||||
update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
|
||||
return this.save({ ...user, id });
|
||||
update(id: string, dto: Updateable<Users>): Promise<UserEntity> {
|
||||
return this.db
|
||||
.updateTable('users')
|
||||
.set(dto)
|
||||
.where('users.id', '=', asUuid(id))
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.returning(columns)
|
||||
.returning(withMetadata)
|
||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
||||
}
|
||||
|
||||
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
|
||||
await this.metadataRepository.upsert({ userId: id, key, value }, { conflictPaths: { userId: true, key: true } });
|
||||
await this.db
|
||||
.insertInto('user_metadata')
|
||||
.values({ userId: id, key, value } as Upsert)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['userId', 'key']).doUpdateSet({
|
||||
key,
|
||||
value,
|
||||
} as Upsert),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteMetadata<T extends keyof UserMetadata>(id: string, key: T) {
|
||||
await this.metadataRepository.delete({ userId: id, key });
|
||||
await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
|
||||
}
|
||||
|
||||
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
|
||||
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
|
||||
delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
|
||||
return hard
|
||||
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>)
|
||||
: (this.db
|
||||
.updateTable('users')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', user.id)
|
||||
.execute() as unknown as Promise<UserEntity>);
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
async getUserStats(): Promise<UserStatsQueryResponse[]> {
|
||||
const stats = await this.userRepository
|
||||
.createQueryBuilder('users')
|
||||
.select('users.id', 'userId')
|
||||
.addSelect('users.name', 'userName')
|
||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
||||
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
|
||||
.addSelect(
|
||||
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`,
|
||||
'usagePhotos',
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`,
|
||||
'usageVideos',
|
||||
)
|
||||
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
|
||||
.leftJoin('users.assets', 'assets')
|
||||
.leftJoin('assets.exifInfo', 'exif')
|
||||
const stats = (await this.db
|
||||
.selectFrom('users')
|
||||
.leftJoin('assets', 'assets.ownerId', 'users.id')
|
||||
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
||||
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
|
||||
.select((eb) => [
|
||||
eb.fn
|
||||
.countAll()
|
||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', 'IMAGE'), eb('assets.isVisible', '=', true)]))
|
||||
.as('photos'),
|
||||
eb.fn
|
||||
.countAll()
|
||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', 'VIDEO'), eb('assets.isVisible', '=', true)]))
|
||||
.as('videos'),
|
||||
eb.fn
|
||||
.coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
|
||||
.as('usage'),
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb.fn
|
||||
.sum('exif.fileSizeInByte')
|
||||
.filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'IMAGE')])),
|
||||
eb.lit(0),
|
||||
)
|
||||
.as('usagePhotos'),
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb.fn
|
||||
.sum('exif.fileSizeInByte')
|
||||
.filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'VIDEO')])),
|
||||
eb.lit(0),
|
||||
)
|
||||
.as('usageVideos'),
|
||||
])
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.groupBy('users.id')
|
||||
.orderBy('users.createdAt', 'ASC')
|
||||
.getRawMany();
|
||||
.orderBy('users.createdAt', 'asc')
|
||||
.execute()) as UserStatsQueryResponse[];
|
||||
|
||||
for (const stat of stats) {
|
||||
stat.photos = Number(stat.photos);
|
||||
@@ -137,41 +222,31 @@ export class UserRepository implements IUserRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
|
||||
async updateUsage(id: string, delta: number): Promise<void> {
|
||||
await this.userRepository.increment({ id }, 'quotaUsageInBytes', delta);
|
||||
await this.db
|
||||
.updateTable('users')
|
||||
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${delta}`, updatedAt: new Date() })
|
||||
.where('id', '=', asUuid(id))
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async syncUsage(id?: string) {
|
||||
// we can't use parameters with getQuery, hence the template string
|
||||
const subQuery = this.assetRepository
|
||||
.createQueryBuilder('assets')
|
||||
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
|
||||
.leftJoin('assets.exifInfo', 'exif')
|
||||
.where('assets.ownerId = users.id')
|
||||
.andWhere(`assets.libraryId IS NULL`)
|
||||
.withDeleted();
|
||||
|
||||
const query = this.userRepository
|
||||
.createQueryBuilder('users')
|
||||
.leftJoin('users.assets', 'assets')
|
||||
.update()
|
||||
.set({ quotaUsageInBytes: () => `(${subQuery.getQuery()})` });
|
||||
|
||||
if (id) {
|
||||
query.where('users.id = :id', { id });
|
||||
}
|
||||
const query = this.db
|
||||
.updateTable('users')
|
||||
.set({
|
||||
quotaUsageInBytes: (eb) =>
|
||||
eb
|
||||
.selectFrom('assets')
|
||||
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage'))
|
||||
.where('assets.libraryId', 'is', null)
|
||||
.where('assets.ownerId', '=', eb.ref('users.id')),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.$if(id != undefined, (eb) => eb.where('users.id', '=', asUuid(id!)));
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
|
||||
private async save(user: Partial<UserEntity>) {
|
||||
const { id } = await this.userRepository.save(user);
|
||||
return this.userRepository.findOneOrFail({
|
||||
where: { id },
|
||||
withDeleted: true,
|
||||
relations: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user