0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-25 02:41:37 -05:00

refactor: migrate sessions repository to kysely (#15268)

* wip: search

* wip: getByToken

* wip: getByToken

* wip: getByUserId

* wip: create/update/delete

* remove unused code

* clean up and pr feedback

* fix: test

* fix: e2e test

* pr feedback
This commit is contained in:
Alex 2025-01-13 19:45:52 -06:00 committed by GitHub
parent 36eef9807b
commit 79726acc72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 185 additions and 96 deletions

View file

@ -129,6 +129,8 @@ describe('/users', () => {
expect(body).toEqual({ expect(body).toEqual({
...before, ...before,
updatedAt: expect.any(String), updatedAt: expect.any(String),
profileChangedAt: expect.any(String),
createdAt: expect.any(String),
name: 'Name', name: 'Name',
}); });
}); });
@ -177,6 +179,8 @@ describe('/users', () => {
...before, ...before,
email: 'non-admin@immich.cloud', email: 'non-admin@immich.cloud',
updatedAt: expect.anything(), updatedAt: expect.anything(),
createdAt: expect.anything(),
profileChangedAt: expect.anything(),
}); });
}); });
}); });

View file

@ -1,3 +1,5 @@
import { ExpressionBuilder } from 'kysely';
import { DB } from 'src/db';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@ -27,3 +29,37 @@ export class SessionEntity {
@Column({ default: '' }) @Column({ default: '' })
deviceOS!: string; deviceOS!: string;
} }
const userColumns = [
'id',
'email',
'createdAt',
'profileImagePath',
'isAdmin',
'shouldChangePassword',
'deletedAt',
'oauthId',
'updatedAt',
'storageLabel',
'name',
'quotaSizeInBytes',
'quotaUsageInBytes',
'status',
'profileChangedAt',
] as const;
export const withUser = (eb: ExpressionBuilder<DB, 'sessions'>) => {
return eb
.selectFrom('users')
.select(userColumns)
.select((eb) =>
eb
.selectFrom('user_metadata')
.whereRef('users.id', '=', 'user_metadata.userId')
.select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata'))
.as('metadata'),
)
.whereRef('users.id', '=', 'sessions.userId')
.where('users.deletedAt', 'is', null)
.as('user');
};

View file

@ -1,3 +1,5 @@
import { Insertable, Updateable } from 'kysely';
import { Sessions } from 'src/db';
import { SessionEntity } from 'src/entities/session.entity'; import { SessionEntity } from 'src/entities/session.entity';
export const ISessionRepository = 'ISessionRepository'; export const ISessionRepository = 'ISessionRepository';
@ -7,9 +9,9 @@ export type SessionSearchOptions = { updatedBefore: Date };
export interface ISessionRepository { export interface ISessionRepository {
search(options: SessionSearchOptions): Promise<SessionEntity[]>; search(options: SessionSearchOptions): Promise<SessionEntity[]>;
create<T extends Partial<E>>(dto: T): Promise<T>; create(dto: Insertable<Sessions>): Promise<SessionEntity>;
update<T extends Partial<E>>(dto: T): Promise<T>; update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
getByToken(token: string): Promise<E | null>; getByToken(token: string): Promise<E | undefined>;
getByUserId(userId: string): Promise<E[]>; getByUserId(userId: string): Promise<E[]>;
} }

View file

@ -1,64 +1,97 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.search -- SessionRepository.search
SELECT select
"SessionEntity"."id" AS "SessionEntity_id", *
"SessionEntity"."userId" AS "SessionEntity_userId", from
"SessionEntity"."createdAt" AS "SessionEntity_createdAt", "sessions"
"SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", where
"SessionEntity"."deviceType" AS "SessionEntity_deviceType", "sessions"."updatedAt" <= $1
"SessionEntity"."deviceOS" AS "SessionEntity_deviceOS"
FROM
"sessions" "SessionEntity"
WHERE
(("SessionEntity"."updatedAt" <= $1))
-- SessionRepository.getByToken -- SessionRepository.getByToken
SELECT DISTINCT select
"distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id" "sessions".*,
FROM to_json("user") as "user"
( from
SELECT "sessions"
"SessionEntity"."id" AS "SessionEntity_id", inner join lateral (
"SessionEntity"."userId" AS "SessionEntity_userId", select
"SessionEntity"."createdAt" AS "SessionEntity_createdAt", "id",
"SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", "email",
"SessionEntity"."deviceType" AS "SessionEntity_deviceType", "createdAt",
"SessionEntity"."deviceOS" AS "SessionEntity_deviceOS", "profileImagePath",
"SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id", "isAdmin",
"SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name", "shouldChangePassword",
"SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin", "deletedAt",
"SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email", "oauthId",
"SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel", "updatedAt",
"SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId", "storageLabel",
"SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath", "name",
"SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword", "quotaSizeInBytes",
"SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt", "quotaUsageInBytes",
"SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt", "status",
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", "profileChangedAt",
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", (
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", select
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes", array_agg("user_metadata") as "metadata"
"SessionEntity__SessionEntity_user"."profileChangedAt" AS "SessionEntity__SessionEntity_user_profileChangedAt", from
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId", "user_metadata"
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key", where
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value" "users"."id" = "user_metadata"."userId"
FROM ) as "metadata"
"sessions" "SessionEntity" from
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId" "users"
AND ( where
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL "users"."id" = "sessions"."userId"
) and "users"."deletedAt" is null
LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id" ) as "user" on true
WHERE where
(("SessionEntity"."token" = $1)) "sessions"."token" = $1
) "distinctAlias"
ORDER BY -- SessionRepository.getByUserId
"SessionEntity_id" ASC select
LIMIT "sessions".*,
1 to_json("user") as "user"
from
"sessions"
inner join lateral (
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
(
select
array_agg("user_metadata") as "metadata"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as "metadata"
from
"users"
where
"users"."id" = "sessions"."userId"
and "users"."deletedAt" is null
) as "user" on true
where
"sessions"."userId" = $1
order by
"sessions"."updatedAt" desc,
"sessions"."createdAt" desc
-- SessionRepository.delete -- SessionRepository.delete
DELETE FROM "sessions" delete from "sessions"
WHERE where
"id" = $1 "id" = $1::uuid

View file

@ -1,56 +1,70 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB, Sessions } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { SessionEntity } from 'src/entities/session.entity'; import { SessionEntity, withUser } from 'src/entities/session.entity';
import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface'; import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface';
import { LessThanOrEqual, Repository } from 'typeorm'; import { asUuid } from 'src/utils/database';
@Injectable() @Injectable()
export class SessionRepository implements ISessionRepository { export class SessionRepository implements ISessionRepository {
constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.DATE] }) @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions): Promise<SessionEntity[]> { search(options: SessionSearchOptions): Promise<SessionEntity[]> {
return this.repository.find({ where: { updatedAt: LessThanOrEqual(options.updatedBefore) } }); return this.db
.selectFrom('sessions')
.selectAll()
.where('sessions.updatedAt', '<=', options.updatedBefore)
.execute() as Promise<SessionEntity[]>;
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<SessionEntity | null> { getByToken(token: string): Promise<SessionEntity | undefined> {
return this.repository.findOne({ return this.db
where: { token }, .selectFrom('sessions')
relations: { .innerJoinLateral(withUser, (join) => join.onTrue())
user: { .selectAll('sessions')
metadata: true, .select((eb) => eb.fn.toJson('user').as('user'))
}, .where('sessions.token', '=', token)
}, .executeTakeFirst() as Promise<SessionEntity | undefined>;
});
} }
@GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string): Promise<SessionEntity[]> { getByUserId(userId: string): Promise<SessionEntity[]> {
return this.repository.find({ return this.db
where: { .selectFrom('sessions')
userId, .innerJoinLateral(withUser, (join) => join.onTrue())
}, .selectAll('sessions')
relations: { .select((eb) => eb.fn.toJson('user').as('user'))
user: true, .where('sessions.userId', '=', userId)
}, .orderBy('sessions.updatedAt', 'desc')
order: { .orderBy('sessions.createdAt', 'desc')
updatedAt: 'desc', .execute() as unknown as Promise<SessionEntity[]>;
createdAt: 'desc',
},
});
} }
create<T extends Partial<SessionEntity>>(dto: T): Promise<T & { id: string }> { async create(dto: Insertable<Sessions>): Promise<SessionEntity> {
return this.repository.save(dto); const { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } = await this.db
.insertInto('sessions')
.values(dto)
.returningAll()
.executeTakeFirstOrThrow();
return { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } as SessionEntity;
} }
update<T extends Partial<SessionEntity>>(dto: T): Promise<T> { update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity> {
return this.repository.save(dto); return this.db
.updateTable('sessions')
.set(dto)
.where('sessions.id', '=', asUuid(id))
.returningAll()
.executeTakeFirstOrThrow() as Promise<SessionEntity>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.repository.delete({ id }); await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
} }
} }

View file

@ -354,7 +354,7 @@ describe('AuthService', () => {
describe('validate - user token', () => { describe('validate - user token', () => {
it('should throw if no token is found', async () => { it('should throw if no token is found', async () => {
sessionMock.getByToken.mockResolvedValue(null); sessionMock.getByToken.mockResolvedValue(void 0);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-user-token': 'auth_token' }, headers: { 'x-immich-user-token': 'auth_token' },
@ -399,7 +399,7 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); expect(sessionMock.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
}); });
}); });

View file

@ -331,7 +331,7 @@ export class AuthService extends BaseService {
const updatedAt = DateTime.fromJSDate(session.updatedAt); const updatedAt = DateTime.fromJSDate(session.updatedAt);
const diff = now.diff(updatedAt, ['hours']); const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) { if (diff.hours > 1) {
await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
} }
return { user: session.user, session }; return { user: session.user, session };
@ -346,9 +346,9 @@ export class AuthService extends BaseService {
await this.sessionRepository.create({ await this.sessionRepository.create({
token, token,
user,
deviceOS: loginDetails.deviceOS, deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType, deviceType: loginDetails.deviceType,
userId: user.id,
}); });
return mapLoginResponse(user, key); return mapLoginResponse(user, key);