@nestbolt/taggable
v0.1.0
Published
Polymorphic tagging system for NestJS with TypeORM — attach tags and categories to any entity.
Maintainers
Readme
This package provides a polymorphic tagging system for NestJS that lets you attach tags and categories to any entity using a shared tags table and a taggables pivot table.
Once installed, using it is as simple as:
@Entity("posts")
@Taggable()
export class Post extends TaggableMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid") id!: string;
@Column() title!: string;
}
// Attach, detach, sync tags
const tag = await taggableService.findOrCreateTag("NestJS");
await taggableService.attachTag(Post, postId, tag.id);Table of Contents
- Installation
- Quick Start
- Module Configuration
- Using the @Taggable() Decorator
- Tag CRUD
- Attaching Tags
- Entity Mixin
- Events
- Using the Service Directly
- Tag Entity
- Testing
- Changelog
- Contributing
- Security
- Credits
- License
Installation
Install the package via npm:
npm install @nestbolt/taggableOr via yarn:
yarn add @nestbolt/taggableOr via pnpm:
pnpm add @nestbolt/taggablePeer Dependencies
This package requires the following peer dependencies, which you likely already have in a NestJS project:
@nestjs/common ^10.0.0 || ^11.0.0
@nestjs/core ^10.0.0 || ^11.0.0
@nestjs/typeorm ^10.0.0 || ^11.0.0
typeorm ^0.3.0
reflect-metadata ^0.1.13 || ^0.2.0Optional
npm install @nestjs/event-emitter # For tag.created, tag.attached eventsQuick Start
1. Register the module in your AppModule
import { TaggableModule } from "@nestbolt/taggable";
@Module({
imports: [
TypeOrmModule.forRoot({
/* ... */
}),
TaggableModule.forRoot(),
],
})
export class AppModule {}2. Mark entities as taggable
import { Taggable, TaggableMixin } from "@nestbolt/taggable";
@Entity("posts")
@Taggable()
export class Post extends TaggableMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid") id!: string;
@Column() title!: string;
}3. Use the service to manage tags
import { TaggableService } from "@nestbolt/taggable";
@Injectable()
export class PostService {
constructor(private readonly taggableService: TaggableService) {}
async tagPost(postId: string) {
const tag = await this.taggableService.findOrCreateTag("NestJS");
await this.taggableService.attachTag(Post, postId, tag.id);
}
}Module Configuration
The module is registered globally — you only need to import it once.
Static Configuration (forRoot)
TaggableModule.forRoot();Async Configuration (forRootAsync)
TaggableModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({}),
});Using the @Taggable() Decorator
The @Taggable() class decorator marks an entity for polymorphic tagging:
@Taggable() // type defaults to class name
@Taggable({ type: "BlogPost" }) // custom type override| Option | Type | Default | Description |
| ------ | -------- | ---------- | ------------------------------------------------ |
| type | string | Class name | Override the entity type name in taggables table |
Tag CRUD
// Create a tag
const tag = await taggableService.createTag("JavaScript", { type: "language" });
// Create with metadata
const tag = await taggableService.createTag("VIP", {
metadata: { color: "gold" },
});
// Find or create (by slug)
const tag = await taggableService.findOrCreateTag("TypeScript");
// Find
const tag = await taggableService.findTagById(id);
const tag = await taggableService.findTagBySlug("javascript");
const tags = await taggableService.findTagsByType("category");
const all = await taggableService.getAllTags();
// Delete (also removes all pivot records)
await taggableService.deleteTag(tagId);Attaching Tags
// Attach a tag to an entity
await taggableService.attachTag(Post, postId, tagId);
// Detach a tag
await taggableService.detachTag(Post, postId, tagId);
// Sync tags (adds missing, removes extra)
await taggableService.syncTags(Post, postId, [tagId1, tagId2]);
// Query
const tags = await taggableService.getEntityTags(Post, postId);
const hasTag = await taggableService.hasTag(Post, postId, tagId);
const count = await taggableService.getTagCount(Post, postId);
const entityIds = await taggableService.getEntitiesWithTag(Post, tagId);Entity Mixin
The TaggableMixin adds convenience methods directly on your entity:
@Entity("posts")
@Taggable()
export class Post extends TaggableMixin(BaseEntity) {
// ...
}
// Usage
const post = await postRepo.findOneBy({ id });
await post.attachTag(tagId);
await post.detachTag(tagId);
await post.syncTags([tagId1, tagId2]);
const tags = await post.getTags();
const hasTag = await post.hasTag(tagId);
const count = await post.getTagCount();| Method | Returns | Description |
| ------------------- | ---------------------- | ------------------------ |
| attachTag(tagId) | Promise<void> | Attach a tag |
| detachTag(tagId) | Promise<void> | Detach a tag |
| syncTags(tagIds) | Promise<void> | Sync tags |
| getTags() | Promise<TagEntity[]> | Get all tags |
| hasTag(tagId) | Promise<boolean> | Check if entity has tag |
| getTagCount() | Promise<number> | 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 |
import { TAGGABLE_EVENTS, TagAttachedEvent } from "@nestbolt/taggable";
import { OnEvent } from "@nestjs/event-emitter";
@OnEvent(TAGGABLE_EVENTS.TAG_ATTACHED)
handleTagAttached(event: TagAttachedEvent) {
console.log(`Tag attached to ${event.taggableType}#${event.taggableId}`);
}Using the Service Directly
Inject TaggableService for tag management and querying:
import { TaggableService } from "@nestbolt/taggable";
@Injectable()
export class PostService {
constructor(private readonly taggableService: TaggableService) {}
async categorizePost(postId: string, categoryNames: string[]) {
const tags = await Promise.all(
categoryNames.map((name) =>
this.taggableService.findOrCreateTag(name, { type: "category" }),
),
);
await this.taggableService.syncTags(
Post,
postId,
tags.map((t) => t.id),
);
}
}| Method | Returns | Description |
| ------------------------------------ | ---------------------------- | ----------------------- |
| createTag(name, opts?) | Promise<TagEntity> | Create a new tag |
| findOrCreateTag(name, opts?) | Promise<TagEntity> | Find by slug or create |
| findTagById(id) | Promise<TagEntity \| null> | Find tag by ID |
| findTagBySlug(slug, type?) | Promise<TagEntity \| null> | Find tag by slug |
| findTagsByType(type) | Promise<TagEntity[]> | Get all tags of a type |
| getAllTags() | Promise<TagEntity[]> | Get all tags |
| deleteTag(id) | Promise<void> | Delete tag and pivots |
| attachTag(Entity, entityId, tagId) | Promise<void> | Attach tag to entity |
| detachTag(Entity, entityId, tagId) | Promise<void> | Detach tag from entity |
| syncTags(Entity, entityId, tagIds) | Promise<void> | Sync entity tags |
| getEntityTags(Entity, entityId) | Promise<TagEntity[]> | Get entity's tags |
| hasTag(Entity, entityId, tagId) | Promise<boolean> | Check if entity has tag |
| getEntitiesWithTag(Entity, tagId) | Promise<string[]> | Get entity IDs with tag |
| getTagCount(Entity, entityId) | Promise<number> | Count entity's tags |
| getOptions() | TaggableModuleOptions | Get module options |
Tag Entity
The tags table stores:
| 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 |
Testing
npm testRun tests in watch mode:
npm run test:watchGenerate coverage report:
npm run test:covChangelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security-related issues, please report them via GitHub Issues with the security label instead of using the public issue tracker.
Credits
- Built by Nestbolt
License
The MIT License (MIT). Please see License File for more information.
