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; type: AssetType;
}; };
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type AuthSharedLink = { export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;

View File

@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { TagItem } from 'src/types'; import { TagItem } from 'src/types';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
@ -52,7 +51,7 @@ export class TagResponseDto {
color?: string; color?: string;
} }
export function mapTag(entity: TagItem | TagEntity): TagResponseDto { export function mapTag(entity: TagItem): TagResponseDto {
return { return {
id: entity.id, id: entity.id,
parentId: entity.parentId ?? undefined, 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 { ExifEntity } from 'src/entities/exif.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { StackEntity } from 'src/entities/stack.entity'; import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { TagItem } from 'src/types';
import { anyUuid, asUuid } from 'src/utils/database'; import { anyUuid, asUuid } from 'src/utils/database';
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
@ -49,7 +49,7 @@ export class AssetEntity {
originalFileName!: string; originalFileName!: string;
sidecarPath!: string | null; sidecarPath!: string | null;
exifInfo?: ExifEntity; exifInfo?: ExifEntity;
tags!: TagEntity[]; tags?: TagItem[];
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
albums?: AlbumEntity[]; albums?: AlbumEntity[];
faces!: AssetFaceEntity[]; 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 limit
$3 $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 -- AssetRepository.getById
select select
"assets".* "assets".*

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash'; import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
@ -495,6 +496,27 @@ export class AssetRepository {
.executeTakeFirst(); .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] }) @GenerateSql({ params: [DummyValue.UUID] })
getById( getById(
id: string, id: string,

View File

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

View File

@ -316,7 +316,7 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> { async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; 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) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }

View File

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

View File

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

View File

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

View File

@ -1,5 +1,15 @@
import { randomUUID } from 'node:crypto'; 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 { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { ActivityItem, MemoryItem, OnThisDayData } from 'src/types'; import { ActivityItem, MemoryItem, OnThisDayData } from 'src/types';
@ -210,6 +220,14 @@ const versionHistoryFactory = () => ({
version: '1.123.45', 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 = { export const factory = {
activity: activityFactory, activity: activityFactory,
apiKey: apiKeyFactory, apiKey: apiKeyFactory,
@ -225,4 +243,7 @@ export const factory = {
user: userFactory, user: userFactory,
userAdmin: userAdminFactory, userAdmin: userAdminFactory,
versionHistory: versionHistoryFactory, versionHistory: versionHistoryFactory,
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
}; };