single statement

pull/24384/head
mertalev 2025-12-05 12:56:30 +07:00
parent 75889f992e
commit e414d43eee
No known key found for this signature in database
GPG Key ID: DF6ABC77AAD98C95
3 changed files with 76 additions and 102 deletions

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { intersection, isEmpty, isUndefined, omit, omitBy, union } from 'lodash'; import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database'; import { Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AssetExifTable, lockableProperties, LockableProperty } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable, LockableProperty } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
@ -113,6 +113,9 @@ interface GetByIdsRelations {
tags?: boolean; tags?: boolean;
} }
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
@Injectable() @Injectable()
export class AssetRepository { export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@ -121,79 +124,60 @@ export class AssetRepository {
exif: Insertable<AssetExifTable>, exif: Insertable<AssetExifTable>,
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' }, { lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' },
): Promise<void> { ): Promise<void> {
await this.db.transaction().execute(async (tx) => { await this.db
const lockedProperties = await tx .insertInto('asset_exif')
.selectFrom('asset_exif') .values(exif)
.select('asset_exif.lockedProperties') .onConflict((oc) =>
.where('asset_exif.assetId', '=', exif.assetId) oc.column('assetId').doUpdateSet((eb) => {
.executeTakeFirst() const updateLocked = <T extends keyof AssetExifTable>(col: T) => eb.ref(`excluded.${col}`);
.then((result) => result?.lockedProperties ?? []); const skipLocked = <T extends keyof AssetExifTable>(col: T) =>
eb
let value = { ...exif, assetId: asUuid(exif.assetId) }; .case()
.when(sql`${col}`, '=', eb.fn.any('asset_exif.lockedProperties'))
switch (lockedPropertiesBehavior) { .then(eb.ref(`asset_exif.${col}`))
case 'skip': { .else(eb.ref(`excluded.${col}`))
value = omit(value, [...lockedProperties, 'lockedProperties']); .end();
break; const ref = lockedPropertiesBehavior === 'update' ? updateLocked : skipLocked;
} return removeUndefinedKeys(
{
case 'update': { description: ref('description'),
const updatedLockableProperties = intersection(lockableProperties, Object.keys(exif)) as LockableProperty[]; exifImageWidth: ref('exifImageWidth'),
value = { exifImageHeight: ref('exifImageHeight'),
...value, fileSizeInByte: ref('fileSizeInByte'),
lockedProperties: union(updatedLockableProperties, lockedProperties), orientation: ref('orientation'),
}; dateTimeOriginal: ref('dateTimeOriginal'),
break; modifyDate: ref('modifyDate'),
} timeZone: ref('timeZone'),
} latitude: ref('latitude'),
longitude: ref('longitude'),
if (Object.keys(value).length <= 1) { projectionType: ref('projectionType'),
return; city: ref('city'),
} livePhotoCID: ref('livePhotoCID'),
autoStackId: ref('autoStackId'),
return tx state: ref('state'),
.insertInto('asset_exif') country: ref('country'),
.values(value) make: ref('make'),
.onConflict((oc) => model: ref('model'),
oc.column('assetId').doUpdateSet((eb) => lensModel: ref('lensModel'),
removeUndefinedKeys( fNumber: ref('fNumber'),
{ focalLength: eb.ref('excluded.focalLength'),
description: eb.ref('excluded.description'), iso: ref('iso'),
exifImageWidth: eb.ref('excluded.exifImageWidth'), exposureTime: ref('exposureTime'),
exifImageHeight: eb.ref('excluded.exifImageHeight'), profileDescription: ref('profileDescription'),
fileSizeInByte: eb.ref('excluded.fileSizeInByte'), colorspace: ref('colorspace'),
orientation: eb.ref('excluded.orientation'), bitsPerSample: ref('bitsPerSample'),
dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'), rating: ref('rating'),
modifyDate: eb.ref('excluded.modifyDate'), fps: ref('fps'),
timeZone: eb.ref('excluded.timeZone'), lockedProperties:
latitude: eb.ref('excluded.latitude'), exif.lockedProperties === undefined || lockedPropertiesBehavior === 'none'
longitude: eb.ref('excluded.longitude'), ? undefined
projectionType: eb.ref('excluded.projectionType'), : distinctLocked(eb, exif.lockedProperties),
city: eb.ref('excluded.city'), },
livePhotoCID: eb.ref('excluded.livePhotoCID'), exif,
autoStackId: eb.ref('excluded.autoStackId'), );
state: eb.ref('excluded.state'), }),
country: eb.ref('excluded.country'), )
make: eb.ref('excluded.make'), .execute();
model: eb.ref('excluded.model'),
lensModel: eb.ref('excluded.lensModel'),
fNumber: eb.ref('excluded.fNumber'),
focalLength: eb.ref('excluded.focalLength'),
iso: eb.ref('excluded.iso'),
exposureTime: eb.ref('excluded.exposureTime'),
profileDescription: eb.ref('excluded.profileDescription'),
colorspace: eb.ref('excluded.colorspace'),
bitsPerSample: eb.ref('excluded.bitsPerSample'),
rating: eb.ref('excluded.rating'),
fps: eb.ref('excluded.fps'),
lockedProperties: eb.ref('excluded.lockedProperties'),
},
value,
),
),
)
.execute();
});
} }
@GenerateSql({ params: [[DummyValue.UUID], { model: DummyValue.STRING }] }) @GenerateSql({ params: [[DummyValue.UUID], { model: DummyValue.STRING }] })
@ -207,11 +191,7 @@ export class AssetRepository {
.updateTable('asset_exif') .updateTable('asset_exif')
.set((eb) => ({ .set((eb) => ({
...options, ...options,
lockedProperties: eb lockedProperties: distinctLocked(eb, Object.keys(options) as LockableProperty[]),
.fn<
LockableProperty[]
>('array', [sql`select distinct unnest(${eb.fn('array_cat', ['lockedProperties', eb.val(Object.keys(options))])})`])
.as('lockedProperties').expression,
})) }))
.where('assetId', 'in', ids) .where('assetId', 'in', ids)
.execute(); .execute();
@ -219,21 +199,17 @@ export class AssetRepository {
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] }) @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
@Chunked() @Chunked()
async updateDateTimeOriginal( updateDateTimeOriginal(ids: string[], delta?: number, timeZone?: string) {
ids: string[], if (ids.length === 0) {
delta?: number, return;
timeZone?: string, }
): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> {
return await this.db return this.db
.updateTable('asset_exif') .updateTable('asset_exif')
.set((eb) => ({ .set((eb) => ({
dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`,
timeZone, timeZone,
lockedProperties: eb lockedProperties: distinctLocked(eb, ['dateTimeOriginal', 'timeZone']),
.fn<
LockableProperty[]
>('array', [sql`select distinct unnest(${eb.fn('array_cat', ['lockedProperties', eb.val(['dateTimeOriginal', 'timeZone'])])})`])
.as('lockedProperties').expression,
})) }))
.where('assetId', 'in', ids) .where('assetId', 'in', ids)
.returning(['assetId', 'dateTimeOriginal', 'timeZone']) .returning(['assetId', 'dateTimeOriginal', 'timeZone'])

@ -1,9 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" ADD "lockedProperties" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" DROP COLUMN "lockedProperties";`.execute(db);
}

@ -3,7 +3,14 @@ import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools'; import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
export type LockableProperty = (typeof lockableProperties)[number]; export type LockableProperty = (typeof lockableProperties)[number];
export const lockableProperties = ['description', 'dateTimeOriginal', 'latitude', 'longitude', 'rating'] as const; export const lockableProperties = [
'description',
'dateTimeOriginal',
'latitude',
'longitude',
'rating',
'timeZone',
] as const;
@Table('asset_exif') @Table('asset_exif')
@UpdatedAtTrigger('asset_exif_updatedAt') @UpdatedAtTrigger('asset_exif_updatedAt')