From 976204d064fd44d745f47aa807ef55203994f485 Mon Sep 17 00:00:00 2001 From: khatabwedaa Date: Wed, 15 Apr 2026 15:49:47 +0300 Subject: [PATCH 1/5] Removes uuid dependency and improves code formatting Removes the unused uuid package dependency to reduce bundle size and minimize external dependencies. Reformats code throughout the service to improve readability by: - Organizing imports alphabetically and grouping related imports - Breaking long function signatures across multiple lines - Improving consistency in constructor parameter formatting - Applying consistent multiline formatting for object literals Expands test coverage to include: - Event emission verification for tag lifecycle operations - Edge cases for type-filtered tag queries - Static instance lifecycle management - Fallback behavior when decorator metadata is missing --- package.json | 5 +- src/entities/tag.entity.ts | 1 + src/taggable.service.ts | 96 ++++++++++++++----- test/taggable.mixin.spec.ts | 162 ++++++++++++++++++++++++++++++++ test/taggable.service.spec.ts | 172 ++++++++++++++++++++++++++++++++++ 5 files changed, 408 insertions(+), 28 deletions(-) create mode 100644 test/taggable.mixin.spec.ts diff --git a/package.json b/package.json index 9671bac..12c94b7 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,7 @@ "engines": { "node": ">=20.0.0" }, - "dependencies": { - "uuid": "^11.0.0" - }, + "dependencies": {}, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -65,7 +63,6 @@ "@nestjs/typeorm": "^11.0.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.0.0", - "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.1.0", "better-sqlite3": "^12.8.0", "eslint": "^9.0.0", diff --git a/src/entities/tag.entity.ts b/src/entities/tag.entity.ts index 44f0d1c..bc0ab67 100644 --- a/src/entities/tag.entity.ts +++ b/src/entities/tag.entity.ts @@ -8,6 +8,7 @@ import { } from "typeorm"; @Entity("tags") +@Index(["slug", "type"], { unique: true }) export class TagEntity { @PrimaryGeneratedColumn("uuid") id!: string; diff --git a/src/taggable.service.ts b/src/taggable.service.ts index 26e0e50..5e41159 100644 --- a/src/taggable.service.ts +++ b/src/taggable.service.ts @@ -1,13 +1,19 @@ -import "reflect-metadata"; -import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, Optional } from "@nestjs/common"; +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, + Optional, +} from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { DataSource, Repository } from "typeorm"; -import { TAGGABLE_OPTIONS } from "./taggable.constants"; -import { TAGGABLE_METADATA_KEY } from "./taggable.constants"; -import { TAGGABLE_EVENTS } from "./events/taggable.events"; +import "reflect-metadata"; +import { Repository } from "typeorm"; import { TagEntity } from "./entities/tag.entity"; import { TaggableEntity } from "./entities/taggable.entity"; +import { TAGGABLE_EVENTS } from "./events/taggable.events"; import type { TaggableModuleOptions } from "./interfaces"; +import { TAGGABLE_METADATA_KEY, TAGGABLE_OPTIONS } from "./taggable.constants"; interface EventEmitterLike { emit(event: string, ...args: any[]): boolean; @@ -20,10 +26,13 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { constructor( @Inject(TAGGABLE_OPTIONS) private readonly options: TaggableModuleOptions, - @InjectRepository(TagEntity) private readonly tagRepo: Repository, - @InjectRepository(TaggableEntity) private readonly taggableRepo: Repository, - private readonly dataSource: DataSource, - @Optional() @Inject("EventEmitter2") private readonly eventEmitter?: EventEmitterLike, + @InjectRepository(TagEntity) + private readonly tagRepo: Repository, + @InjectRepository(TaggableEntity) + private readonly taggableRepo: Repository, + @Optional() + @Inject("EventEmitter2") + private readonly eventEmitter?: EventEmitterLike, ) {} onModuleInit(): void { @@ -49,7 +58,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { // --- Tag CRUD --- - async createTag(name: string, options?: { slug?: string; type?: string; metadata?: any }): Promise { + async createTag( + name: string, + options?: { slug?: string; type?: string; metadata?: any }, + ): Promise { const slug = options?.slug ?? this.slugify(name); const tag = this.tagRepo.create({ name, @@ -63,9 +75,14 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return saved; } - async findOrCreateTag(name: string, options?: { type?: string }): Promise { + async findOrCreateTag( + name: string, + options?: { type?: string }, + ): Promise { const slug = this.slugify(name); - const qb = this.tagRepo.createQueryBuilder("t").where("t.slug = :slug", { slug }); + const qb = this.tagRepo + .createQueryBuilder("t") + .where("t.slug = :slug", { slug }); if (options?.type) { qb.andWhere("t.type = :type", { type: options.type }); @@ -107,7 +124,11 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { // --- Attach / Detach --- - async attachTag(entityConstructor: Function, entityId: string, tagId: string): Promise { + async attachTag( + entityConstructor: Function, + entityId: string, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const existing = await this.taggableRepo.findOne({ @@ -123,12 +144,18 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { await this.taggableRepo.save(pivot); const tag = await this.tagRepo.findOne({ where: { id: tagId } }); - if (tag) { - this.emit(TAGGABLE_EVENTS.TAG_ATTACHED, { tag, taggableType, taggableId: entityId }); - } + this.emit(TAGGABLE_EVENTS.TAG_ATTACHED, { + tag, + taggableType, + taggableId: entityId, + }); } - async detachTag(entityConstructor: Function, entityId: string, tagId: string): Promise { + async detachTag( + entityConstructor: Function, + entityId: string, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); await this.taggableRepo.delete({ @@ -137,10 +164,18 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { taggableId: entityId, }); - this.emit(TAGGABLE_EVENTS.TAG_DETACHED, { tagId, taggableType, taggableId: entityId }); + this.emit(TAGGABLE_EVENTS.TAG_DETACHED, { + tagId, + taggableType, + taggableId: entityId, + }); } - async syncTags(entityConstructor: Function, entityId: string, tagIds: string[]): Promise { + async syncTags( + entityConstructor: Function, + entityId: string, + tagIds: string[], + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const existing = await this.taggableRepo.find({ @@ -162,7 +197,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { // --- Query --- - async getEntityTags(entityConstructor: Function, entityId: string): Promise { + async getEntityTags( + entityConstructor: Function, + entityId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const pivots = await this.taggableRepo.find({ @@ -173,7 +211,11 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return pivots.map((p) => p.tag).filter(Boolean); } - async hasTag(entityConstructor: Function, entityId: string, tagId: string): Promise { + async hasTag( + entityConstructor: Function, + entityId: string, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const count = await this.taggableRepo.count({ @@ -183,7 +225,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return count > 0; } - async getEntitiesWithTag(entityConstructor: Function, tagId: string): Promise { + async getEntitiesWithTag( + entityConstructor: Function, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const pivots = await this.taggableRepo.find({ @@ -193,7 +238,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return pivots.map((p) => p.taggableId); } - async getTagCount(entityConstructor: Function, entityId: string): Promise { + async getTagCount( + entityConstructor: Function, + entityId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); return this.taggableRepo.count({ diff --git a/test/taggable.mixin.spec.ts b/test/taggable.mixin.spec.ts new file mode 100644 index 0000000..1d14f65 --- /dev/null +++ b/test/taggable.mixin.spec.ts @@ -0,0 +1,162 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Entity, PrimaryGeneratedColumn, Column, DataSource } from "typeorm"; +import { TaggableModule } from "../src/taggable.module"; +import { TaggableService } from "../src/taggable.service"; +import { TagEntity } from "../src/entities/tag.entity"; +import { TaggableEntity } from "../src/entities/taggable.entity"; +import { Taggable } from "../src/decorators/taggable.decorator"; +import { TaggableMixin } from "../src/mixins/taggable.mixin"; +import { TaggableNotInitializedException } from "../src/exceptions/taggable-not-initialized.exception"; + +@Taggable({ type: "MixinPost" }) +@Entity("mixin_posts") +class MixinPost extends TaggableMixin(class { + id!: string; +}) { + @PrimaryGeneratedColumn("uuid") + declare id: string; + + @Column() + title!: string; +} + +@Entity("mixin_plain") +class MixinPlain extends TaggableMixin(class { + id!: string; +}) { + @PrimaryGeneratedColumn("uuid") + declare id: string; + + @Column() + name!: string; +} + +describe("TaggableMixin", () => { + let module: TestingModule; + let service: TaggableService; + let dataSource: DataSource; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [MixinPost, MixinPlain, TagEntity, TaggableEntity], + synchronize: true, + }), + TaggableModule.forRoot(), + ], + }).compile(); + + await module.init(); + service = module.get(TaggableService); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + describe("getTaggableType()", () => { + it("should return custom type from @Taggable metadata", () => { + const post = new MixinPost(); + expect(post.getTaggableType()).toBe("MixinPost"); + }); + + it("should default to class name when no metadata", () => { + const plain = new MixinPlain(); + expect(plain.getTaggableType()).toBe("MixinPlain"); + }); + }); + + describe("getTaggableId()", () => { + it("should return the entity id as string", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Test" })); + expect(post.getTaggableId()).toBe(post.id); + }); + }); + + describe("attachTag() / detachTag()", () => { + it("should attach and detach tags via mixin", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Test" })); + const tag = await service.createTag("MixinTag"); + + await post.attachTag(tag.id); + let tags = await post.getTags(); + expect(tags).toHaveLength(1); + expect(tags[0].name).toBe("MixinTag"); + + await post.detachTag(tag.id); + tags = await post.getTags(); + expect(tags).toHaveLength(0); + }); + }); + + describe("syncTags()", () => { + it("should sync tags via mixin", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Sync" })); + const tag1 = await service.createTag("Sync1"); + const tag2 = await service.createTag("Sync2"); + const tag3 = await service.createTag("Sync3"); + + await post.attachTag(tag1.id); + await post.attachTag(tag2.id); + + await post.syncTags([tag2.id, tag3.id]); + + const tags = await post.getTags(); + const names = tags.map((t) => t.name).sort(); + expect(names).toEqual(["Sync2", "Sync3"]); + }); + }); + + describe("hasTag()", () => { + it("should check if entity has a tag", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Has" })); + const tag = await service.createTag("CheckTag"); + + expect(await post.hasTag(tag.id)).toBe(false); + await post.attachTag(tag.id); + expect(await post.hasTag(tag.id)).toBe(true); + }); + }); + + describe("getTagCount()", () => { + it("should return tag count", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Count" })); + const tag1 = await service.createTag("C1"); + const tag2 = await service.createTag("C2"); + + expect(await post.getTagCount()).toBe(0); + await post.attachTag(tag1.id); + await post.attachTag(tag2.id); + expect(await post.getTagCount()).toBe(2); + }); + }); +}); + +describe("TaggableMixin without service", () => { + it("should throw TaggableNotInitializedException when service is not available", () => { + const post = new MixinPost(); + (post as any).id = "fake-id"; + expect(() => post.attachTag("fake-tag")).rejects.toThrow(TaggableNotInitializedException); + }); +}); + +describe("TaggableNotInitializedException", () => { + it("should have correct message and name", () => { + const error = new TaggableNotInitializedException(); + expect(error.message).toContain("TaggableModule has not been initialized"); + expect(error.name).toBe("TaggableNotInitializedException"); + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/test/taggable.service.spec.ts b/test/taggable.service.spec.ts index 7542367..55161a8 100644 --- a/test/taggable.service.spec.ts +++ b/test/taggable.service.spec.ts @@ -7,6 +7,7 @@ import { TaggableService } from "../src/taggable.service"; import { TagEntity } from "../src/entities/tag.entity"; import { TaggableEntity } from "../src/entities/taggable.entity"; import { Taggable } from "../src/decorators/taggable.decorator"; +import { TaggableModule } from "../src/taggable.module"; import { TAGGABLE_OPTIONS } from "../src/taggable.constants"; @Taggable({ type: "Post" }) @@ -297,4 +298,175 @@ describe("TaggableService", () => { expect(articleTags).toHaveLength(0); }); }); + + describe("getOptions()", () => { + it("should return the module options", () => { + const options = service.getOptions(); + expect(options).toBeDefined(); + expect(typeof options).toBe("object"); + }); + }); + + describe("findOrCreateTag() with type", () => { + it("should filter by type when provided", async () => { + await service.createTag("Same Name", { type: "category" }); + await service.createTag("Same Name", { type: "label", slug: "same-name-label" }); + + const found = await service.findOrCreateTag("Same Name", { type: "category" }); + expect(found.type).toBe("category"); + }); + }); + + describe("findTagBySlug() with type", () => { + it("should filter by type when provided", async () => { + await service.createTag("ByType", { type: "special" }); + const found = await service.findTagBySlug("bytype", "special"); + expect(found).not.toBeNull(); + expect(found!.type).toBe("special"); + }); + + it("should return null when type does not match", async () => { + await service.createTag("ByType2", { type: "special" }); + const found = await service.findTagBySlug("bytype2", "other"); + expect(found).toBeNull(); + }); + }); + + describe("deleteTag() nonexistent", () => { + it("should handle deleting a nonexistent tag", async () => { + await service.deleteTag("00000000-0000-0000-0000-000000000000"); + // No error thrown + }); + }); + + describe("static instance lifecycle", () => { + it("should clear instance on module destroy", async () => { + expect(TaggableService.getInstance()).toBe(service); + await module.close(); + expect(TaggableService.getInstance()).toBeNull(); + module = undefined as any; + }); + + it("should not clear instance if it was replaced by another", async () => { + const origInstance = TaggableService.getInstance(); + // Replace static instance with a different value + (TaggableService as any).instance = { fake: true }; + + // Destroy the module — onModuleDestroy should NOT clear since instance !== this + await module.close(); + expect(TaggableService.getInstance()).toEqual({ fake: true }); + + // Cleanup + (TaggableService as any).instance = null; + module = undefined as any; + }); + }); + + + describe("resolveTaggableType() without decorator", () => { + it("should fall back to class name when no @Taggable metadata", async () => { + // Use a plain class without @Taggable decorator + class PlainEntity { + id!: string; + } + + const tag = await service.createTag("PlainTag"); + const postRepo = dataSource.getRepository(Post); + const post = await postRepo.save(postRepo.create({ title: "Plain" })); + + // Calling with PlainEntity — resolveTaggableType falls back to class name + await service.attachTag(PlainEntity as any, post.id, tag.id); + const has = await service.hasTag(PlainEntity as any, post.id, tag.id); + expect(has).toBe(true); + }); + }); +}); + +describe("TaggableService with events", () => { + let module: TestingModule; + let service: TaggableService; + let dataSource: DataSource; + const emitted: { event: string; payload: any }[] = []; + + beforeEach(async () => { + emitted.length = 0; + + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [Post, Article, TagEntity, TaggableEntity], + synchronize: true, + }), + TypeOrmModule.forFeature([TagEntity, TaggableEntity]), + ], + providers: [ + { provide: TAGGABLE_OPTIONS, useValue: {} }, + { + provide: "EventEmitter2", + useValue: { + emit(event: string, payload: any) { + emitted.push({ event, payload }); + return true; + }, + }, + }, + TaggableService, + ], + }).compile(); + + await module.init(); + service = module.get(TaggableService); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + it("should emit tag.created event", async () => { + await service.createTag("EventTag"); + const evt = emitted.find((e) => e.event === "tag.created"); + expect(evt).toBeDefined(); + expect(evt!.payload.tag.name).toBe("EventTag"); + }); + + it("should emit tag.deleted event", async () => { + const tag = await service.createTag("DeleteMe"); + emitted.length = 0; + + await service.deleteTag(tag.id); + const evt = emitted.find((e) => e.event === "tag.deleted"); + expect(evt).toBeDefined(); + expect(evt!.payload.tagId).toBe(tag.id); + expect(evt!.payload.tagName).toBe("DeleteMe"); + }); + + it("should emit tag.attached event", async () => { + const tag = await service.createTag("AttachEvent"); + const postRepo = dataSource.getRepository(Post); + const post = await postRepo.save(postRepo.create({ title: "Evt Post" })); + emitted.length = 0; + + await service.attachTag(Post, post.id, tag.id); + const evt = emitted.find((e) => e.event === "tag.attached"); + expect(evt).toBeDefined(); + expect(evt!.payload.tag.name).toBe("AttachEvent"); + expect(evt!.payload.taggableType).toBe("Post"); + expect(evt!.payload.taggableId).toBe(post.id); + }); + + it("should emit tag.detached event", async () => { + const tag = await service.createTag("DetachEvent"); + const postRepo = dataSource.getRepository(Post); + const post = await postRepo.save(postRepo.create({ title: "Evt Post" })); + await service.attachTag(Post, post.id, tag.id); + emitted.length = 0; + + await service.detachTag(Post, post.id, tag.id); + const evt = emitted.find((e) => e.event === "tag.detached"); + expect(evt).toBeDefined(); + expect(evt!.payload.tagId).toBe(tag.id); + }); }); From 683f740e2eb7fb08442f07efcab08f4c28403f7a Mon Sep 17 00:00:00 2001 From: khatabwedaa Date: Wed, 15 Apr 2026 15:51:01 +0300 Subject: [PATCH 2/5] Refactor TaggableModuleOptions interface for clarity and maintainability --- src/interfaces/taggable-options.interface.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/interfaces/taggable-options.interface.ts b/src/interfaces/taggable-options.interface.ts index 033401d..bfcbef0 100644 --- a/src/interfaces/taggable-options.interface.ts +++ b/src/interfaces/taggable-options.interface.ts @@ -1,12 +1,12 @@ import type { DynamicModule } from "@nestjs/common"; -export interface TaggableModuleOptions { - tableName?: string; - pivotTableName?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TaggableModuleOptions {} export interface TaggableAsyncOptions { imports?: (DynamicModule | Promise | any)[]; - useFactory: (...args: any[]) => TaggableModuleOptions | Promise; + useFactory: ( + ...args: any[] + ) => TaggableModuleOptions | Promise; inject?: any[]; } From 3f1e769de5aa96f58718f775115ffd4d58abcd00 Mon Sep 17 00:00:00 2001 From: khatabwedaa Date: Wed, 15 Apr 2026 15:56:09 +0300 Subject: [PATCH 3/5] Improves code formatting and documentation consistency Formats code examples in README for better readability by adding proper line breaks and indentation to multi-line configurations and method calls. Aligns table columns in documentation to improve visual consistency across all API reference tables. Replaces empty interface with Record type for TaggableModuleOptions to better represent an empty object type. Uses findOneByOrFail instead of findOne for tag retrieval to ensure proper error handling when tag is not found. Removes credits section from documentation. --- README.md | 113 +++++++++---------- src/interfaces/taggable-options.interface.ts | 3 +- src/taggable.service.ts | 2 +- 3 files changed, 58 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d76e718..2050db4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ import { TaggableModule } from "@nestbolt/taggable"; @Module({ imports: [ - TypeOrmModule.forRoot({ /* ... */ }), + TypeOrmModule.forRoot({ + /* ... */ + }), TaggableModule.forRoot(), ], }) @@ -163,8 +165,8 @@ The `@Taggable()` class decorator marks an entity for polymorphic tagging: @Taggable({ type: "BlogPost" }) // custom type override ``` -| Option | Type | Default | Description | -| ------ | -------- | ---------- | ----------------------------------------------- | +| Option | Type | Default | Description | +| ------ | -------- | ---------- | ------------------------------------------------ | | `type` | `string` | Class name | Override the entity type name in taggables table | ## Tag CRUD @@ -174,7 +176,9 @@ The `@Taggable()` class decorator marks an entity for polymorphic tagging: const tag = await taggableService.createTag("JavaScript", { type: "language" }); // Create with metadata -const tag = await taggableService.createTag("VIP", { metadata: { color: "gold" } }); +const tag = await taggableService.createTag("VIP", { + metadata: { color: "gold" }, +}); // Find or create (by slug) const tag = await taggableService.findOrCreateTag("TypeScript"); @@ -229,27 +233,27 @@ const hasTag = await post.hasTag(tagId); const count = await post.getTagCount(); ``` -| Method | Returns | Description | -| ------------------- | -------------------- | ------------------------- | -| `attachTag(tagId)` | `Promise` | Attach a tag | -| `detachTag(tagId)` | `Promise` | Detach a tag | -| `syncTags(tagIds)` | `Promise` | Sync tags | -| `getTags()` | `Promise` | Get all tags | -| `hasTag(tagId)` | `Promise` | Check if entity has tag | -| `getTagCount()` | `Promise` | Count tags | -| `getTaggableType()` | `string` | Get the entity type name | -| `getTaggableId()` | `string` | Get the entity ID | +| Method | Returns | Description | +| ------------------- | ---------------------- | ------------------------ | +| `attachTag(tagId)` | `Promise` | Attach a tag | +| `detachTag(tagId)` | `Promise` | Detach a tag | +| `syncTags(tagIds)` | `Promise` | Sync tags | +| `getTags()` | `Promise` | Get all tags | +| `hasTag(tagId)` | `Promise` | Check if entity has tag | +| `getTagCount()` | `Promise` | Count tags | +| `getTaggableType()` | `string` | Get the entity type name | +| `getTaggableId()` | `string` | Get the entity ID | ## Events When `@nestjs/event-emitter` is installed, the package emits: -| Event | Payload | When | -| -------------- | --------------------------------------------- | ------------------------- | -| `tag.created` | `{ tag }` | After a tag is created | -| `tag.deleted` | `{ tagId, tagName }` | After a tag is deleted | -| `tag.attached` | `{ tag, taggableType, taggableId }` | After a tag is attached | -| `tag.detached` | `{ tagId, taggableType, taggableId }` | After a tag is detached | +| Event | Payload | When | +| -------------- | ------------------------------------- | ----------------------- | +| `tag.created` | `{ tag }` | After a tag is created | +| `tag.deleted` | `{ tagId, tagName }` | After a tag is deleted | +| `tag.attached` | `{ tag, taggableType, taggableId }` | After a tag is attached | +| `tag.detached` | `{ tagId, taggableType, taggableId }` | After a tag is detached | ```typescript import { TAGGABLE_EVENTS, TagAttachedEvent } from "@nestbolt/taggable"; @@ -274,50 +278,49 @@ export class PostService { async categorizePost(postId: string, categoryNames: string[]) { const tags = await Promise.all( - categoryNames.map((name) => this.taggableService.findOrCreateTag(name, { type: "category" })), + categoryNames.map((name) => + this.taggableService.findOrCreateTag(name, { type: "category" }), + ), + ); + await this.taggableService.syncTags( + Post, + postId, + tags.map((t) => t.id), ); - await this.taggableService.syncTags(Post, postId, tags.map((t) => t.id)); } } ``` -| Method | Returns | Description | -| --------------------------------------- | -------------------------- | ------------------------- | -| `createTag(name, opts?)` | `Promise` | Create a new tag | -| `findOrCreateTag(name, opts?)` | `Promise` | Find by slug or create | -| `findTagById(id)` | `Promise` | Find tag by ID | -| `findTagBySlug(slug, type?)` | `Promise` | Find tag by slug | -| `findTagsByType(type)` | `Promise` | Get all tags of a type | -| `getAllTags()` | `Promise` | Get all tags | -| `deleteTag(id)` | `Promise` | Delete tag and pivots | -| `attachTag(Entity, entityId, tagId)` | `Promise` | Attach tag to entity | -| `detachTag(Entity, entityId, tagId)` | `Promise` | Detach tag from entity | -| `syncTags(Entity, entityId, tagIds)` | `Promise` | Sync entity tags | -| `getEntityTags(Entity, entityId)` | `Promise` | Get entity's tags | -| `hasTag(Entity, entityId, tagId)` | `Promise` | Check if entity has tag | -| `getEntitiesWithTag(Entity, tagId)` | `Promise` | Get entity IDs with tag | -| `getTagCount(Entity, entityId)` | `Promise` | Count entity's tags | - -## Configuration Options - -| Option | Type | Default | Description | -| ---------------- | -------- | ------------ | ------------------------------ | -| `tableName` | `string` | `"tags"` | Custom name for the tags table | -| `pivotTableName` | `string` | `"taggables"` | Custom name for the pivot table | +| Method | Returns | Description | +| ------------------------------------ | ---------------------------- | ----------------------- | +| `createTag(name, opts?)` | `Promise` | Create a new tag | +| `findOrCreateTag(name, opts?)` | `Promise` | Find by slug or create | +| `findTagById(id)` | `Promise` | Find tag by ID | +| `findTagBySlug(slug, type?)` | `Promise` | Find tag by slug | +| `findTagsByType(type)` | `Promise` | Get all tags of a type | +| `getAllTags()` | `Promise` | Get all tags | +| `deleteTag(id)` | `Promise` | Delete tag and pivots | +| `attachTag(Entity, entityId, tagId)` | `Promise` | Attach tag to entity | +| `detachTag(Entity, entityId, tagId)` | `Promise` | Detach tag from entity | +| `syncTags(Entity, entityId, tagIds)` | `Promise` | Sync entity tags | +| `getEntityTags(Entity, entityId)` | `Promise` | Get entity's tags | +| `hasTag(Entity, entityId, tagId)` | `Promise` | Check if entity has tag | +| `getEntitiesWithTag(Entity, tagId)` | `Promise` | Get entity IDs with tag | +| `getTagCount(Entity, entityId)` | `Promise` | Count entity's tags | ## Tag Entity The `tags` table stores: -| Column | Type | Description | -| ------------ | ------------ | ---------------------- | -| `id` | UUID | Primary key | -| `name` | varchar(255) | Tag name | -| `slug` | varchar(255) | URL-friendly slug | +| Column | Type | Description | +| ------------ | ------------ | ------------------------- | +| `id` | UUID | Primary key | +| `name` | varchar(255) | Tag name | +| `slug` | varchar(255) | URL-friendly slug | | `type` | varchar(100) | Tag type/group (nullable) | -| `metadata` | text | JSON metadata (nullable) | -| `created_at` | timestamp | Creation timestamp | -| `updated_at` | timestamp | Last update timestamp | +| `metadata` | text | JSON metadata (nullable) | +| `created_at` | timestamp | Creation timestamp | +| `updated_at` | timestamp | Last update timestamp | ## Testing @@ -349,10 +352,6 @@ Please see [CONTRIBUTING](CONTRIBUTING.md) for details. If you discover any security-related issues, please report them via [GitHub Issues](https://github.com/nestbolt/taggable/issues) with the **security** label instead of using the public issue tracker. -## Credits - -- Inspired by [Laravel's](https://laravel.com) tagging packages and [spatie/laravel-tags](https://github.com/spatie/laravel-tags) - ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/src/interfaces/taggable-options.interface.ts b/src/interfaces/taggable-options.interface.ts index bfcbef0..7313fdd 100644 --- a/src/interfaces/taggable-options.interface.ts +++ b/src/interfaces/taggable-options.interface.ts @@ -1,7 +1,6 @@ import type { DynamicModule } from "@nestjs/common"; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface TaggableModuleOptions {} +export type TaggableModuleOptions = Record; export interface TaggableAsyncOptions { imports?: (DynamicModule | Promise | any)[]; diff --git a/src/taggable.service.ts b/src/taggable.service.ts index 5e41159..ac535fe 100644 --- a/src/taggable.service.ts +++ b/src/taggable.service.ts @@ -143,7 +143,7 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { }); await this.taggableRepo.save(pivot); - const tag = await this.tagRepo.findOne({ where: { id: tagId } }); + const tag = await this.tagRepo.findOneByOrFail({ id: tagId }); this.emit(TAGGABLE_EVENTS.TAG_ATTACHED, { tag, taggableType, From 25404460ce8827a880d9ed7a1d8fbb7f37d593b9 Mon Sep 17 00:00:00 2001 From: khatabwedaa Date: Wed, 15 Apr 2026 16:01:02 +0300 Subject: [PATCH 4/5] Update changelog and README for clarity and completeness --- CHANGELOG.md | 11 ++++++----- README.md | 6 +++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 782f8a8..a6929c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ All notable changes to `@nestbolt/taggable` will be documented in this file. ### Features -- **Tag entity** — Dedicated `tags` table with name, slug, type/group, and optional metadata -- **Polymorphic tagging** — `taggables` pivot table enables tagging any entity type +- **Tag entity** — Dedicated `tags` table with name, slug, type/group, and optional JSON metadata +- **Polymorphic tagging** — `taggables` pivot table enables tagging any entity type via `taggableType` + `taggableId` - **@Taggable() decorator** — Class decorator to mark entities as taggable with optional type override -- **Tag service** — Full CRUD for tags plus attach/detach/sync operations on entities -- **Entity mixin** — `TaggableMixin()` adds `attachTag()`, `detachTag()`, `syncTags()`, `getTags()`, `hasTag()` to entities +- **Tag service** — Full CRUD for tags (`createTag`, `findOrCreateTag`, `findTagById`, `findTagBySlug`, `findTagsByType`, `getAllTags`, `deleteTag`) plus attach/detach/sync operations on entities +- **Entity mixin** — `TaggableMixin()` adds `attachTag()`, `detachTag()`, `syncTags()`, `getTags()`, `hasTag()`, `getTagCount()` to entities - **Events** — Emits `tag.created`, `tag.deleted`, `tag.attached`, `tag.detached` via optional `@nestjs/event-emitter` -- **Module configuration** — `forRoot()` and `forRootAsync()` with global options +- **Unique constraints** — Composite unique indexes on `[slug, type]` for tags and `[tagId, taggableType, taggableId]` for pivot records +- **Module configuration** — `forRoot()` and `forRootAsync()` with global registration diff --git a/README.md b/README.md index 2050db4..183a148 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ await taggableService.attachTag(Post, postId, tag.id); - [Entity Mixin](#entity-mixin) - [Events](#events) - [Using the Service Directly](#using-the-service-directly) -- [Configuration Options](#configuration-options) - [Tag Entity](#tag-entity) - [Testing](#testing) - [Changelog](#changelog) @@ -307,6 +306,7 @@ export class PostService { | `hasTag(Entity, entityId, tagId)` | `Promise` | Check if entity has tag | | `getEntitiesWithTag(Entity, tagId)` | `Promise` | Get entity IDs with tag | | `getTagCount(Entity, entityId)` | `Promise` | Count entity's tags | +| `getOptions()` | `TaggableModuleOptions` | Get module options | ## Tag Entity @@ -352,6 +352,10 @@ Please see [CONTRIBUTING](CONTRIBUTING.md) for details. If you discover any security-related issues, please report them via [GitHub Issues](https://github.com/nestbolt/taggable/issues) with the **security** label instead of using the public issue tracker. +## Credits + +- Built by [Nestbolt](https://github.com/nestbolt) + ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. From 0f9f6254c9c81bb1a94a4d0be3b33270b45c29b0 Mon Sep 17 00:00:00 2001 From: khatabwedaa Date: Wed, 15 Apr 2026 16:02:12 +0300 Subject: [PATCH 5/5] WIP --- pnpm-lock.yaml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cff9bb3..37c8a74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@nestjs/event-emitter': specifier: ^2.0.0 || ^3.0.0 version: 3.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) - uuid: - specifier: ^11.0.0 - version: 11.1.0 devDependencies: '@eslint/js': specifier: ^9.0.0 @@ -36,9 +33,6 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.39 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 '@vitest/coverage-v8': specifier: ^4.1.0 version: 4.1.4(vitest@4.1.4) @@ -390,9 +384,6 @@ packages: '@types/node@20.19.39': resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1875,8 +1866,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2