Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 58 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -98,7 +97,9 @@ import { TaggableModule } from "@nestbolt/taggable";

@Module({
imports: [
TypeOrmModule.forRoot({ /* ... */ }),
TypeOrmModule.forRoot({
/* ... */
}),
TaggableModule.forRoot(),
],
})
Expand Down Expand Up @@ -163,8 +164,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
Expand All @@ -174,7 +175,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");
Expand Down Expand Up @@ -229,27 +232,27 @@ 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 |
| 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 |
| 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";
Expand All @@ -274,50 +277,50 @@ 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<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 |

## 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<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 |
| 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

Expand Down Expand Up @@ -351,7 +354,7 @@ If you discover any security-related issues, please report them via [GitHub Issu

## Credits

- Inspired by [Laravel's](https://laravel.com) tagging packages and [spatie/laravel-tags](https://github.com/spatie/laravel-tags)
- Built by [Nestbolt](https://github.com/nestbolt)

## License

Expand Down
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
11 changes: 0 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/entities/tag.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "typeorm";

@Entity("tags")
@Index(["slug", "type"], { unique: true })
export class TagEntity {
Comment on lines 10 to 12

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unique index on (slug, type) will not prevent duplicate slugs when type is NULL in many SQL databases (e.g., Postgres/MySQL allow multiple NULLs in a unique index). If the intent is to guarantee uniqueness for untyped tags as well, consider making type non-nullable with a sentinel default (e.g., empty string) or adding an additional constraint/index strategy for the NULL case.

Copilot uses AI. Check for mistakes.
@PrimaryGeneratedColumn("uuid")
id!: string;
Expand Down
9 changes: 4 additions & 5 deletions src/interfaces/taggable-options.interface.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { DynamicModule } from "@nestjs/common";

export interface TaggableModuleOptions {
tableName?: string;
pivotTableName?: string;
}
export type TaggableModuleOptions = Record<string, never>;

export interface TaggableAsyncOptions {
imports?: (DynamicModule | Promise<DynamicModule> | any)[];
useFactory: (...args: any[]) => TaggableModuleOptions | Promise<TaggableModuleOptions>;
useFactory: (
Comment on lines 1 to +7

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TaggableModuleOptions is a public exported type (re-exported from src/index.ts) and this change removes the previously supported tableName/pivotTableName shape by making it Record<string, never>. If consumers were using these options, this is a breaking API change; consider keeping an (even if unused) backwards-compatible interface or documenting the breaking change (e.g., changelog / major version bump) and updating TaggableModule.forRoot signature accordingly.

Copilot uses AI. Check for mistakes.
...args: any[]
) => TaggableModuleOptions | Promise<TaggableModuleOptions>;
inject?: any[];
}
Loading
Loading