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