fix: updating lockable properties

This commit is contained in:
Daniel Dietzler
2025-12-12 10:33:26 -06:00
parent faf9964af0
commit 05b6d90a36
9 changed files with 152 additions and 55 deletions

View File

@@ -1,5 +1,21 @@
-- NOTE: This file is auto generated by ./sql-generator -- 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 -- AssetRepository.updateAllExif
update "asset_exif" update "asset_exif"
set set

View File

@@ -120,9 +120,15 @@ const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuild
export class AssetRepository { export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({
params: [
{ dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
{ lockedPropertiesBehavior: 'append' },
],
})
async upsertExif( async upsertExif(
exif: Insertable<AssetExifTable>, exif: Insertable<AssetExifTable>,
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' }, { lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' },
): Promise<void> { ): Promise<void> {
await this.db await this.db
.insertInto('asset_exif') .insertInto('asset_exif')
@@ -137,44 +143,46 @@ export class AssetRepository {
.then(eb.ref(`asset_exif.${col}`)) .then(eb.ref(`asset_exif.${col}`))
.else(eb.ref(`excluded.${col}`)) .else(eb.ref(`excluded.${col}`))
.end(); .end();
const ref = lockedPropertiesBehavior === 'update' ? updateLocked : skipLocked; const ref = lockedPropertiesBehavior === 'skip' ? skipLocked : updateLocked;
return removeUndefinedKeys( return {
{ ...removeUndefinedKeys(
description: ref('description'), {
exifImageWidth: ref('exifImageWidth'), description: ref('description'),
exifImageHeight: ref('exifImageHeight'), exifImageWidth: ref('exifImageWidth'),
fileSizeInByte: ref('fileSizeInByte'), exifImageHeight: ref('exifImageHeight'),
orientation: ref('orientation'), fileSizeInByte: ref('fileSizeInByte'),
dateTimeOriginal: ref('dateTimeOriginal'), orientation: ref('orientation'),
modifyDate: ref('modifyDate'), dateTimeOriginal: ref('dateTimeOriginal'),
timeZone: ref('timeZone'), modifyDate: ref('modifyDate'),
latitude: ref('latitude'), timeZone: ref('timeZone'),
longitude: ref('longitude'), latitude: ref('latitude'),
projectionType: ref('projectionType'), longitude: ref('longitude'),
city: ref('city'), projectionType: ref('projectionType'),
livePhotoCID: ref('livePhotoCID'), city: ref('city'),
autoStackId: ref('autoStackId'), livePhotoCID: ref('livePhotoCID'),
state: ref('state'), autoStackId: ref('autoStackId'),
country: ref('country'), state: ref('state'),
make: ref('make'), country: ref('country'),
model: ref('model'), make: ref('make'),
lensModel: ref('lensModel'), model: ref('model'),
fNumber: ref('fNumber'), lensModel: ref('lensModel'),
focalLength: ref('focalLength'), fNumber: ref('fNumber'),
iso: ref('iso'), focalLength: ref('focalLength'),
exposureTime: ref('exposureTime'), iso: ref('iso'),
profileDescription: ref('profileDescription'), exposureTime: ref('exposureTime'),
colorspace: ref('colorspace'), profileDescription: ref('profileDescription'),
bitsPerSample: ref('bitsPerSample'), colorspace: ref('colorspace'),
rating: ref('rating'), bitsPerSample: ref('bitsPerSample'),
fps: ref('fps'), rating: ref('rating'),
lockedProperties: fps: ref('fps'),
exif.lockedProperties !== undefined && lockedPropertiesBehavior !== 'none' lockedProperties:
? distinctLocked(eb, exif.lockedProperties) lockedPropertiesBehavior === 'append'
: exif.lockedProperties, ? distinctLocked(eb, exif.lockedProperties ?? null)
}, : ref('lockedProperties'),
exif, },
); exif,
),
};
}), }),
) )
.execute(); .execute();

View File

@@ -370,7 +370,10 @@ export class AssetMediaService extends BaseService {
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); : this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); 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({ await this.jobRepository.queue({
name: JobName.AssetExtractMetadata, name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' }, data: { id: assetId, source: 'upload' },
@@ -401,7 +404,7 @@ export class AssetMediaService extends BaseService {
const { size } = await this.storageRepository.stat(created.originalPath); const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size }, { assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'none' }, { lockedPropertiesBehavior: 'override' },
); );
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } }); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created; 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.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif(
{ assetId: asset.id, fileSizeInByte: file.size }, { assetId: asset.id, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'none' }, { lockedPropertiesBehavior: 'override' },
); );
await this.eventRepository.emit('AssetCreate', { asset }); await this.eventRepository.emit('AssetCreate', { asset });

View File

@@ -226,8 +226,8 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-1', description: 'Test description' }, { assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'update' }, { lockedPropertiesBehavior: 'append' },
); );
}); });
@@ -242,8 +242,9 @@ describe(AssetService.name, () => {
{ {
assetId: 'asset-1', assetId: 'asset-1',
rating: 3, rating: 3,
lockedProperties: ['rating'],
}, },
{ lockedPropertiesBehavior: 'update' }, { lockedPropertiesBehavior: 'append' },
); );
}); });

View File

@@ -33,6 +33,7 @@ import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access'; import { requireElevatedPermission } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
import { updateLockedColumns } from 'src/utils/database';
@Injectable() @Injectable()
export class AssetService extends BaseService { export class AssetService extends BaseService {
@@ -438,11 +439,11 @@ export class AssetService extends BaseService {
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif(
{ updateLockedColumns({
assetId: id, assetId: id,
...writes, ...writes,
}, }),
{ lockedPropertiesBehavior: 'update' }, { lockedPropertiesBehavior: 'append' },
); );
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } }); await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
} }

View File

@@ -19,6 +19,7 @@ import { columns, Exif, Person } from 'src/database';
import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { lockableProperties } from 'src/schema/tables/asset-exif.table';
import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { DatabaseConnectionParams, VectorExtension } from 'src/types';
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
@@ -488,3 +489,8 @@ export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: V
} }
} }
} }
export const updateLockedColumns = <T extends Record<string, unknown>>(exif: T) => ({
...exif,
lockedProperties: lockableProperties.filter((property) => property in exif),
});

View File

@@ -202,7 +202,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
} }
async newExif(dto: Insertable<AssetExifTable>) { 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 }; return { result };
} }

View File

@@ -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',
}),
}),
);
});
});
}); });

View File

@@ -2,6 +2,7 @@ import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { updateLockedColumns } from 'src/utils/database';
import { SyncTestContext } from 'test/medium.factory'; import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils'; import { getKyselyDB, wait } from 'test/utils';
@@ -289,11 +290,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset // update the asset
const assetRepository = ctx.get(AssetRepository); const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif( await assetRepository.upsertExif(
{ updateLockedColumns({
assetId: asset.id, assetId: asset.id,
city: 'New City', city: 'New City',
}, }),
{ lockedPropertiesBehavior: 'update' }, { lockedPropertiesBehavior: 'append' },
); );
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
@@ -350,11 +351,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset // update the asset
const assetRepository = ctx.get(AssetRepository); const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif( await assetRepository.upsertExif(
{ updateLockedColumns({
assetId: assetDelayedExif.id, assetId: assetDelayedExif.id,
city: 'Delayed Exif', city: 'Delayed Exif',
}, }),
{ lockedPropertiesBehavior: 'update' }, { lockedPropertiesBehavior: 'append' },
); );
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([