fix: updating lockable properties
parent
8f19a5e2fe
commit
9a8c6e9297
|
|
@ -465,3 +465,13 @@ export const columns = {
|
|||
'plugin.updatedAt as updatedAt',
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type LockableProperty = (typeof lockableProperties)[number];
|
||||
export const lockableProperties = [
|
||||
'description',
|
||||
'dateTimeOriginal',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'rating',
|
||||
'timeZone',
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AssetRepository.upsertExif
|
||||
insert into
|
||||
"asset_exif" ("dateTimeOriginal", "lockedProperties")
|
||||
values
|
||||
($1, $2)
|
||||
on conflict ("assetId") do update
|
||||
set
|
||||
"dateTimeOriginal" = "excluded"."dateTimeOriginal",
|
||||
"lockedProperties" = nullif(
|
||||
array(
|
||||
select distinct
|
||||
unnest("asset_exif"."lockedProperties" || $3)
|
||||
),
|
||||
'{}'
|
||||
)
|
||||
|
||||
-- AssetRepository.updateAllExif
|
||||
update "asset_exif"
|
||||
set
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common';
|
|||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Stack } from 'src/database';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable, LockableProperty } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
|
@ -120,9 +120,15 @@ const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuild
|
|||
export class AssetRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{ dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
],
|
||||
})
|
||||
async upsertExif(
|
||||
exif: Insertable<AssetExifTable>,
|
||||
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' },
|
||||
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' },
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('asset_exif')
|
||||
|
|
@ -137,44 +143,46 @@ export class AssetRepository {
|
|||
.then(eb.ref(`asset_exif.${col}`))
|
||||
.else(eb.ref(`excluded.${col}`))
|
||||
.end();
|
||||
const ref = lockedPropertiesBehavior === 'update' ? updateLocked : skipLocked;
|
||||
return removeUndefinedKeys(
|
||||
{
|
||||
description: ref('description'),
|
||||
exifImageWidth: ref('exifImageWidth'),
|
||||
exifImageHeight: ref('exifImageHeight'),
|
||||
fileSizeInByte: ref('fileSizeInByte'),
|
||||
orientation: ref('orientation'),
|
||||
dateTimeOriginal: ref('dateTimeOriginal'),
|
||||
modifyDate: ref('modifyDate'),
|
||||
timeZone: ref('timeZone'),
|
||||
latitude: ref('latitude'),
|
||||
longitude: ref('longitude'),
|
||||
projectionType: ref('projectionType'),
|
||||
city: ref('city'),
|
||||
livePhotoCID: ref('livePhotoCID'),
|
||||
autoStackId: ref('autoStackId'),
|
||||
state: ref('state'),
|
||||
country: ref('country'),
|
||||
make: ref('make'),
|
||||
model: ref('model'),
|
||||
lensModel: ref('lensModel'),
|
||||
fNumber: ref('fNumber'),
|
||||
focalLength: ref('focalLength'),
|
||||
iso: ref('iso'),
|
||||
exposureTime: ref('exposureTime'),
|
||||
profileDescription: ref('profileDescription'),
|
||||
colorspace: ref('colorspace'),
|
||||
bitsPerSample: ref('bitsPerSample'),
|
||||
rating: ref('rating'),
|
||||
fps: ref('fps'),
|
||||
lockedProperties:
|
||||
exif.lockedProperties !== undefined && lockedPropertiesBehavior !== 'none'
|
||||
? distinctLocked(eb, exif.lockedProperties)
|
||||
: exif.lockedProperties,
|
||||
},
|
||||
exif,
|
||||
);
|
||||
const ref = lockedPropertiesBehavior === 'skip' ? skipLocked : updateLocked;
|
||||
return {
|
||||
...removeUndefinedKeys(
|
||||
{
|
||||
description: ref('description'),
|
||||
exifImageWidth: ref('exifImageWidth'),
|
||||
exifImageHeight: ref('exifImageHeight'),
|
||||
fileSizeInByte: ref('fileSizeInByte'),
|
||||
orientation: ref('orientation'),
|
||||
dateTimeOriginal: ref('dateTimeOriginal'),
|
||||
modifyDate: ref('modifyDate'),
|
||||
timeZone: ref('timeZone'),
|
||||
latitude: ref('latitude'),
|
||||
longitude: ref('longitude'),
|
||||
projectionType: ref('projectionType'),
|
||||
city: ref('city'),
|
||||
livePhotoCID: ref('livePhotoCID'),
|
||||
autoStackId: ref('autoStackId'),
|
||||
state: ref('state'),
|
||||
country: ref('country'),
|
||||
make: ref('make'),
|
||||
model: ref('model'),
|
||||
lensModel: ref('lensModel'),
|
||||
fNumber: ref('fNumber'),
|
||||
focalLength: ref('focalLength'),
|
||||
iso: ref('iso'),
|
||||
exposureTime: ref('exposureTime'),
|
||||
profileDescription: ref('profileDescription'),
|
||||
colorspace: ref('colorspace'),
|
||||
bitsPerSample: ref('bitsPerSample'),
|
||||
rating: ref('rating'),
|
||||
fps: ref('fps'),
|
||||
lockedProperties:
|
||||
lockedPropertiesBehavior === 'append'
|
||||
? distinctLocked(eb, exif.lockedProperties ?? null)
|
||||
: ref('lockedProperties'),
|
||||
},
|
||||
exif,
|
||||
),
|
||||
};
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import { LockableProperty } from 'src/database';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
|
||||
|
||||
export type LockableProperty = (typeof lockableProperties)[number];
|
||||
export const lockableProperties = [
|
||||
'description',
|
||||
'dateTimeOriginal',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'rating',
|
||||
'timeZone',
|
||||
] as const;
|
||||
|
||||
@Table('asset_exif')
|
||||
@UpdatedAtTrigger('asset_exif_updatedAt')
|
||||
export class AssetExifTable {
|
||||
|
|
|
|||
|
|
@ -370,7 +370,10 @@ export class AssetMediaService extends BaseService {
|
|||
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
||||
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }, { lockedPropertiesBehavior: 'none' });
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId, fileSizeInByte: file.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetExtractMetadata,
|
||||
data: { id: assetId, source: 'upload' },
|
||||
|
|
@ -401,7 +404,7 @@ export class AssetMediaService extends BaseService {
|
|||
const { size } = await this.storageRepository.stat(created.originalPath);
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: created.id, fileSizeInByte: size },
|
||||
{ lockedPropertiesBehavior: 'none' },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
|
||||
return created;
|
||||
|
|
@ -445,7 +448,7 @@ export class AssetMediaService extends BaseService {
|
|||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: asset.id, fileSizeInByte: file.size },
|
||||
{ lockedPropertiesBehavior: 'none' },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
|
|
|
|||
|
|
@ -226,8 +226,8 @@ describe(AssetService.name, () => {
|
|||
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', description: 'Test description' },
|
||||
{ lockedPropertiesBehavior: 'update' },
|
||||
{ assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -242,8 +242,9 @@ describe(AssetService.name, () => {
|
|||
{
|
||||
assetId: 'asset-1',
|
||||
rating: 3,
|
||||
lockedProperties: ['rating'],
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'update' },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { BaseService } from 'src/services/base.service';
|
|||
import { JobItem, JobOf } from 'src/types';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
|
||||
@Injectable()
|
||||
export class AssetService extends BaseService {
|
||||
|
|
@ -438,11 +439,11 @@ export class AssetService extends BaseService {
|
|||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.assetRepository.upsertExif(
|
||||
{
|
||||
updateLockedColumns({
|
||||
assetId: id,
|
||||
...writes,
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'update' },
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ export class MetadataService extends BaseService {
|
|||
<Tags>{
|
||||
Description: description,
|
||||
ImageDescription: description,
|
||||
DateTimeOriginal: dateTimeOriginal == null ? undefined : String(dateTimeOriginal),
|
||||
DateTimeOriginal: dateTimeOriginal ? String(dateTimeOriginal) : undefined,
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { PostgresJSDialect } from 'kysely-postgres-js';
|
|||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import postgres, { Notice, PostgresError } from 'postgres';
|
||||
import { columns, Exif, Person } from 'src/database';
|
||||
import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
|
|
@ -488,3 +488,10 @@ export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: V
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateLockedColumns = <T extends Record<string, unknown> & { lockedProperties?: LockableProperty[] }>(
|
||||
exif: T,
|
||||
) => {
|
||||
exif.lockedProperties = lockableProperties.filter((property) => property in exif);
|
||||
return exif;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
}
|
||||
|
||||
async newExif(dto: Insertable<AssetExifTable>) {
|
||||
const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'none' });
|
||||
const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'override' });
|
||||
return { result };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,4 +268,65 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: null });
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAll', () => {
|
||||
it('should relatively update assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({
|
||||
dateTimeOriginal: '2023-11-19T18:00:00+00:00',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Kysely } from 'kysely';
|
|||
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
import { SyncTestContext } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getKyselyDB, wait } from 'test/utils';
|
||||
|
|
@ -289,11 +290,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
|||
// update the asset
|
||||
const assetRepository = ctx.get(AssetRepository);
|
||||
await assetRepository.upsertExif(
|
||||
{
|
||||
updateLockedColumns({
|
||||
assetId: asset.id,
|
||||
city: 'New City',
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'update' },
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
|
||||
|
|
@ -350,11 +351,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
|||
// update the asset
|
||||
const assetRepository = ctx.get(AssetRepository);
|
||||
await assetRepository.upsertExif(
|
||||
{
|
||||
updateLockedColumns({
|
||||
assetId: assetDelayedExif.id,
|
||||
city: 'Delayed Exif',
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'update' },
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
|
||||
|
|
|
|||
Loading…
Reference in New Issue