From fa9e432cb6895f4622bb444b620325669dac0f67 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 13 Jan 2025 19:45:52 -0600 Subject: [PATCH] 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 --- e2e/src/api/specs/user.e2e-spec.ts | 4 + server/src/entities/session.entity.ts | 36 +++++ server/src/interfaces/session.interface.ts | 8 +- server/src/queries/session.repository.sql | 147 +++++++++++------- server/src/repositories/session.repository.ts | 78 ++++++---- server/src/services/auth.service.spec.ts | 4 +- server/src/services/auth.service.ts | 4 +- 7 files changed, 185 insertions(+), 96 deletions(-) diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 1964dc67936421..9cffa5d754d1f7 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -129,6 +129,8 @@ describe('/users', () => { expect(body).toEqual({ ...before, updatedAt: expect.any(String), + profileChangedAt: expect.any(String), + createdAt: expect.any(String), name: 'Name', }); }); @@ -177,6 +179,8 @@ describe('/users', () => { ...before, email: 'non-admin@immich.cloud', updatedAt: expect.anything(), + createdAt: expect.anything(), + profileChangedAt: expect.anything(), }); }); }); diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts index 1cc9ad98572ab2..e21c6d52ba4691 100644 --- a/server/src/entities/session.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,3 +1,5 @@ +import { ExpressionBuilder } from 'kysely'; +import { DB } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @@ -27,3 +29,37 @@ export class SessionEntity { @Column({ default: '' }) 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) => { + 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'); +}; diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts index 33b48045a23764..8d695fbfc29c0a 100644 --- a/server/src/interfaces/session.interface.ts +++ b/server/src/interfaces/session.interface.ts @@ -1,3 +1,5 @@ +import { Insertable, Updateable } from 'kysely'; +import { Sessions } from 'src/db'; import { SessionEntity } from 'src/entities/session.entity'; export const ISessionRepository = 'ISessionRepository'; @@ -7,9 +9,9 @@ export type SessionSearchOptions = { updatedBefore: Date }; export interface ISessionRepository { search(options: SessionSearchOptions): Promise; - create>(dto: T): Promise; - update>(dto: T): Promise; + create(dto: Insertable): Promise; + update(id: string, dto: Updateable): Promise; delete(id: string): Promise; - getByToken(token: string): Promise; + getByToken(token: string): Promise; getByUserId(userId: string): Promise; } diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 2f0613b4d03989..b928195e720092 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -1,64 +1,97 @@ -- NOTE: This file is auto generated by ./sql-generator -- SessionRepository.search -SELECT - "SessionEntity"."id" AS "SessionEntity_id", - "SessionEntity"."userId" AS "SessionEntity_userId", - "SessionEntity"."createdAt" AS "SessionEntity_createdAt", - "SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", - "SessionEntity"."deviceType" AS "SessionEntity_deviceType", - "SessionEntity"."deviceOS" AS "SessionEntity_deviceOS" -FROM - "sessions" "SessionEntity" -WHERE - (("SessionEntity"."updatedAt" <= $1)) +select + * +from + "sessions" +where + "sessions"."updatedAt" <= $1 -- SessionRepository.getByToken -SELECT DISTINCT - "distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id" -FROM - ( - SELECT - "SessionEntity"."id" AS "SessionEntity_id", - "SessionEntity"."userId" AS "SessionEntity_userId", - "SessionEntity"."createdAt" AS "SessionEntity_createdAt", - "SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", - "SessionEntity"."deviceType" AS "SessionEntity_deviceType", - "SessionEntity"."deviceOS" AS "SessionEntity_deviceOS", - "SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id", - "SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name", - "SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin", - "SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email", - "SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel", - "SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId", - "SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath", - "SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword", - "SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt", - "SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt", - "SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", - "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", - "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", - "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes", - "SessionEntity__SessionEntity_user"."profileChangedAt" AS "SessionEntity__SessionEntity_user_profileChangedAt", - "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId", - "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key", - "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value" - FROM - "sessions" "SessionEntity" - LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId" - AND ( - "SessionEntity__SessionEntity_user"."deletedAt" IS NULL - ) - LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id" - WHERE - (("SessionEntity"."token" = $1)) - ) "distinctAlias" -ORDER BY - "SessionEntity_id" ASC -LIMIT - 1 +select + "sessions".*, + 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"."token" = $1 + +-- SessionRepository.getByUserId +select + "sessions".*, + 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 -DELETE FROM "sessions" -WHERE - "id" = $1 +delete from "sessions" +where + "id" = $1::uuid diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 3a0af1ef69d0f3..3e6c8977212a7c 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,56 +1,70 @@ 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 { SessionEntity } from 'src/entities/session.entity'; +import { SessionEntity, withUser } from 'src/entities/session.entity'; import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface'; -import { LessThanOrEqual, Repository } from 'typeorm'; +import { asUuid } from 'src/utils/database'; @Injectable() export class SessionRepository implements ISessionRepository { - constructor(@InjectRepository(SessionEntity) private repository: Repository) {} + constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.DATE] }) + @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) search(options: SessionSearchOptions): Promise { - return this.repository.find({ where: { updatedAt: LessThanOrEqual(options.updatedBefore) } }); + return this.db + .selectFrom('sessions') + .selectAll() + .where('sessions.updatedAt', '<=', options.updatedBefore) + .execute() as Promise; } @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string): Promise { - return this.repository.findOne({ - where: { token }, - relations: { - user: { - metadata: true, - }, - }, - }); + getByToken(token: string): Promise { + return this.db + .selectFrom('sessions') + .innerJoinLateral(withUser, (join) => join.onTrue()) + .selectAll('sessions') + .select((eb) => eb.fn.toJson('user').as('user')) + .where('sessions.token', '=', token) + .executeTakeFirst() as Promise; } + @GenerateSql({ params: [DummyValue.UUID] }) getByUserId(userId: string): Promise { - return this.repository.find({ - where: { - userId, - }, - relations: { - user: true, - }, - order: { - updatedAt: 'desc', - createdAt: 'desc', - }, - }); + return this.db + .selectFrom('sessions') + .innerJoinLateral(withUser, (join) => join.onTrue()) + .selectAll('sessions') + .select((eb) => eb.fn.toJson('user').as('user')) + .where('sessions.userId', '=', userId) + .orderBy('sessions.updatedAt', 'desc') + .orderBy('sessions.createdAt', 'desc') + .execute() as unknown as Promise; } - create>(dto: T): Promise { - return this.repository.save(dto); + async create(dto: Insertable): Promise { + 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>(dto: T): Promise { - return this.repository.save(dto); + update(id: string, dto: Updateable): Promise { + return this.db + .updateTable('sessions') + .set(dto) + .where('sessions.id', '=', asUuid(id)) + .returningAll() + .executeTakeFirstOrThrow() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) async delete(id: string): Promise { - await this.repository.delete({ id }); + await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 6494a735b191f0..da25663f38750d 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -354,7 +354,7 @@ describe('AuthService', () => { describe('validate - user token', () => { it('should throw if no token is found', async () => { - sessionMock.getByToken.mockResolvedValue(null); + sessionMock.getByToken.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-immich-user-token': 'auth_token' }, @@ -399,7 +399,7 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).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) }); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 29b73954650cfa..9999c16f64ba23 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -331,7 +331,7 @@ export class AuthService extends BaseService { const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); 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 }; @@ -346,9 +346,9 @@ export class AuthService extends BaseService { await this.sessionRepository.create({ token, - user, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, + userId: user.id, }); return mapLoginResponse(user, key);