feat: improve performance for GET /api/album & /api/album/:id (#17124)

* fix(server) optimize number of sql calls for GET /api/albums

remove unnecessary join for getMetadataForIds
remove separate call to getLastUpdatedAssetForAlbumId

* fix(server) remove unnecessary getLastUpdatedAssetForAlbumId call for GET /api/album/:id

also remove getLastUpdatedAssetForAlbumId query as it is no longer referenced

* fix(server): correct lastModifiedAssetTimestamp return type + formatting and typing

* chore(server): address type issue with tests found via npm:check

tests & lint still pass before this commit.
This commit is contained in:
PathToLife 2025-04-01 00:28:41 +13:00 committed by GitHub
parent 238c151ac3
commit 09f4476f97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 71 additions and 51 deletions

View File

@ -201,19 +201,23 @@ order by
-- AlbumRepository.getMetadataForIds -- AlbumRepository.getMetadataForIds
select select
"albums"."id" as "albumId", "album_assets"."albumsId" as "albumId",
min("assets"."localDateTime") as "startDate", min(
max("assets"."localDateTime") as "endDate", ("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date
) as "startDate",
max(
("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date
) as "endDate",
max("assets"."updatedAt") as "lastModifiedAssetTimestamp",
count("assets"."id")::int as "assetCount" count("assets"."id")::int as "assetCount"
from from
"albums" "assets"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id"
inner join "assets" on "assets"."id" = "album_assets"."assetsId"
where where
"albums"."id" in ($1) "album_assets"."albumsId" in ($1)
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
group by group by
"albums"."id" "album_assets"."albumsId"
-- AlbumRepository.getOwned -- AlbumRepository.getOwned
select select

View File

@ -12,6 +12,7 @@ export interface AlbumAssetCount {
assetCount: number; assetCount: number;
startDate: Date | null; startDate: Date | null;
endDate: Date | null; endDate: Date | null;
lastModifiedAssetTimestamp: Date | null;
} }
export interface AlbumInfoOptions { export interface AlbumInfoOptions {
@ -132,18 +133,21 @@ export class AlbumRepository {
return []; return [];
} }
return this.db return (
.selectFrom('albums') this.db
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') .selectFrom('assets')
.innerJoin('assets', 'assets.id', 'album_assets.assetsId') .innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
.select('albums.id as albumId') .select('album_assets.albumsId as albumId')
.select((eb) => eb.fn.min('assets.localDateTime').as('startDate')) .select((eb) => eb.fn.min(sql<Date>`("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate'))
.select((eb) => eb.fn.max('assets.localDateTime').as('endDate')) .select((eb) => eb.fn.max(sql<Date>`("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('endDate'))
.select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount')) // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
.where('albums.id', 'in', ids) .select((eb) => eb.fn.max('assets.updatedAt').as('lastModifiedAssetTimestamp'))
.where('assets.deletedAt', 'is', null) .select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount'))
.groupBy('albums.id') .where('album_assets.albumsId', 'in', ids)
.execute(); .where('assets.deletedAt', 'is', null)
.groupBy('album_assets.albumsId')
.execute()
);
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })

View File

@ -728,17 +728,6 @@ export class AssetRepository {
return paginationHelper(items as any as AssetEntity[], pagination.take); return paginationHelper(items as any as AssetEntity[], pagination.take);
} }
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | undefined> {
return this.db
.selectFrom('assets')
.selectAll('assets')
.innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId')
.where('albums_assets_assets.albumsId', '=', asUuid(albumId))
.orderBy('updatedAt', 'desc')
.limit(1)
.executeTakeFirst() as Promise<AssetEntity | undefined>;
}
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> { getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')

View File

@ -41,8 +41,20 @@ describe(AlbumService.name, () => {
it('gets list of albums for auth user', async () => { it('gets list of albums for auth user', async () => {
mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, {
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, albumId: albumStub.empty.id,
assetCount: 0,
startDate: null,
endDate: null,
lastModifiedAssetTimestamp: null,
},
{
albumId: albumStub.sharedWithUser.id,
assetCount: 0,
startDate: null,
endDate: null,
lastModifiedAssetTimestamp: null,
},
]); ]);
const result = await sut.getAll(authStub.admin, {}); const result = await sut.getAll(authStub.admin, {});
@ -59,6 +71,7 @@ describe(AlbumService.name, () => {
assetCount: 1, assetCount: 1,
startDate: new Date('1970-01-01'), startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'),
lastModifiedAssetTimestamp: new Date('1970-01-01'),
}, },
]); ]);
@ -71,7 +84,13 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => { it('gets list of albums that are shared', async () => {
mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, {
albumId: albumStub.sharedWithUser.id,
assetCount: 0,
startDate: null,
endDate: null,
lastModifiedAssetTimestamp: null,
},
]); ]);
const result = await sut.getAll(authStub.admin, { shared: true }); const result = await sut.getAll(authStub.admin, { shared: true });
@ -83,7 +102,13 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => { it('gets list of albums that are NOT shared', async () => {
mocks.album.getNotShared.mockResolvedValue([albumStub.empty]); mocks.album.getNotShared.mockResolvedValue([albumStub.empty]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, {
albumId: albumStub.empty.id,
assetCount: 0,
startDate: null,
endDate: null,
lastModifiedAssetTimestamp: null,
},
]); ]);
const result = await sut.getAll(authStub.admin, { shared: false }); const result = await sut.getAll(authStub.admin, { shared: false });
@ -101,6 +126,7 @@ describe(AlbumService.name, () => {
assetCount: 1, assetCount: 1,
startDate: new Date('1970-01-01'), startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'),
lastModifiedAssetTimestamp: new Date('1970-01-01'),
}, },
]); ]);
@ -447,6 +473,7 @@ describe(AlbumService.name, () => {
assetCount: 1, assetCount: 1,
startDate: new Date('1970-01-01'), startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'),
lastModifiedAssetTimestamp: new Date('1970-01-01'),
}, },
]); ]);
@ -468,6 +495,7 @@ describe(AlbumService.name, () => {
assetCount: 1, assetCount: 1,
startDate: new Date('1970-01-01'), startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'),
lastModifiedAssetTimestamp: new Date('1970-01-01'),
}, },
]); ]);
@ -489,6 +517,7 @@ describe(AlbumService.name, () => {
assetCount: 1, assetCount: 1,
startDate: new Date('1970-01-01'), startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'), endDate: new Date('1970-01-01'),
lastModifiedAssetTimestamp: new Date('1970-01-01'),
}, },
]); ]);

View File

@ -58,19 +58,15 @@ export class AlbumService extends BaseService {
albumMetadata[metadata.albumId] = metadata; albumMetadata[metadata.albumId] = metadata;
} }
return Promise.all( return albums.map((album) => ({
albums.map(async (album) => { ...mapAlbumWithoutAssets(album),
const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); sharedLinks: undefined,
return { startDate: albumMetadata[album.id]?.startDate ?? undefined,
...mapAlbumWithoutAssets(album), endDate: albumMetadata[album.id]?.endDate ?? undefined,
sharedLinks: undefined, assetCount: albumMetadata[album.id]?.assetCount ?? 0,
startDate: albumMetadata[album.id]?.startDate ?? undefined, // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
endDate: albumMetadata[album.id]?.endDate ?? undefined, lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0, }));
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}),
);
} }
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> { async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
@ -79,14 +75,13 @@ export class AlbumService extends BaseService {
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets }); const album = await this.findOrFail(id, { withAssets });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]); const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id);
return { return {
...mapAlbum(album, withAssets, auth), ...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds?.startDate ?? undefined, startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined, endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds?.assetCount ?? 0, assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined,
}; };
} }

View File

@ -21,7 +21,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getByChecksums: vitest.fn(), getByChecksums: vitest.fn(),
getUploadAssetIdByChecksum: vitest.fn(), getUploadAssetIdByChecksum: vitest.fn(),
getRandom: vitest.fn(), getRandom: vitest.fn(),
getLastUpdatedAssetForAlbumId: vitest.fn(),
getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
getAllByDeviceId: vitest.fn(), getAllByDeviceId: vitest.fn(),
getLivePhotoCount: vitest.fn(), getLivePhotoCount: vitest.fn(),