mirror of
https://github.com/immich-app/immich.git
synced 2025-05-30 19:54:52 -04:00
refactor: migration tag repository to kysely (#16398)
This commit is contained in:
parent
ff19502035
commit
d1fd0076cc
@ -52,5 +52,6 @@ export const columns = {
|
|||||||
'shared_links.password',
|
'shared_links.password',
|
||||||
],
|
],
|
||||||
userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
|
userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
|
||||||
|
tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'],
|
||||||
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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 { TagEntity } from 'src/entities/tag.entity';
|
||||||
|
import { TagItem } from 'src/types';
|
||||||
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class TagCreateDto {
|
export class TagCreateDto {
|
||||||
@ -51,7 +52,7 @@ export class TagResponseDto {
|
|||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTag(entity: TagEntity): TagResponseDto {
|
export function mapTag(entity: TagItem | TagEntity): TagResponseDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
parentId: entity.parentId ?? undefined,
|
parentId: entity.parentId ?? undefined,
|
||||||
|
@ -1,10 +1,118 @@
|
|||||||
-- NOTE: This file is auto generated by ./sql-generator
|
-- NOTE: This file is auto generated by ./sql-generator
|
||||||
|
|
||||||
-- TagRepository.getAssetIds
|
-- TagRepository.get
|
||||||
SELECT
|
select
|
||||||
"tag_asset"."assetsId" AS "assetId"
|
"id",
|
||||||
FROM
|
"value",
|
||||||
"tag_asset" "tag_asset"
|
"createdAt",
|
||||||
WHERE
|
"updatedAt",
|
||||||
"tag_asset"."tagsId" = $1
|
"color",
|
||||||
AND "tag_asset"."assetsId" IN ($2)
|
"parentId"
|
||||||
|
from
|
||||||
|
"tags"
|
||||||
|
where
|
||||||
|
"id" = $1
|
||||||
|
|
||||||
|
-- TagRepository.getByValue
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"value",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"color",
|
||||||
|
"parentId"
|
||||||
|
from
|
||||||
|
"tags"
|
||||||
|
where
|
||||||
|
"userId" = $1
|
||||||
|
and "value" = $2
|
||||||
|
|
||||||
|
-- TagRepository.upsertValue
|
||||||
|
begin
|
||||||
|
insert into
|
||||||
|
"tags" ("userId", "value", "parentId")
|
||||||
|
values
|
||||||
|
($1, $2, $3)
|
||||||
|
on conflict ("userId", "value") do update
|
||||||
|
set
|
||||||
|
"parentId" = $4
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
rollback
|
||||||
|
|
||||||
|
-- TagRepository.getAll
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"value",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"color",
|
||||||
|
"parentId"
|
||||||
|
from
|
||||||
|
"tags"
|
||||||
|
where
|
||||||
|
"userId" = $1
|
||||||
|
order by
|
||||||
|
"value" asc
|
||||||
|
|
||||||
|
-- TagRepository.create
|
||||||
|
insert into
|
||||||
|
"tags" ("userId", "color", "value")
|
||||||
|
values
|
||||||
|
($1, $2, $3)
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
|
||||||
|
-- TagRepository.update
|
||||||
|
update "tags"
|
||||||
|
set
|
||||||
|
"color" = $1
|
||||||
|
where
|
||||||
|
"id" = $2
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
|
||||||
|
-- TagRepository.delete
|
||||||
|
delete from "tags"
|
||||||
|
where
|
||||||
|
"id" = $1
|
||||||
|
|
||||||
|
-- TagRepository.addAssetIds
|
||||||
|
insert into
|
||||||
|
"tag_asset" ("tagsId", "assetsId")
|
||||||
|
values
|
||||||
|
($1, $2)
|
||||||
|
|
||||||
|
-- TagRepository.removeAssetIds
|
||||||
|
delete from "tag_asset"
|
||||||
|
where
|
||||||
|
"tagsId" = $1
|
||||||
|
and "assetsId" in ($2)
|
||||||
|
|
||||||
|
-- TagRepository.replaceAssetTags
|
||||||
|
begin
|
||||||
|
delete from "tag_asset"
|
||||||
|
where
|
||||||
|
"assetsId" = $1
|
||||||
|
insert into
|
||||||
|
"tag_asset" ("tagsId", "assetsId")
|
||||||
|
values
|
||||||
|
($1, $2)
|
||||||
|
on conflict do nothing
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
rollback
|
||||||
|
|
||||||
|
-- TagRepository.deleteEmptyTags
|
||||||
|
begin
|
||||||
|
select
|
||||||
|
"tags"."id",
|
||||||
|
count("assets"."id") as "count"
|
||||||
|
from
|
||||||
|
"assets"
|
||||||
|
inner join "tag_asset" on "tag_asset"."assetsId" = "assets"."id"
|
||||||
|
inner join "tags_closure" on "tags_closure"."id_descendant" = "tag_asset"."tagsId"
|
||||||
|
inner join "tags" on "tags"."id" = "tags_closure"."id_descendant"
|
||||||
|
group by
|
||||||
|
"tags"."id"
|
||||||
|
commit
|
||||||
|
@ -1,209 +1,188 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { columns } from 'src/database';
|
||||||
|
import { DB, TagAsset, Tags } from 'src/db';
|
||||||
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { TagEntity } from 'src/entities/tag.entity';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { DataSource, In, Repository } from 'typeorm';
|
|
||||||
|
|
||||||
export type AssetTagItem = { assetId: string; tagId: string };
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TagRepository {
|
export class TagRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource() private dataSource: DataSource,
|
@InjectKysely() private db: Kysely<DB>,
|
||||||
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
|
|
||||||
private logger: LoggingRepository,
|
private logger: LoggingRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(TagRepository.name);
|
this.logger.setContext(TagRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): Promise<TagEntity | null> {
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
return this.repository.findOne({ where: { id } });
|
get(id: string) {
|
||||||
|
return this.db.selectFrom('tags').select(columns.tagDto).where('id', '=', id).executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
getByValue(userId: string, value: string): Promise<TagEntity | null> {
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
return this.repository.findOne({ where: { userId, value } });
|
getByValue(userId: string, value: string) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('tags')
|
||||||
|
.select(columns.tagDto)
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.where('value', '=', value)
|
||||||
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertValue({
|
@GenerateSql({ params: [{ userId: DummyValue.UUID, value: DummyValue.STRING, parentId: DummyValue.UUID }] })
|
||||||
userId,
|
async upsertValue({ userId, value, parentId: _parentId }: { userId: string; value: string; parentId?: string }) {
|
||||||
value,
|
const parentId = _parentId ?? null;
|
||||||
parent,
|
return this.db.transaction().execute(async (tx) => {
|
||||||
}: {
|
const tag = await this.db
|
||||||
userId: string;
|
.insertInto('tags')
|
||||||
value: string;
|
.values({ userId, value, parentId })
|
||||||
parent?: TagEntity;
|
.onConflict((oc) => oc.columns(['userId', 'value']).doUpdateSet({ parentId }))
|
||||||
}): Promise<TagEntity> {
|
.returningAll()
|
||||||
return this.dataSource.transaction(async (manager) => {
|
.executeTakeFirstOrThrow();
|
||||||
// upsert tag
|
|
||||||
const { identifiers } = await manager.upsert(
|
|
||||||
TagEntity,
|
|
||||||
{ userId, value, parentId: parent?.id },
|
|
||||||
{ conflictPaths: { userId: true, value: true } },
|
|
||||||
);
|
|
||||||
const id = identifiers[0]?.id;
|
|
||||||
if (!id) {
|
|
||||||
throw new Error('Failed to upsert tag');
|
|
||||||
}
|
|
||||||
|
|
||||||
// update closure table
|
// update closure table
|
||||||
await manager.query(
|
await tx
|
||||||
`INSERT INTO tags_closure (id_ancestor, id_descendant)
|
.insertInto('tags_closure')
|
||||||
VALUES ($1, $1)
|
.values({ id_ancestor: tag.id, id_descendant: tag.id })
|
||||||
ON CONFLICT DO NOTHING;`,
|
.onConflict((oc) => oc.doNothing())
|
||||||
[id],
|
.execute();
|
||||||
);
|
|
||||||
|
|
||||||
if (parent) {
|
if (parentId) {
|
||||||
await manager.query(
|
await tx
|
||||||
`INSERT INTO tags_closure (id_ancestor, id_descendant)
|
.insertInto('tags_closure')
|
||||||
SELECT id_ancestor, '${id}' as id_descendant FROM tags_closure WHERE id_descendant = $1
|
.columns(['id_ancestor', 'id_descendant'])
|
||||||
ON CONFLICT DO NOTHING`,
|
.expression(
|
||||||
[parent.id],
|
this.db
|
||||||
);
|
.selectFrom('tags_closure')
|
||||||
|
.select(['id_ancestor', sql.raw<string>(`'${tag.id}'`).as('id_descendant')])
|
||||||
|
.where('id_descendant', '=', parentId),
|
||||||
|
)
|
||||||
|
.onConflict((oc) => oc.doNothing())
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
return manager.findOneOrFail(TagEntity, { where: { id } });
|
return tag;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(userId: string): Promise<TagEntity[]> {
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
const tags = await this.repository.find({
|
getAll(userId: string) {
|
||||||
where: { userId },
|
return this.db
|
||||||
order: {
|
.selectFrom('tags')
|
||||||
value: 'ASC',
|
.select(columns.tagDto)
|
||||||
},
|
.where('userId', '=', userId)
|
||||||
});
|
.orderBy('value asc')
|
||||||
|
.execute();
|
||||||
return tags;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(tag: Partial<TagEntity>): Promise<TagEntity> {
|
@GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] })
|
||||||
return this.save(tag);
|
create(tag: Insertable<Tags>) {
|
||||||
|
return this.db.insertInto('tags').values(tag).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(tag: Partial<TagEntity>): Promise<TagEntity> {
|
@GenerateSql({ params: [DummyValue.UUID, { color: DummyValue.STRING }] })
|
||||||
return this.save(tag);
|
update(id: string, dto: Updateable<Tags>) {
|
||||||
|
return this.db.updateTable('tags').set(dto).where('id', '=', id).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
await this.repository.delete(id);
|
async delete(id: string) {
|
||||||
|
await this.db.deleteFrom('tags').where('id', '=', id).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
|
||||||
@ChunkedSet({ paramIndex: 1 })
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||||
async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
|
async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
|
||||||
if (assetIds.length === 0) {
|
if (assetIds.length === 0) {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await this.dataSource
|
const results = await this.db
|
||||||
.createQueryBuilder()
|
.selectFrom('tag_asset')
|
||||||
.select('tag_asset.assetsId', 'assetId')
|
.select(['assetsId as assetId'])
|
||||||
.from('tag_asset', 'tag_asset')
|
.where('tagsId', '=', tagId)
|
||||||
.where('"tag_asset"."tagsId" = :tagId', { tagId })
|
.where('assetsId', 'in', assetIds)
|
||||||
.andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
|
.execute();
|
||||||
.getRawMany<{ assetId: string }>();
|
|
||||||
|
|
||||||
return new Set(results.map(({ assetId }) => assetId));
|
return new Set(results.map(({ assetId }) => assetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||||
|
@Chunked({ paramIndex: 1 })
|
||||||
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
||||||
if (assetIds.length === 0) {
|
if (assetIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.manager
|
await this.db
|
||||||
.createQueryBuilder()
|
.insertInto('tag_asset')
|
||||||
.insert()
|
|
||||||
.into('tag_asset', ['tagsId', 'assetsId'])
|
|
||||||
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
|
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||||
@Chunked({ paramIndex: 1 })
|
@Chunked({ paramIndex: 1 })
|
||||||
async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
||||||
if (assetIds.length === 0) {
|
if (assetIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource
|
await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute();
|
||||||
.createQueryBuilder()
|
|
||||||
.delete()
|
|
||||||
.from('tag_asset')
|
|
||||||
.where({
|
|
||||||
tagsId: tagId,
|
|
||||||
assetsId: In(assetIds),
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }] })
|
||||||
@Chunked()
|
@Chunked()
|
||||||
async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
|
upsertAssetIds(items: Insertable<TagAsset>[]) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return [];
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { identifiers } = await this.dataSource
|
return this.db
|
||||||
.createQueryBuilder()
|
.insertInto('tag_asset')
|
||||||
.insert()
|
.values(items)
|
||||||
.into('tag_asset', ['assetsId', 'tagsId'])
|
.onConflict((oc) => oc.doNothing())
|
||||||
.values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
|
.returningAll()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
|
|
||||||
assetId: assetsId,
|
|
||||||
tagId: tagsId,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||||
await this.dataSource.transaction(async (manager) => {
|
@Chunked({ paramIndex: 1 })
|
||||||
await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
|
replaceAssetTags(assetId: string, tagIds: string[]) {
|
||||||
|
return this.db.transaction().execute(async (tx) => {
|
||||||
|
await tx.deleteFrom('tag_asset').where('assetsId', '=', assetId).execute();
|
||||||
|
|
||||||
if (tagIds.length === 0) {
|
if (tagIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await manager
|
return tx
|
||||||
.createQueryBuilder()
|
.insertInto('tag_asset')
|
||||||
.insert()
|
|
||||||
.into('tag_asset', ['tagsId', 'assetsId'])
|
|
||||||
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
|
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
|
||||||
|
.onConflict((oc) => oc.doNothing())
|
||||||
|
.returningAll()
|
||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql()
|
||||||
async deleteEmptyTags() {
|
async deleteEmptyTags() {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
// TODO rewrite as a single statement
|
||||||
const ids = new Set<string>();
|
await this.db.transaction().execute(async (tx) => {
|
||||||
const tags = await manager.find(TagEntity);
|
const result = await tx
|
||||||
for (const tag of tags) {
|
.selectFrom('assets')
|
||||||
const count = await manager
|
.innerJoin('tag_asset', 'tag_asset.assetsId', 'assets.id')
|
||||||
.createQueryBuilder('assets', 'asset')
|
.innerJoin('tags_closure', 'tags_closure.id_descendant', 'tag_asset.tagsId')
|
||||||
.innerJoin(
|
.innerJoin('tags', 'tags.id', 'tags_closure.id_descendant')
|
||||||
'asset.tags',
|
.select((eb) => ['tags.id', eb.fn.count<number>('assets.id').as('count')])
|
||||||
'asset_tags',
|
.groupBy('tags.id')
|
||||||
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
|
.execute();
|
||||||
{ tagId: tag.id },
|
|
||||||
)
|
|
||||||
.getCount();
|
|
||||||
|
|
||||||
if (count === 0) {
|
const ids = result.filter(({ count }) => count === 0).map(({ id }) => id);
|
||||||
this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`);
|
if (ids.length > 0) {
|
||||||
ids.add(tag.id);
|
await this.db.deleteFrom('tags').where('id', 'in', ids).execute();
|
||||||
}
|
this.logger.log(`Deleted ${ids.length} empty tags`);
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.size > 0) {
|
|
||||||
await manager.delete(TagEntity, { id: In([...ids]) });
|
|
||||||
this.logger.log(`Deleted ${ids.size} empty tags`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
|
|
||||||
const { id } = await this.repository.save(partial);
|
|
||||||
return this.repository.findOneOrFail({ where: { id } });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -241,7 +241,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract tags from TagsList', async () => {
|
it('should extract tags from TagsList', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ TagsList: ['Parent'] });
|
mockReadTags({ TagsList: ['Parent'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
@ -251,27 +251,27 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract hierarchy from TagsList', async () => {
|
it('should extract hierarchy from TagsList', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
parent: tagStub.parent,
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should extract tags from Keywords as a string', async () => {
|
it('should extract tags from Keywords as a string', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ Keywords: 'Parent' });
|
mockReadTags({ Keywords: 'Parent' });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
@ -281,7 +281,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract tags from Keywords as a list', async () => {
|
it('should extract tags from Keywords as a list', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ Keywords: ['Parent'] });
|
mockReadTags({ Keywords: ['Parent'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
@ -291,7 +291,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract tags from Keywords as a list with a number', async () => {
|
it('should extract tags from Keywords as a list with a number', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
@ -302,58 +302,58 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract hierarchal tags from Keywords', async () => {
|
it('should extract hierarchal tags from Keywords', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ Keywords: 'Parent/Child' });
|
mockReadTags({ Keywords: 'Parent/Child' });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
parent: tagStub.parent,
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore Keywords when TagsList is present', async () => {
|
it('should ignore Keywords when TagsList is present', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
parent: tagStub.parent,
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
parent: tagStub.parent,
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
||||||
});
|
});
|
||||||
@ -361,7 +361,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
@ -372,7 +372,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
@ -386,19 +386,19 @@ describe(MetadataService.name, () => {
|
|||||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
parent: tagStub.parent,
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -408,7 +408,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.tag.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] });
|
expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith('asset-id', []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not apply motion photos if asset is video', async () => {
|
it('should not apply motion photos if asset is video', async () => {
|
||||||
|
@ -390,7 +390,10 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
||||||
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
|
await this.tagRepository.replaceAssetTags(
|
||||||
|
asset.id,
|
||||||
|
results.map((tag) => tag.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
|
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
|
||||||
|
@ -22,7 +22,7 @@ describe(TagService.name, () => {
|
|||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('should return all tags for a user', async () => {
|
it('should return all tags for a user', async () => {
|
||||||
mocks.tag.getAll.mockResolvedValue([tagStub.tag1]);
|
mocks.tag.getAll.mockResolvedValue([tagStub.tag]);
|
||||||
await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]);
|
await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]);
|
||||||
expect(mocks.tag.getAll).toHaveBeenCalledWith(authStub.admin.user.id);
|
expect(mocks.tag.getAll).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||||
});
|
});
|
||||||
@ -30,13 +30,12 @@ describe(TagService.name, () => {
|
|||||||
|
|
||||||
describe('get', () => {
|
describe('get', () => {
|
||||||
it('should throw an error for an invalid id', async () => {
|
it('should throw an error for an invalid id', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(null);
|
|
||||||
await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.tag.get).toHaveBeenCalledWith('tag-1');
|
expect(mocks.tag.get).toHaveBeenCalledWith('tag-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a tag for a user', async () => {
|
it('should return a tag for a user', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(tagStub.tag1);
|
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||||
await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
|
await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
|
||||||
expect(mocks.tag.get).toHaveBeenCalledWith('tag-1');
|
expect(mocks.tag.get).toHaveBeenCalledWith('tag-1');
|
||||||
});
|
});
|
||||||
@ -53,9 +52,9 @@ describe(TagService.name, () => {
|
|||||||
|
|
||||||
it('should create a tag with a parent', async () => {
|
it('should create a tag with a parent', async () => {
|
||||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
|
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
|
||||||
mocks.tag.create.mockResolvedValue(tagStub.tag1);
|
mocks.tag.create.mockResolvedValue(tagStub.tagCreate);
|
||||||
mocks.tag.get.mockResolvedValueOnce(tagStub.parent);
|
mocks.tag.get.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.get.mockResolvedValueOnce(tagStub.child);
|
mocks.tag.get.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
|
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
|
||||||
expect(mocks.tag.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
|
expect(mocks.tag.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
|
||||||
});
|
});
|
||||||
@ -71,14 +70,14 @@ describe(TagService.name, () => {
|
|||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should throw an error for a duplicate tag', async () => {
|
it('should throw an error for a duplicate tag', async () => {
|
||||||
mocks.tag.getByValue.mockResolvedValue(tagStub.tag1);
|
mocks.tag.getByValue.mockResolvedValue(tagStub.tag);
|
||||||
await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.tag.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
|
expect(mocks.tag.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
|
||||||
expect(mocks.tag.create).not.toHaveBeenCalled();
|
expect(mocks.tag.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new tag', async () => {
|
it('should create a new tag', async () => {
|
||||||
mocks.tag.create.mockResolvedValue(tagStub.tag1);
|
mocks.tag.create.mockResolvedValue(tagStub.tagCreate);
|
||||||
await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
|
await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
|
||||||
expect(mocks.tag.create).toHaveBeenCalledWith({
|
expect(mocks.tag.create).toHaveBeenCalledWith({
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
@ -87,7 +86,7 @@ describe(TagService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new tag with optional color', async () => {
|
it('should create a new tag with optional color', async () => {
|
||||||
mocks.tag.create.mockResolvedValue(tagStub.color1);
|
mocks.tag.create.mockResolvedValue(tagStub.colorCreate);
|
||||||
await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual(
|
await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual(
|
||||||
tagResponseStub.color1,
|
tagResponseStub.color1,
|
||||||
);
|
);
|
||||||
@ -110,15 +109,15 @@ describe(TagService.name, () => {
|
|||||||
|
|
||||||
it('should update a tag', async () => {
|
it('should update a tag', async () => {
|
||||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
|
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
|
||||||
mocks.tag.update.mockResolvedValue(tagStub.color1);
|
mocks.tag.update.mockResolvedValue(tagStub.colorCreate);
|
||||||
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
|
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
|
||||||
expect(mocks.tag.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
|
expect(mocks.tag.update).toHaveBeenCalledWith('tag-1', { color: '#000000' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('upsert', () => {
|
describe('upsert', () => {
|
||||||
it('should upsert a new tag', async () => {
|
it('should upsert a new tag', async () => {
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
|
await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({
|
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
@ -128,36 +127,34 @@ describe(TagService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should upsert a nested tag', async () => {
|
it('should upsert a nested tag', async () => {
|
||||||
mocks.tag.getByValue.mockResolvedValueOnce(null);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child);
|
|
||||||
await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
|
await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
userId: 'admin_id',
|
userId: 'admin_id',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
userId: 'admin_id',
|
userId: 'admin_id',
|
||||||
parent: expect.objectContaining({ id: 'tag-parent' }),
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should upsert a tag and ignore leading and trailing slashes', async () => {
|
it('should upsert a tag and ignore leading and trailing slashes', async () => {
|
||||||
mocks.tag.getByValue.mockResolvedValueOnce(null);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child);
|
|
||||||
await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined();
|
await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined();
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
userId: 'admin_id',
|
userId: 'admin_id',
|
||||||
parent: undefined,
|
parentId: undefined,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
userId: 'admin_id',
|
userId: 'admin_id',
|
||||||
parent: expect.objectContaining({ id: 'tag-parent' }),
|
parentId: 'tag-parent',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -170,7 +167,7 @@ describe(TagService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should remove a tag', async () => {
|
it('should remove a tag', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(tagStub.tag1);
|
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||||
await sut.remove(authStub.admin, 'tag-1');
|
await sut.remove(authStub.admin, 'tag-1');
|
||||||
expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1');
|
expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1');
|
||||||
});
|
});
|
||||||
@ -190,12 +187,12 @@ describe(TagService.name, () => {
|
|||||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
{ tagsId: 'tag-1', assetsId: 'asset-1' },
|
||||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
{ tagsId: 'tag-1', assetsId: 'asset-2' },
|
||||||
{ tagId: 'tag-1', assetId: 'asset-3' },
|
{ tagsId: 'tag-1', assetsId: 'asset-3' },
|
||||||
{ tagId: 'tag-2', assetId: 'asset-1' },
|
{ tagsId: 'tag-2', assetsId: 'asset-1' },
|
||||||
{ tagId: 'tag-2', assetId: 'asset-2' },
|
{ tagsId: 'tag-2', assetsId: 'asset-2' },
|
||||||
{ tagId: 'tag-2', assetId: 'asset-3' },
|
{ tagsId: 'tag-2', assetsId: 'asset-3' },
|
||||||
]);
|
]);
|
||||||
await expect(
|
await expect(
|
||||||
sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
|
sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||||
@ -203,19 +200,18 @@ describe(TagService.name, () => {
|
|||||||
count: 6,
|
count: 6,
|
||||||
});
|
});
|
||||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
{ tagsId: 'tag-1', assetsId: 'asset-1' },
|
||||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
{ tagsId: 'tag-1', assetsId: 'asset-2' },
|
||||||
{ tagId: 'tag-1', assetId: 'asset-3' },
|
{ tagsId: 'tag-1', assetsId: 'asset-3' },
|
||||||
{ tagId: 'tag-2', assetId: 'asset-1' },
|
{ tagsId: 'tag-2', assetsId: 'asset-1' },
|
||||||
{ tagId: 'tag-2', assetId: 'asset-2' },
|
{ tagsId: 'tag-2', assetsId: 'asset-2' },
|
||||||
{ tagId: 'tag-2', assetId: 'asset-3' },
|
{ tagsId: 'tag-2', assetsId: 'asset-3' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addAssets', () => {
|
describe('addAssets', () => {
|
||||||
it('should handle invalid ids', async () => {
|
it('should handle invalid ids', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(null);
|
|
||||||
mocks.tag.getAssetIds.mockResolvedValue(new Set([]));
|
mocks.tag.getAssetIds.mockResolvedValue(new Set([]));
|
||||||
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
||||||
{ id: 'asset-1', success: false, error: 'no_permission' },
|
{ id: 'asset-1', success: false, error: 'no_permission' },
|
||||||
@ -225,7 +221,7 @@ describe(TagService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept accept ids that are new and reject the rest', async () => {
|
it('should accept accept ids that are new and reject the rest', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(tagStub.tag1);
|
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||||
|
|
||||||
@ -245,7 +241,6 @@ describe(TagService.name, () => {
|
|||||||
|
|
||||||
describe('removeAssets', () => {
|
describe('removeAssets', () => {
|
||||||
it('should throw an error for an invalid id', async () => {
|
it('should throw an error for an invalid id', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(null);
|
|
||||||
mocks.tag.getAssetIds.mockResolvedValue(new Set());
|
mocks.tag.getAssetIds.mockResolvedValue(new Set());
|
||||||
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
||||||
{ id: 'asset-1', success: false, error: 'not_found' },
|
{ id: 'asset-1', success: false, error: 'not_found' },
|
||||||
@ -253,7 +248,7 @@ describe(TagService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept accept ids that are tagged and reject the rest', async () => {
|
it('should accept accept ids that are tagged and reject the rest', async () => {
|
||||||
mocks.tag.get.mockResolvedValue(tagStub.tag1);
|
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { Insertable } from 'kysely';
|
||||||
|
import { TagAsset } from 'src/db';
|
||||||
import { OnJob } from 'src/decorators';
|
import { OnJob } from 'src/decorators';
|
||||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
@ -11,9 +13,7 @@ import {
|
|||||||
TagUpsertDto,
|
TagUpsertDto,
|
||||||
mapTag,
|
mapTag,
|
||||||
} from 'src/dtos/tag.dto';
|
} from 'src/dtos/tag.dto';
|
||||||
import { TagEntity } from 'src/entities/tag.entity';
|
|
||||||
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||||
import { AssetTagItem } from 'src/repositories/tag.repository';
|
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||||
import { upsertTags } from 'src/utils/tag';
|
import { upsertTags } from 'src/utils/tag';
|
||||||
@ -32,10 +32,10 @@ export class TagService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(auth: AuthDto, dto: TagCreateDto) {
|
async create(auth: AuthDto, dto: TagCreateDto) {
|
||||||
let parent: TagEntity | undefined;
|
let parent;
|
||||||
if (dto.parentId) {
|
if (dto.parentId) {
|
||||||
await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
|
await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
|
||||||
parent = (await this.tagRepository.get(dto.parentId)) || undefined;
|
parent = await this.tagRepository.get(dto.parentId);
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
throw new BadRequestException('Tag not found');
|
throw new BadRequestException('Tag not found');
|
||||||
}
|
}
|
||||||
@ -49,7 +49,7 @@ export class TagService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { color } = dto;
|
const { color } = dto;
|
||||||
const tag = await this.tagRepository.create({ userId, value, color, parent });
|
const tag = await this.tagRepository.create({ userId, value, color, parentId: parent?.id });
|
||||||
|
|
||||||
return mapTag(tag);
|
return mapTag(tag);
|
||||||
}
|
}
|
||||||
@ -58,7 +58,7 @@ export class TagService extends BaseService {
|
|||||||
await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] });
|
||||||
|
|
||||||
const { color } = dto;
|
const { color } = dto;
|
||||||
const tag = await this.tagRepository.update({ id, color });
|
const tag = await this.tagRepository.update(id, { color });
|
||||||
return mapTag(tag);
|
return mapTag(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,15 +81,15 @@ export class TagService extends BaseService {
|
|||||||
this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
|
this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const items: AssetTagItem[] = [];
|
const items: Insertable<TagAsset>[] = [];
|
||||||
for (const tagId of tagIds) {
|
for (const tagsId of tagIds) {
|
||||||
for (const assetId of assetIds) {
|
for (const assetsId of assetIds) {
|
||||||
items.push({ tagId, assetId });
|
items.push({ tagsId, assetsId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await this.tagRepository.upsertAssetIds(items);
|
const results = await this.tagRepository.upsertAssetIds(items);
|
||||||
for (const assetId of new Set(results.map((item) => item.assetId))) {
|
for (const assetId of new Set(results.map((item) => item.assetsId))) {
|
||||||
await this.eventRepository.emit('asset.tag', { assetId });
|
await this.eventRepository.emit('asset.tag', { assetId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,6 +37,15 @@ export type MemoryItem =
|
|||||||
|
|
||||||
export type SessionItem = Awaited<ReturnType<ISessionRepository['getByUserId']>>[0];
|
export type SessionItem = Awaited<ReturnType<ISessionRepository['getByUserId']>>[0];
|
||||||
|
|
||||||
|
export type TagItem = {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
color: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface CropOptions {
|
export interface CropOptions {
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import { TagEntity } from 'src/entities/tag.entity';
|
|
||||||
import { TagRepository } from 'src/repositories/tag.repository';
|
import { TagRepository } from 'src/repositories/tag.repository';
|
||||||
|
import { TagItem } from 'src/types';
|
||||||
|
|
||||||
type UpsertRequest = { userId: string; tags: string[] };
|
type UpsertRequest = { userId: string; tags: string[] };
|
||||||
export const upsertTags = async (repository: TagRepository, { userId, tags }: UpsertRequest) => {
|
export const upsertTags = async (repository: TagRepository, { userId, tags }: UpsertRequest) => {
|
||||||
tags = [...new Set(tags)];
|
tags = [...new Set(tags)];
|
||||||
|
|
||||||
const results: TagEntity[] = [];
|
const results: TagItem[] = [];
|
||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
const parts = tag.split('/').filter(Boolean);
|
const parts = tag.split('/').filter(Boolean);
|
||||||
let parent: TagEntity | undefined;
|
let parent: TagItem | undefined;
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const value = parent ? `${parent.value}/${part}` : part;
|
const value = parent ? `${parent.value}/${part}` : part;
|
||||||
parent = await repository.upsertValue({ userId, value, parent });
|
parent = await repository.upsertValue({ userId, value, parentId: parent?.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
|
42
server/test/fixtures/tag.stub.ts
vendored
42
server/test/fixtures/tag.stub.ts
vendored
@ -1,49 +1,51 @@
|
|||||||
import { TagResponseDto } from 'src/dtos/tag.dto';
|
import { TagResponseDto } from 'src/dtos/tag.dto';
|
||||||
import { TagEntity } from 'src/entities/tag.entity';
|
import { TagItem } from 'src/types';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
|
||||||
|
|
||||||
const parent = Object.freeze<TagEntity>({
|
const parent = Object.freeze<TagItem>({
|
||||||
id: 'tag-parent',
|
id: 'tag-parent',
|
||||||
createdAt: new Date('2021-01-01T00:00:00Z'),
|
createdAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
value: 'Parent',
|
value: 'Parent',
|
||||||
color: null,
|
color: null,
|
||||||
userId: userStub.admin.id,
|
parentId: null,
|
||||||
user: userStub.admin,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const child = Object.freeze<TagEntity>({
|
const child = Object.freeze<TagItem>({
|
||||||
id: 'tag-child',
|
id: 'tag-child',
|
||||||
createdAt: new Date('2021-01-01T00:00:00Z'),
|
createdAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
value: 'Parent/Child',
|
value: 'Parent/Child',
|
||||||
color: null,
|
color: null,
|
||||||
parent,
|
parentId: parent.id,
|
||||||
userId: userStub.admin.id,
|
|
||||||
user: userStub.admin,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tagStub = {
|
const tag = {
|
||||||
tag1: Object.freeze<TagEntity>({
|
|
||||||
id: 'tag-1',
|
id: 'tag-1',
|
||||||
createdAt: new Date('2021-01-01T00:00:00Z'),
|
createdAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
value: 'Tag1',
|
value: 'Tag1',
|
||||||
color: null,
|
color: null,
|
||||||
userId: userStub.admin.id,
|
parentId: null,
|
||||||
user: userStub.admin,
|
};
|
||||||
}),
|
|
||||||
parent,
|
const color = {
|
||||||
child,
|
|
||||||
color1: Object.freeze<TagEntity>({
|
|
||||||
id: 'tag-1',
|
id: 'tag-1',
|
||||||
createdAt: new Date('2021-01-01T00:00:00Z'),
|
createdAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
updatedAt: new Date('2021-01-01T00:00:00Z'),
|
||||||
value: 'Tag1',
|
value: 'Tag1',
|
||||||
color: '#000000',
|
color: '#000000',
|
||||||
userId: userStub.admin.id,
|
parentId: null,
|
||||||
user: userStub.admin,
|
};
|
||||||
}),
|
|
||||||
|
const upsert = { userId: 'tag-user', updateId: 'uuid-v7' };
|
||||||
|
|
||||||
|
export const tagStub = {
|
||||||
|
tag,
|
||||||
|
tagCreate: { ...tag, ...upsert },
|
||||||
|
color,
|
||||||
|
colorCreate: { ...color, ...upsert },
|
||||||
|
parentUpsert: { ...parent, ...upsert },
|
||||||
|
childUpsert: { ...child, ...upsert },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tagResponseStub = {
|
export const tagResponseStub = {
|
||||||
|
@ -7,7 +7,7 @@ export const newTagRepositoryMock = (): Mocked<RepositoryInterface<TagRepository
|
|||||||
getAll: vitest.fn(),
|
getAll: vitest.fn(),
|
||||||
getByValue: vitest.fn(),
|
getByValue: vitest.fn(),
|
||||||
upsertValue: vitest.fn(),
|
upsertValue: vitest.fn(),
|
||||||
upsertAssetTags: vitest.fn(),
|
replaceAssetTags: vitest.fn(),
|
||||||
|
|
||||||
get: vitest.fn(),
|
get: vitest.fn(),
|
||||||
create: vitest.fn(),
|
create: vitest.fn(),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user