Skip to content

Commit

Permalink
refactor: migrate sessions repository to kysely (#15268)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
alextran1502 authored Jan 14, 2025
1 parent 36eef98 commit 79726ac
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 96 deletions.
4 changes: 4 additions & 0 deletions e2e/src/api/specs/user.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ describe('/users', () => {
expect(body).toEqual({
...before,
updatedAt: expect.any(String),
profileChangedAt: expect.any(String),
createdAt: expect.any(String),
name: 'Name',
});
});
Expand Down Expand Up @@ -177,6 +179,8 @@ describe('/users', () => {
...before,
email: '[email protected]',
updatedAt: expect.anything(),
createdAt: expect.anything(),
profileChangedAt: expect.anything(),
});
});
});
Expand Down
36 changes: 36 additions & 0 deletions server/src/entities/session.entity.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<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');
};
8 changes: 5 additions & 3 deletions server/src/interfaces/session.interface.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,9 +9,9 @@ export type SessionSearchOptions = { updatedBefore: Date };

export interface ISessionRepository {
search(options: SessionSearchOptions): Promise<SessionEntity[]>;
create<T extends Partial<E>>(dto: T): Promise<T>;
update<T extends Partial<E>>(dto: T): Promise<T>;
create(dto: Insertable<Sessions>): Promise<SessionEntity>;
update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<E | null>;
getByToken(token: string): Promise<E | undefined>;
getByUserId(userId: string): Promise<E[]>;
}
147 changes: 90 additions & 57 deletions server/src/queries/session.repository.sql
Original file line number Diff line number Diff line change
@@ -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
78 changes: 46 additions & 32 deletions server/src/repositories/session.repository.ts
Original file line number Diff line number Diff line change
@@ -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<SessionEntity>) {}
constructor(@InjectKysely() private db: Kysely<DB>) {}

@GenerateSql({ params: [DummyValue.DATE] })
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
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] })
getByToken(token: string): Promise<SessionEntity | null> {
return this.repository.findOne({
where: { token },
relations: {
user: {
metadata: true,
},
},
});
getByToken(token: string): Promise<SessionEntity | undefined> {
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<SessionEntity | undefined>;
}

@GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string): Promise<SessionEntity[]> {
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<SessionEntity[]>;
}

create<T extends Partial<SessionEntity>>(dto: T): Promise<T & { id: string }> {
return this.repository.save(dto);
async create(dto: Insertable<Sessions>): Promise<SessionEntity> {
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> {
return this.repository.save(dto);
update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity> {
return this.db
.updateTable('sessions')
.set(dto)
.where('sessions.id', '=', asUuid(id))
.returningAll()
.executeTakeFirstOrThrow() as Promise<SessionEntity>;
}

@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
}
}
4 changes: 2 additions & 2 deletions server/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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) });
});
});

Expand Down
4 changes: 2 additions & 2 deletions server/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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);
Expand Down

0 comments on commit 79726ac

Please sign in to comment.