Skip to content

Commit

Permalink
refactor: migrate shared-link repository to kysely (#15289)
Browse files Browse the repository at this point in the history
* refactor: migrate shared-link repository to kysely

* fix duplicate individual shared link return in getAll when there are more than 1 asset in the shared link

* using correct order condition

* using eb.table

---------

Co-authored-by: Alex Tran <[email protected]>
  • Loading branch information
danieldietzler and alextran1502 authored Jan 18, 2025
1 parent 430d0b8 commit 3d13da7
Show file tree
Hide file tree
Showing 7 changed files with 435 additions and 404 deletions.
6 changes: 3 additions & 3 deletions e2e/src/api/specs/shared-link.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('/shared-links', () => {
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
album,
album: expect.objectContaining({ id: album.id }),
userId: user1.userId,
type: SharedLinkType.Album,
}),
Expand Down Expand Up @@ -208,7 +208,7 @@ describe('/shared-links', () => {
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
album,
album: expect.objectContaining({ id: album.id }),
userId: user1.userId,
type: SharedLinkType.Album,
}),
Expand Down Expand Up @@ -262,7 +262,7 @@ describe('/shared-links', () => {
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
album,
album: expect.objectContaining({ id: album.id }),
userId: user1.userId,
type: SharedLinkType.Album,
}),
Expand Down
10 changes: 6 additions & 4 deletions server/src/interfaces/shared-link.interface.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Insertable, Updateable } from 'kysely';
import { SharedLinks } from 'src/db';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';

export const ISharedLinkRepository = 'ISharedLinkRepository';

export interface ISharedLinkRepository {
getAll(userId: string): Promise<SharedLinkEntity[]>;
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
getByKey(key: Buffer): Promise<SharedLinkEntity | null>;
create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
get(userId: string, id: string): Promise<SharedLinkEntity | undefined>;
getByKey(key: Buffer): Promise<SharedLinkEntity | undefined>;
create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity>;
update(entity: Updateable<SharedLinks> & { id: string; assetIds?: string[] }): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<void>;
}
510 changes: 188 additions & 322 deletions server/src/queries/shared.link.repository.sql

Large diffs are not rendered by default.

292 changes: 229 additions & 63 deletions server/src/repositories/shared-link.repository.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,256 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { DB, SharedLinks } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SharedLinkType } from 'src/enum';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { Repository } from 'typeorm';

@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
constructor(@InjectRepository(SharedLinkEntity) private repository: Repository<SharedLinkEntity>) {}
constructor(@InjectKysely() private db: Kysely<DB>) {}

@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
assets: {
exifInfo: true,
},
album: {
assets: {
exifInfo: true,
},
owner: true,
},
},
order: {
createdAt: 'DESC',
assets: {
fileCreatedAt: 'ASC',
},
album: {
assets: {
fileCreatedAt: 'ASC',
},
},
},
});
get(userId: string, id: string): Promise<SharedLinkEntity | undefined> {
return this.db
.selectFrom('shared_links')
.selectAll('shared_links')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('shared_link__asset')
.whereRef('shared_links.id', '=', 'shared_link__asset.sharedLinksId')
.innerJoin('assets', 'assets.id', 'shared_link__asset.assetsId')
.where('assets.deletedAt', 'is', null)
.selectAll('assets')
.innerJoinLateral(
(eb) => eb.selectFrom('exif').selectAll('exif').whereRef('exif.assetId', '=', 'assets.id').as('exifInfo'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.orderBy('assets.fileCreatedAt', 'asc')
.as('a'),
(join) => join.onTrue(),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('albums')
.selectAll('albums')
.whereRef('albums.id', '=', 'shared_links.albumId')
.where('albums.deletedAt', 'is', null)
.leftJoin('albums_assets_assets', 'albums_assets_assets.albumsId', 'albums.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets')
.selectAll('assets')
.whereRef('albums_assets_assets.assetsId', '=', 'assets.id')
.where('assets.deletedAt', 'is', null)
.innerJoinLateral(
(eb) =>
eb
.selectFrom('exif')
.selectAll('exif')
.whereRef('exif.assetId', '=', 'assets.id')
.as('assets_exifInfo'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson(eb.table('assets_exifInfo')).as('exifInfo'))
.orderBy('assets.fileCreatedAt', 'asc')
.as('assets'),
(join) => join.onTrue(),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom('users')
.selectAll('users')
.whereRef('users.id', '=', 'albums.ownerId')
.where('users.deletedAt', 'is', null)
.as('owner'),
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
)
.select((eb) => eb.fn.toJson('owner').as('owner'))
.groupBy(['albums.id', sql`"owner".*`])
.as('album'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`).as('assets'))
.groupBy(['shared_links.id', sql`"album".*`])
.select((eb) => eb.fn.toJson('album').as('album'))
.where('shared_links.id', '=', id)
.where('shared_links.userId', '=', userId)
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
.orderBy('shared_links.createdAt', 'desc')
.executeTakeFirst() as Promise<SharedLinkEntity | undefined>;
}

@GenerateSql({ params: [DummyValue.UUID] })
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
assets: true,
album: {
owner: true,
},
},
order: {
createdAt: 'DESC',
},
});
return this.db
.selectFrom('shared_links')
.selectAll('shared_links')
.where('shared_links.userId', '=', userId)
.leftJoin('shared_link__asset', 'shared_link__asset.sharedLinksId', 'shared_links.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets')
.whereRef('assets.id', '=', 'shared_link__asset.assetsId')
.where('assets.deletedAt', 'is', null)
.selectAll('assets')
.as('assets'),
(join) => join.onTrue(),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('albums')
.selectAll('albums')
.whereRef('albums.id', '=', 'shared_links.albumId')
.innerJoinLateral(
(eb) =>
eb
.selectFrom('users')
.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',
])
.whereRef('users.id', '=', 'albums.ownerId')
.where('users.deletedAt', 'is', null)
.as('owner'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('owner').as('owner'))
.where('albums.deletedAt', 'is', null)
.as('album'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('album').as('album'))
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
.orderBy('shared_links.createdAt', 'desc')
.distinctOn(['shared_links.createdAt'])
.execute() as unknown as Promise<SharedLinkEntity[]>;
}

@GenerateSql({ params: [DummyValue.BUFFER] })
async getByKey(key: Buffer): Promise<SharedLinkEntity | null> {
return await this.repository.findOne({
where: {
key,
},
relations: {
user: true,
},
});
async getByKey(key: Buffer): Promise<SharedLinkEntity | undefined> {
return this.db
.selectFrom('shared_links')
.selectAll('shared_links')
.where('shared_links.key', '=', key)
.leftJoin('albums', 'albums.id', 'shared_links.albumId')
.where('albums.deletedAt', 'is', null)
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('users')
.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',
])
.whereRef('users.id', '=', 'shared_links.userId'),
).as('user'),
)
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('albums.id', 'is not', null)]))
.executeTakeFirst() as Promise<SharedLinkEntity | undefined>;
}

create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity> {
const { id } = await this.db
.insertInto('shared_links')
.values(_.omit(entity, 'assetIds'))
.returningAll()
.executeTakeFirstOrThrow();

if (entity.assetIds && entity.assetIds.length > 0) {
await this.db
.insertInto('shared_link__asset')
.values(entity.assetIds!.map((assetsId) => ({ assetsId, sharedLinksId: id })))
.execute();
}

return this.getSharedLinks(id);
}

update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
async update(entity: Updateable<SharedLinks> & { id: string; assetIds?: string[] }): Promise<SharedLinkEntity> {
const { id } = await this.db
.updateTable('shared_links')
.set(_.omit(entity, 'assets', 'album', 'assetIds'))
.where('shared_links.id', '=', entity.id)
.returningAll()
.executeTakeFirstOrThrow();

if (entity.assetIds && entity.assetIds.length > 0) {
await this.db
.insertInto('shared_link__asset')
.values(entity.assetIds!.map((assetsId) => ({ assetsId, sharedLinksId: id })))
.execute();
}

return this.getSharedLinks(id);
}

async remove(entity: SharedLinkEntity): Promise<void> {
await this.repository.remove(entity);
await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute();
}

private async save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
private getSharedLinks(id: string) {
return this.db
.selectFrom('shared_links')
.selectAll('shared_links')
.where('shared_links.id', '=', id)
.leftJoin('shared_link__asset', 'shared_link__asset.sharedLinksId', 'shared_links.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets')
.whereRef('assets.id', '=', 'shared_link__asset.assetsId')
.selectAll('assets')
.innerJoinLateral(
(eb) => eb.selectFrom('exif').whereRef('exif.assetId', '=', 'assets.id').selectAll().as('exif'),
(join) => join.onTrue(),
)
.as('assets'),
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
)
.groupBy('shared_links.id')
.executeTakeFirstOrThrow() as Promise<SharedLinkEntity>;
}
}
1 change: 0 additions & 1 deletion server/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ describe('AuthService', () => {

describe('validate - shared key', () => {
it('should not accept a non-existent key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(null);
await expect(
sut.authenticate({
headers: { 'x-immich-share-key': 'key' },
Expand Down
Loading

0 comments on commit 3d13da7

Please sign in to comment.