mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
fix: tag clean up query and add tests (#22633)
* fix delete empty tags query * rewrite as a single statement * create tag service medium test * single tag exists, connected to one asset, and is not deleted * do not delete parent tag if children have an asset * hierarchical tag tests * fix query to match 3 test * remove transaction and format:fix * remove transaction and format:fix * simplify query, handle nested empty tag * unused helper --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
parent
74a9be4a0e
commit
9d639607c7
3 changed files with 171 additions and 17 deletions
|
|
@ -163,22 +163,22 @@ export class TagRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEmptyTags() {
|
async deleteEmptyTags() {
|
||||||
// TODO rewrite as a single statement
|
const result = await this.db
|
||||||
await this.db.transaction().execute(async (tx) => {
|
.deleteFrom('tag')
|
||||||
const result = await tx
|
.where(({ not, exists, selectFrom }) =>
|
||||||
.selectFrom('asset')
|
not(
|
||||||
.innerJoin('tag_asset', 'tag_asset.assetsId', 'asset.id')
|
exists(
|
||||||
.innerJoin('tag_closure', 'tag_closure.id_descendant', 'tag_asset.tagsId')
|
selectFrom('tag_closure')
|
||||||
.innerJoin('tag', 'tag.id', 'tag_closure.id_descendant')
|
.whereRef('tag.id', '=', 'tag_closure.id_ancestor')
|
||||||
.select((eb) => ['tag.id', eb.fn.count<number>('asset.id').as('count')])
|
.innerJoin('tag_asset', 'tag_closure.id_descendant', 'tag_asset.tagsId'),
|
||||||
.groupBy('tag.id')
|
),
|
||||||
.execute();
|
),
|
||||||
|
)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
const ids = result.filter(({ count }) => count === 0).map(({ id }) => id);
|
const deletedRows = Number(result.numDeletedRows);
|
||||||
if (ids.length > 0) {
|
if (deletedRows > 0) {
|
||||||
await this.db.deleteFrom('tag').where('id', 'in', ids).execute();
|
this.logger.log(`Deleted ${deletedRows} empty tags`);
|
||||||
this.logger.log(`Deleted ${ids.length} empty tags`);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
||||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
import { SyncRepository } from 'src/repositories/sync.repository';
|
||||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||||
|
import { TagRepository } from 'src/repositories/tag.repository';
|
||||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||||
import { UserRepository } from 'src/repositories/user.repository';
|
import { UserRepository } from 'src/repositories/user.repository';
|
||||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||||
|
|
@ -52,6 +53,8 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||||
import { PersonTable } from 'src/schema/tables/person.table';
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
import { SessionTable } from 'src/schema/tables/session.table';
|
import { SessionTable } from 'src/schema/tables/session.table';
|
||||||
import { StackTable } from 'src/schema/tables/stack.table';
|
import { StackTable } from 'src/schema/tables/stack.table';
|
||||||
|
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
||||||
|
import { TagTable } from 'src/schema/tables/tag.table';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
|
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
|
||||||
import { SyncService } from 'src/services/sync.service';
|
import { SyncService } from 'src/services/sync.service';
|
||||||
|
|
@ -240,6 +243,18 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) {
|
||||||
|
const tagsAssets: Insertable<TagAssetTable>[] = [];
|
||||||
|
for (const tagsId of tagBulkAssets.tagIds) {
|
||||||
|
for (const assetsId of tagBulkAssets.assetIds) {
|
||||||
|
tagsAssets.push({ tagsId, assetsId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.get(TagRepository).upsertAssetIds(tagsAssets);
|
||||||
|
return { tagsAssets, result };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncTestContext extends MediumTestContext<SyncService> {
|
export class SyncTestContext extends MediumTestContext<SyncService> {
|
||||||
|
|
@ -318,6 +333,10 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||||
return new key(LoggingRepository.create());
|
return new key(LoggingRepository.create());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case TagRepository: {
|
||||||
|
return new key(db, LoggingRepository.create());
|
||||||
|
}
|
||||||
|
|
||||||
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
|
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
|
||||||
return new key() as unknown as T;
|
return new key() as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +364,8 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||||
case SyncCheckpointRepository:
|
case SyncCheckpointRepository:
|
||||||
case SystemMetadataRepository:
|
case SystemMetadataRepository:
|
||||||
case UserRepository:
|
case UserRepository:
|
||||||
case VersionHistoryRepository: {
|
case VersionHistoryRepository:
|
||||||
|
case TagRepository: {
|
||||||
return automock(key);
|
return automock(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -567,6 +587,23 @@ const memoryInsert = (memory: Partial<Insertable<MemoryTable>> = {}) => {
|
||||||
return { ...defaults, ...memory, id };
|
return { ...defaults, ...memory, id };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tagInsert = (tag: Partial<Insertable<TagTable>>) => {
|
||||||
|
const id = tag.id || newUuid();
|
||||||
|
|
||||||
|
const defaults: Insertable<TagTable> = {
|
||||||
|
id,
|
||||||
|
userId: '',
|
||||||
|
value: '',
|
||||||
|
createdAt: newDate(),
|
||||||
|
updatedAt: newDate(),
|
||||||
|
color: '',
|
||||||
|
parentId: null,
|
||||||
|
updateId: newUuid(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...defaults, ...tag, id };
|
||||||
|
};
|
||||||
|
|
||||||
class CustomWritable extends Writable {
|
class CustomWritable extends Writable {
|
||||||
private data = '';
|
private data = '';
|
||||||
|
|
||||||
|
|
@ -619,4 +656,5 @@ export const mediumFactory = {
|
||||||
memoryInsert,
|
memoryInsert,
|
||||||
loginDetails,
|
loginDetails,
|
||||||
loginResponse,
|
loginResponse,
|
||||||
|
tagInsert,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
116
server/test/medium/specs/services/tag.service.spec.ts
Normal file
116
server/test/medium/specs/services/tag.service.spec.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { JobStatus } from 'src/enum';
|
||||||
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { TagRepository } from 'src/repositories/tag.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { TagService } from 'src/services/tag.service';
|
||||||
|
import { upsertTags } from 'src/utils/tag';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
return newMediumService(TagService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [TagRepository, AccessRepository],
|
||||||
|
mock: [LoggingRepository],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(TagService.name, () => {
|
||||||
|
describe('deleteEmptyTags', () => {
|
||||||
|
it('single tag exists, not connected to any assets, and is deleted', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const tagRepo = ctx.get(TagRepository);
|
||||||
|
const [tag] = await upsertTags(tagRepo, { userId: user.id, tags: ['tag-1'] });
|
||||||
|
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toEqual(expect.objectContaining({ id: tag.id }));
|
||||||
|
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single tag exists, connected to one asset, and is not deleted', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const tagRepo = ctx.get(TagRepository);
|
||||||
|
const [tag] = await upsertTags(tagRepo, { userId: user.id, tags: ['tag-1'] });
|
||||||
|
|
||||||
|
await ctx.newTagAsset({ tagIds: [tag.id], assetIds: [asset.id] });
|
||||||
|
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toEqual(expect.objectContaining({ id: tag.id }));
|
||||||
|
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toEqual(expect.objectContaining({ id: tag.id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hierarchical tag exists, and the parent is connected to an asset, and the child is deleted', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const tagRepo = ctx.get(TagRepository);
|
||||||
|
const [parentTag, childTag] = await upsertTags(tagRepo, { userId: user.id, tags: ['parent', 'parent/child'] });
|
||||||
|
|
||||||
|
await ctx.newTagAsset({ tagIds: [parentTag.id], assetIds: [asset.id] });
|
||||||
|
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: parentTag.id }),
|
||||||
|
);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: childTag.id }),
|
||||||
|
);
|
||||||
|
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: parentTag.id }),
|
||||||
|
);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hierarchical tag exists, and only the child is connected to an asset, and nothing is deleted', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const tagRepo = ctx.get(TagRepository);
|
||||||
|
const [parentTag, childTag] = await upsertTags(tagRepo, { userId: user.id, tags: ['parent', 'parent/child'] });
|
||||||
|
|
||||||
|
await ctx.newTagAsset({ tagIds: [childTag.id], assetIds: [asset.id] });
|
||||||
|
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: parentTag.id }),
|
||||||
|
);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: childTag.id }),
|
||||||
|
);
|
||||||
|
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: parentTag.id }),
|
||||||
|
);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: childTag.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hierarchical tag exists, and neither parent nor child is connected to an asset, and both are deleted', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const tagRepo = ctx.get(TagRepository);
|
||||||
|
const [parentTag, childTag] = await upsertTags(tagRepo, { userId: user.id, tags: ['parent', 'parent/child'] });
|
||||||
|
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: parentTag.id }),
|
||||||
|
);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: childTag.id }),
|
||||||
|
);
|
||||||
|
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toBeUndefined();
|
||||||
|
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue