refactor(server): view repository (#12755)

This commit is contained in:
Jason Rasmussen 2024-09-17 16:32:09 -04:00 committed by GitHub
parent f53e4721cf
commit 1e6ef5c9e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 159 additions and 166 deletions

View File

@ -147,8 +147,6 @@ export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffli
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
getUniqueOriginalPaths(userId: string): Promise<string[]>;
create(asset: AssetCreate): Promise<AssetEntity>;
getByIds(
ids: string[],

View File

@ -0,0 +1,8 @@
import { AssetEntity } from 'src/entities/asset.entity';
export const IViewRepository = 'IViewRepository';
export interface IViewRepository {
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
getUniqueOriginalPaths(userId: string): Promise<string[]>;
}

View File

@ -1134,109 +1134,6 @@ WHERE
AND "asset"."ownerId" IN ($1)
AND "asset"."updatedAt" > $2
-- AssetRepository.getAssetsByOriginalPath
SELECT
"asset"."id" AS "asset_id",
"asset"."deviceAssetId" AS "asset_deviceAssetId",
"asset"."ownerId" AS "asset_ownerId",
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"asset"."createdAt" AS "asset_createdAt",
"asset"."updatedAt" AS "asset_updatedAt",
"asset"."deletedAt" AS "asset_deletedAt",
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"asset"."localDateTime" AS "asset_localDateTime",
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"asset"."isFavorite" AS "asset_isFavorite",
"asset"."isArchived" AS "asset_isArchived",
"asset"."isExternal" AS "asset_isExternal",
"asset"."isOffline" AS "asset_isOffline",
"asset"."checksum" AS "asset_checksum",
"asset"."duration" AS "asset_duration",
"asset"."isVisible" AS "asset_isVisible",
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"asset"."originalFileName" AS "asset_originalFileName",
"asset"."sidecarPath" AS "asset_sidecarPath",
"asset"."stackId" AS "asset_stackId",
"asset"."duplicateId" AS "asset_duplicateId",
"exifInfo"."assetId" AS "exifInfo_assetId",
"exifInfo"."description" AS "exifInfo_description",
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"exifInfo"."orientation" AS "exifInfo_orientation",
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"exifInfo"."timeZone" AS "exifInfo_timeZone",
"exifInfo"."latitude" AS "exifInfo_latitude",
"exifInfo"."longitude" AS "exifInfo_longitude",
"exifInfo"."projectionType" AS "exifInfo_projectionType",
"exifInfo"."city" AS "exifInfo_city",
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"exifInfo"."state" AS "exifInfo_state",
"exifInfo"."country" AS "exifInfo_country",
"exifInfo"."make" AS "exifInfo_make",
"exifInfo"."model" AS "exifInfo_model",
"exifInfo"."lensModel" AS "exifInfo_lensModel",
"exifInfo"."fNumber" AS "exifInfo_fNumber",
"exifInfo"."focalLength" AS "exifInfo_focalLength",
"exifInfo"."iso" AS "exifInfo_iso",
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId",
"stack"."primaryAssetId" AS "stack_primaryAssetId",
"stackedAssets"."id" AS "stackedAssets_id",
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
"stackedAssets"."checksum" AS "stackedAssets_checksum",
"stackedAssets"."duration" AS "stackedAssets_duration",
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
"stackedAssets"."stackId" AS "stackedAssets_stackId",
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
FROM
"assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND (
"asset"."originalPath" LIKE $2
AND "asset"."originalPath" NOT LIKE $3
)
ORDER BY
regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC
-- AssetRepository.upsertFile
INSERT INTO
"asset_files" (

View File

@ -0,0 +1,78 @@
-- NOTE: This file is auto generated by ./sql-generator
-- ViewRepository.getAssetsByOriginalPath
SELECT
"asset"."id" AS "asset_id",
"asset"."deviceAssetId" AS "asset_deviceAssetId",
"asset"."ownerId" AS "asset_ownerId",
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"asset"."createdAt" AS "asset_createdAt",
"asset"."updatedAt" AS "asset_updatedAt",
"asset"."deletedAt" AS "asset_deletedAt",
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"asset"."localDateTime" AS "asset_localDateTime",
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"asset"."isFavorite" AS "asset_isFavorite",
"asset"."isArchived" AS "asset_isArchived",
"asset"."isExternal" AS "asset_isExternal",
"asset"."isOffline" AS "asset_isOffline",
"asset"."checksum" AS "asset_checksum",
"asset"."duration" AS "asset_duration",
"asset"."isVisible" AS "asset_isVisible",
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"asset"."originalFileName" AS "asset_originalFileName",
"asset"."sidecarPath" AS "asset_sidecarPath",
"asset"."stackId" AS "asset_stackId",
"asset"."duplicateId" AS "asset_duplicateId",
"exifInfo"."assetId" AS "exifInfo_assetId",
"exifInfo"."description" AS "exifInfo_description",
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"exifInfo"."orientation" AS "exifInfo_orientation",
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"exifInfo"."timeZone" AS "exifInfo_timeZone",
"exifInfo"."latitude" AS "exifInfo_latitude",
"exifInfo"."longitude" AS "exifInfo_longitude",
"exifInfo"."projectionType" AS "exifInfo_projectionType",
"exifInfo"."city" AS "exifInfo_city",
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"exifInfo"."state" AS "exifInfo_state",
"exifInfo"."country" AS "exifInfo_country",
"exifInfo"."make" AS "exifInfo_make",
"exifInfo"."model" AS "exifInfo_model",
"exifInfo"."lensModel" AS "exifInfo_lensModel",
"exifInfo"."fNumber" AS "exifInfo_fNumber",
"exifInfo"."focalLength" AS "exifInfo_focalLength",
"exifInfo"."iso" AS "exifInfo_iso",
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps"
FROM
"assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
WHERE
(
(
"asset"."isVisible" = $1
AND "asset"."isArchived" = $2
AND "asset"."ownerId" = $3
)
AND (
"asset"."originalPath" LIKE $4
AND "asset"."originalPath" NOT LIKE $5
)
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC

View File

@ -836,50 +836,6 @@ export class AssetRepository implements IAssetRepository {
return builder.getMany();
}
async getUniqueOriginalPaths(userId: string): Promise<string[]> {
const builder = this.getBuilder({
userIds: [userId],
exifInfo: false,
withStacked: false,
isArchived: false,
isTrashed: false,
});
const results = await builder
.select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
.getRawMany();
return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
const builder = this.getBuilder({
userIds: [userId],
exifInfo: true,
withStacked: false,
isArchived: false,
isTrashed: false,
});
const assets = await builder
.where('asset.ownerId = :userId', { userId })
.andWhere(
new Brackets((qb) => {
qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
'asset.originalPath NOT LIKE :notLikePath',
{ notLikePath: `%${normalizedPath}/%/%` },
);
}),
)
.orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
.getMany();
return assets;
}
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] });

View File

@ -30,6 +30,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@ -62,6 +63,7 @@ import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [
{ provide: IAccessRepository, useClass: AccessRepository },
@ -96,4 +98,5 @@ export const repositories = [
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IViewRepository, useClass: ViewRepository },
];

View File

@ -0,0 +1,48 @@
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { IViewRepository } from 'src/interfaces/view.interface';
import { Brackets, Repository } from 'typeorm';
export class ViewRepository implements IViewRepository {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
async getUniqueOriginalPaths(userId: string): Promise<string[]> {
const results = await this.assetRepository
.createQueryBuilder('asset')
.where({
isVisible: true,
isArchived: false,
ownerId: userId,
})
.select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
.getRawMany();
return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
const assets = await this.assetRepository
.createQueryBuilder('asset')
.where({
isVisible: true,
isArchived: false,
ownerId: userId,
})
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.andWhere(
new Brackets((qb) => {
qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
'asset.originalPath NOT LIKE :notLikePath',
{ notLikePath: `%${normalizedPath}/%/%` },
);
}),
)
.orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
.getMany();
return assets;
}
}

View File

@ -1,21 +1,20 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { ViewService } from 'src/services/view.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
import { Mocked } from 'vitest';
describe(ViewService.name, () => {
let sut: ViewService;
let assetMock: Mocked<IAssetRepository>;
let viewMock: Mocked<IViewRepository>;
beforeEach(() => {
assetMock = newAssetRepositoryMock();
viewMock = newViewRepositoryMock();
sut = new ViewService(assetMock);
sut = new ViewService(viewMock);
});
it('should work', () => {
@ -25,12 +24,12 @@ describe(ViewService.name, () => {
describe('getUniqueOriginalPaths', () => {
it('should return unique original paths', async () => {
const mockPaths = ['path1', 'path2', 'path3'];
assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
const result = await sut.getUniqueOriginalPaths(authStub.admin);
expect(result).toEqual(mockPaths);
expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
});
});
@ -45,11 +44,11 @@ describe(ViewService.name, () => {
const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin }));
assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
const result = await sut.getAssetsByOriginalPath(authStub.admin, path);
expect(result).toEqual(mockAssetReponseDto);
await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
});
});
});

View File

@ -1,18 +1,17 @@
import { Inject } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
export class ViewService {
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
constructor(@Inject(IViewRepository) private viewRepository: IViewRepository) {}
getUniqueOriginalPaths(auth: AuthDto): Promise<string[]> {
return this.assetRepository.getUniqueOriginalPaths(auth.user.id);
return this.viewRepository.getUniqueOriginalPaths(auth.user.id);
}
async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path);
const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path);
return assets.map((asset) => mapAsset(asset, { auth }));
}
}

View File

@ -43,7 +43,5 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
getChangedDeltaSync: vitest.fn(),
getDuplicates: vitest.fn(),
upsertFile: vitest.fn(),
getAssetsByOriginalPath: vitest.fn(),
getUniqueOriginalPaths: vitest.fn(),
};
};

View File

@ -0,0 +1,9 @@
import { IViewRepository } from 'src/interfaces/view.interface';
import { Mocked, vitest } from 'vitest';
export const newViewRepositoryMock = (): Mocked<IViewRepository> => {
return {
getAssetsByOriginalPath: vitest.fn(),
getUniqueOriginalPaths: vitest.fn(),
};
};