refactor: remove tag entity (#17462)

This commit is contained in:
Jason Rasmussen 2025-04-08 10:52:54 -04:00 committed by GitHub
parent 75bc32b47b
commit b6c5a03533
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 92 additions and 50 deletions

View File

@ -92,6 +92,13 @@ export type Asset = {
type: AssetType;
};
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type AuthSharedLink = {
id: string;
expiresAt: Date | null;

View File

@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { TagItem } from 'src/types';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
@ -52,7 +51,7 @@ export class TagResponseDto {
color?: string;
}
export function mapTag(entity: TagItem | TagEntity): TagResponseDto {
export function mapTag(entity: TagItem): TagResponseDto {
return {
id: entity.id,
parentId: entity.parentId ?? undefined,

View File

@ -8,11 +8,11 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { TagItem } from 'src/types';
import { anyUuid, asUuid } from 'src/utils/database';
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
@ -49,7 +49,7 @@ export class AssetEntity {
originalFileName!: string;
sidecarPath!: string | null;
exifInfo?: ExifEntity;
tags!: TagEntity[];
tags?: TagItem[];
sharedLinks!: SharedLinkEntity[];
albums?: AlbumEntity[];
faces!: AssetFaceEntity[];

View File

@ -1,17 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
export class TagEntity {
id!: string;
value!: string;
createdAt!: Date;
updatedAt!: Date;
updateId?: string;
color!: string | null;
parentId?: string;
parent?: TagEntity;
children?: TagEntity[];
user?: UserEntity;
userId!: string;
assets?: AssetEntity[];
}

View File

@ -210,6 +210,32 @@ where
limit
$3
-- AssetRepository.getAssetForSidecarWriteJob
select
"id",
"sidecarPath",
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"tags"."value"
from
"tags"
inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
where
"assets"."id" = "tag_asset"."assetsId"
) as agg
) as "tags"
from
"assets"
where
"assets"."id" = $1::uuid
limit
$2
-- AssetRepository.getById
select
"assets".*

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
@ -495,6 +496,27 @@ export class AssetRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAssetForSidecarWriteJob(id: string) {
return this.db
.selectFrom('assets')
.where('assets.id', '=', asUuid(id))
.select((eb) => [
'id',
'sidecarPath',
'originalPath',
jsonArrayFrom(
eb
.selectFrom('tags')
.select(['tags.value'])
.innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
.whereRef('assets.id', '=', 'tag_asset.assetsId'),
).as('tags'),
])
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,

View File

@ -15,6 +15,7 @@ import { probeStub } from 'test/fixtures/media.stub';
import { metadataStub } from 'test/fixtures/metadata.stub';
import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MetadataService.name, () => {
@ -1405,33 +1406,35 @@ describe(MetadataService.name, () => {
describe('handleSidecarWrite', () => {
it('should skip assets that do not exist anymore', async () => {
mocks.asset.getByIds.mockResolvedValue([]);
mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
});
it('should skip jobs with not metadata', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED);
it('should skip jobs with no metadata', async () => {
const asset = factory.jobAssets.sidecarWrite();
mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
});
it('should write tags', async () => {
const asset = factory.jobAssets.sidecarWrite();
const description = 'this is a description';
const gps = 12;
const date = '2023-11-22T04:56:12.196Z';
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(asset);
await expect(
sut.handleSidecarWrite({
id: assetStub.sidecar.id,
id: asset.id,
description,
latitude: gps,
longitude: gps,
dateTimeOriginal: date,
}),
).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, {
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, {
Description: description,
ImageDescription: description,
DateTimeOriginal: date,

View File

@ -316,7 +316,7 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true });
const asset = await this.assetRepository.getAssetForSidecarWriteJob(id);
if (!asset) {
return JobStatus.FAILED;
}

View File

@ -89,7 +89,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
faces: [],
sidecarPath: null,
@ -123,7 +122,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'IMG_456.jpg',
faces: [],
@ -162,7 +160,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
@ -197,7 +194,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
@ -243,7 +239,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
@ -283,7 +278,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
@ -325,7 +319,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
@ -363,7 +356,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
@ -404,7 +396,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
@ -443,7 +434,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
isExternal: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
@ -480,7 +470,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
@ -519,7 +508,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
faces: [],
sidecarPath: null,
@ -608,7 +596,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
@ -650,7 +637,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
@ -685,7 +671,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
@ -721,7 +706,6 @@ export const assetStub = {
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
faces: [],
sidecarPath: null,
@ -759,7 +743,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
tags: [],
sharedLinks: [],
originalFileName: 'photo.jpg',
faces: [],
@ -797,7 +780,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.dng',
faces: [],
@ -837,7 +819,6 @@ export const assetStub = {
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.hif',
faces: [],

View File

@ -241,7 +241,6 @@ export const sharedLinkStub = {
autoStackId: null,
rating: 3,
},
tags: [],
sharedLinks: [],
faces: [],
sidecarPath: null,

View File

@ -12,6 +12,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getByDayOfYear: vitest.fn(),
getByIds: vitest.fn().mockResolvedValue([]),
getAssetForSearchDuplicatesJob: vitest.fn(),
getAssetForSidecarWriteJob: vitest.fn(),
getByIdsWithAllRelations: vitest.fn().mockResolvedValue([]),
getByAlbumId: vitest.fn(),
getByDeviceIds: vitest.fn(),

View File

@ -1,5 +1,15 @@
import { randomUUID } from 'node:crypto';
import { ApiKey, Asset, AuthApiKey, AuthUser, Library, Partner, User, UserAdmin } from 'src/database';
import {
ApiKey,
Asset,
AuthApiKey,
AuthUser,
Library,
Partner,
SidecarWriteAsset,
User,
UserAdmin,
} from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { ActivityItem, MemoryItem, OnThisDayData } from 'src/types';
@ -210,6 +220,14 @@ const versionHistoryFactory = () => ({
version: '1.123.45',
});
const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
id: newUuid(),
sidecarPath: '/path/to/original-path.jpg.xmp',
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
...asset,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
@ -225,4 +243,7 @@ export const factory = {
user: userFactory,
userAdmin: userAdminFactory,
versionHistory: versionHistoryFactory,
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
};