feat: export decorators (@Exportable, @ExportColumn, @ExportIgnore)#15
Conversation
Allows users to decorate entities/DTOs directly instead of writing separate export classes. Includes inheritance support, per-column formatting/mapping, and 4 new ExcelService methods. README updated to use decorator API as the primary example. Closes #6 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a decorator-based API for defining exports directly on entities/DTOs, plus ExcelService convenience methods that convert decorated classes into the existing concern-compatible export pipeline.
Changes:
- Introduces
@Exportable,@ExportColumn,@ExportIgnore, andbuildExportFromEntity()to build concern-compatible export objects from decorator metadata. - Adds
ExcelService.downloadFromEntity(),downloadFromEntityAsStream(),storeFromEntity(), andrawFromEntity()helpers. - Updates public exports and documentation (README + CHANGELOG) and adds a new decorator-focused test suite.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/decorators.spec.ts | Adds unit + end-to-end style tests validating decorator metadata → export output behavior (XLSX/CSV, inheritance, ignore). |
| src/index.ts | Re-exports decorators and their option types from the package entrypoint. |
| src/excel.service.ts | Adds decorator-based convenience methods delegating to existing service methods. |
| src/decorators/interfaces.ts | Defines decorator option types for class-level and column-level configuration. |
| src/decorators/index.ts | Barrel exports for decorators + builder + types. |
| src/decorators/exportable.decorator.ts | Implements @Exportable() metadata storage. |
| src/decorators/export-column.decorator.ts | Implements @ExportColumn() metadata storage. |
| src/decorators/export-ignore.decorator.ts | Implements @ExportIgnore() metadata storage. |
| src/decorators/constants.ts | Defines metadata keys for decorator storage. |
| src/decorators/build-export-from-entity.ts | Builds a concern-compatible export object by resolving columns/options (incl. inheritance). |
| README.md | Promotes decorator API as primary usage and documents options/inheritance/service methods. |
| CHANGELOG.md | Documents new decorator API and new ExcelService methods. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const exportableOpts: ExportableOptions | undefined = | ||
| Reflect.getMetadata(EXPORTABLE_META, entityClass); | ||
|
|
||
| if (!exportableOpts) { | ||
| throw new Error( | ||
| `Class "${entityClass.name}" is not decorated with @Exportable().`, | ||
| ); |
There was a problem hiding this comment.
buildExportFromEntity uses Reflect.getMetadata(EXPORTABLE_META, entityClass), which will also return metadata inherited from a parent class. That means a child class without @Exportable() will still be treated as exportable, contradicting the error message and the README examples where each class is explicitly decorated. Consider using Reflect.getOwnMetadata (or explicitly documenting/handling inherited @Exportable options) so behavior matches the intended API.
| const merged = new Map<string, ExportColumnOptions>(); | ||
| const ignored = new Set<string>(); | ||
|
|
There was a problem hiding this comment.
collectColumns maintains an ignored Set and updates it, but the set is never used to influence the returned ResolvedColumn[] (the behavior is fully determined by merged). This extra state makes the override/ignore logic harder to reason about; consider removing ignored (or using it in a final filter if that was the intent).
| return (target: Object, propertyKey: string | symbol) => { | ||
| const key = String(propertyKey); | ||
| const ctor = target.constructor; | ||
|
|
||
| const existing: Map<string, ExportColumnOptions> = | ||
| Reflect.getOwnMetadata(EXPORT_COLUMNS_META, ctor) ?? new Map(); | ||
|
|
||
| existing.set(key, opts ?? {}); | ||
| Reflect.defineMetadata(EXPORT_COLUMNS_META, existing, ctor); |
There was a problem hiding this comment.
ExportColumn derives the owning class via target.constructor, which breaks if the decorator is applied to a static property (for static properties, target is the constructor function and target.constructor is Function). Consider using const ctor = typeof target === "function" ? target : target.constructor; so metadata is attached to the correct class in both cases.
| return (target: Object, propertyKey: string | symbol) => { | ||
| const key = String(propertyKey); | ||
| const ctor = target.constructor; | ||
|
|
||
| const existing: Set<string> = | ||
| Reflect.getOwnMetadata(EXPORT_IGNORE_META, ctor) ?? new Set(); | ||
|
|
||
| existing.add(key); | ||
| Reflect.defineMetadata(EXPORT_IGNORE_META, existing, ctor); |
There was a problem hiding this comment.
ExportIgnore derives the owning class via target.constructor, which breaks if the decorator is applied to a static property (for static properties, target is the constructor function and target.constructor is Function). Consider using const ctor = typeof target === "function" ? target : target.constructor; so the ignore metadata is always attached to the correct class.
| async downloadFromEntity<T>( | ||
| entityClass: new (...args: any[]) => T, | ||
| data: T[], | ||
| filename: string, | ||
| writerType?: ExcelType, | ||
| ): Promise<ExcelDownloadResult> { | ||
| const exportable = buildExportFromEntity(entityClass, data); | ||
| return this.download(exportable, filename, writerType); | ||
| } | ||
|
|
||
| /** | ||
| * Export a decorated entity class as a NestJS StreamableFile. | ||
| */ | ||
| async downloadFromEntityAsStream<T>( | ||
| entityClass: new (...args: any[]) => T, | ||
| data: T[], | ||
| filename: string, | ||
| writerType?: ExcelType, | ||
| ): Promise<StreamableFile> { | ||
| const exportable = buildExportFromEntity(entityClass, data); | ||
| return this.downloadAsStream(exportable, filename, writerType); | ||
| } | ||
|
|
||
| /** | ||
| * Export a decorated entity class to a local file. | ||
| */ | ||
| async storeFromEntity<T>( | ||
| entityClass: new (...args: any[]) => T, | ||
| data: T[], | ||
| filePath: string, | ||
| writerType?: ExcelType, | ||
| ): Promise<void> { | ||
| const exportable = buildExportFromEntity(entityClass, data); | ||
| return this.store(exportable, filePath, writerType); | ||
| } | ||
|
|
||
| /** | ||
| * Export a decorated entity class and return the raw buffer. | ||
| */ | ||
| async rawFromEntity<T>( | ||
| entityClass: new (...args: any[]) => T, | ||
| data: T[], | ||
| writerType: ExcelType, | ||
| ): Promise<Buffer> { | ||
| const exportable = buildExportFromEntity(entityClass, data); | ||
| return this.raw(exportable, writerType); | ||
| } |
There was a problem hiding this comment.
New decorator-based convenience methods (downloadFromEntity*, storeFromEntity, rawFromEntity) are not covered by the existing test/excel.service.spec.ts suite (no references found). Since this file has extensive service-level tests, it would be good to add a few cases asserting these methods delegate correctly (type resolution via filename for download/store, and required writerType for raw).
- Use getOwnMetadata for @Exportable check so child classes must explicitly opt in rather than silently inheriting - Remove dead `ignored` Set from collectColumns — merged.delete() already handles exclusion - Add service-level tests for all *FromEntity methods (download, stream, store, raw) and inheritance guard test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
v0.2.0 is already published; decorator feature will ship in the next release. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
@Exportable(),@ExportColumn(), and@ExportIgnore()decorators so users can define exports directly on entities/DTOs instead of writing separate export classesExcelServicemethods:downloadFromEntity(),downloadFromEntityAsStream(),storeFromEntity(),rawFromEntity()What changed
src/decorators/src/excel.service.ts*FromEntitymethods delegating to existing concern-based pipelinesrc/index.tsREADME.mdCHANGELOG.mdtest/decorators.spec.tsTest plan
npx tsc --noEmit)Closes #6
🤖 Generated with Claude Code