Polymorphic tagging system for NestJS with TypeORM — attach tags and categories to any entity.
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);- 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
Install the package via npm:
npm install @nestbolt/taggableOr via yarn:
yarn add @nestbolt/taggableOr via pnpm:
pnpm add @nestbolt/taggableThis 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.0
npm install @nestjs/event-emitter # For tag.created, tag.attached eventsimport { TaggableModule } from "@nestbolt/taggable";
@Module({
imports: [
TypeOrmModule.forRoot({
/* ... */
}),
TaggableModule.forRoot(),
],
})
export class AppModule {}import { Taggable, TaggableMixin } from "@nestbolt/taggable";
@Entity("posts")
@Taggable()
export class Post extends TaggableMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid") id!: string;
@Column() title!: string;
}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);
}
}The module is registered globally — you only need to import it once.
TaggableModule.forRoot();TaggableModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({}),
});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 |
// 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);// 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);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 |
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}`);
}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 |
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 |
npm testRun tests in watch mode:
npm run test:watchGenerate coverage report:
npm run test:covPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
If you discover any security-related issues, please report them via GitHub Issues with the security label instead of using the public issue tracker.
- Built by Nestbolt
The MIT License (MIT). Please see License File for more information.