diff --git a/README.md b/README.md
index 303f1c3..f87945d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
@nestbolt/excel
- Supercharged Excel and CSV exports for NestJS applications. Effortlessly create and download spreadsheets with powerful features and seamless integration.
+ Supercharged Excel and CSV exports and imports for NestJS applications. Effortlessly create, download, and import spreadsheets with powerful features and seamless integration.
@@ -56,6 +56,18 @@ return this.excelService.downloadAsStream(new UsersExport(), "users.xlsx");
- [WithFrozenRows / WithFrozenColumns](#withfrozenrows--withfrozencolumns)
- [FromTemplate](#fromtemplate)
- [WithTemplateData](#withtemplatedata)
+- [Imports](#imports)
+ - [ToArray](#toarray)
+ - [ToCollection](#tocollection)
+ - [WithHeadingRow](#withheadingrow)
+ - [WithImportMapping](#withimportmapping)
+ - [WithColumnMapping](#withcolumnmapping)
+ - [WithValidation](#withvalidation)
+ - [WithBatchInserts](#withbatchinserts)
+ - [WithStartRow](#withstartrow)
+ - [WithLimit](#withlimit)
+ - [SkipsEmptyRows](#skipsemptyrows)
+ - [SkipsOnError](#skipsonerror)
- [Using the Service Directly](#using-the-service-directly)
- [Configuration Options](#configuration-options)
- [Testing](#testing)
@@ -476,10 +488,216 @@ class InvoiceExport implements FromTemplate, WithTemplateData {
The `dataStartCell()` specifies where the first row of data is written. Each subsequent row is placed on the next row below.
+## Imports
+
+Import classes use the same **concern-based** pattern as exports. Implement one or more interfaces to configure how data is read, transformed, and validated.
+
+### Quick Import Example
+
+```typescript
+class UsersImport implements ToCollection, WithHeadingRow, WithValidation, SkipsOnError {
+ readonly hasHeadingRow = true as const;
+ readonly skipsOnError = true as const;
+
+ handleCollection(rows: Record[]) {
+ // Process imported rows
+ }
+
+ rules() {
+ return {
+ name: [{ validate: (v) => v?.length > 0, message: "Name is required" }],
+ email: [{ validate: (v) => /^.+@.+\..+$/.test(v), message: "Invalid email" }],
+ };
+ }
+}
+
+// In your controller
+const result = await this.excelService.import(new UsersImport(), "users.xlsx");
+// result.rows, result.errors, result.skipped
+```
+
+### ToArray
+
+Receive imported data as a two-dimensional array.
+
+```typescript
+class DataImport implements ToArray {
+ handleArray(rows: any[][]) {
+ console.log(rows); // [[1, "Alice"], [2, "Bob"]]
+ }
+}
+```
+
+### ToCollection
+
+Receive imported data as an array of objects. Requires `WithHeadingRow` or `WithColumnMapping` to derive object keys.
+
+```typescript
+class UsersImport implements ToCollection, WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+
+ handleCollection(rows: Record[]) {
+ console.log(rows); // [{ ID: 1, Name: "Alice" }, ...]
+ }
+}
+```
+
+### WithHeadingRow
+
+Use a row in the spreadsheet as column headings. Defaults to row 1.
+
+```typescript
+class ImportWithCustomHeading implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+
+ headingRow() {
+ return 2; // row 2 contains the headers
+ }
+}
+```
+
+### WithImportMapping
+
+Transform each row after reading.
+
+```typescript
+class MappedImport implements WithHeadingRow, WithImportMapping {
+ readonly hasHeadingRow = true as const;
+
+ mapRow(row: Record) {
+ return {
+ fullName: row.first_name + " " + row.last_name,
+ email: row.email.toLowerCase(),
+ };
+ }
+}
+```
+
+### WithColumnMapping
+
+Map column letters or 1-based indices to named fields, useful for files without headers.
+
+```typescript
+class NoHeaderImport implements WithColumnMapping {
+ columnMapping() {
+ return { name: "A", email: "C", age: 2 };
+ }
+}
+```
+
+### WithValidation
+
+Validate imported rows using custom rules or class-validator DTOs.
+
+**Custom rules:**
+
+```typescript
+rules() {
+ return {
+ name: [
+ { validate: (v) => v?.length > 0, message: "Name is required" },
+ ],
+ email: [
+ { validate: (v) => /^.+@.+\..+$/.test(v), message: "Invalid email" },
+ ],
+ };
+}
+```
+
+**class-validator DTO:**
+
+```typescript
+import { IsString, IsEmail, IsNotEmpty } from "class-validator";
+
+class UserDto {
+ @IsString() @IsNotEmpty() name!: string;
+ @IsEmail() email!: string;
+}
+
+// In your import class
+rules() {
+ return { dto: UserDto };
+}
+```
+
+> **Note:** DTO mode requires `class-validator` and `class-transformer` as peer dependencies:
+> ```bash
+> pnpm add class-validator class-transformer
+> ```
+
+The `ImportResult` returned from the service contains:
+
+```typescript
+interface ImportResult {
+ rows: T[]; // valid rows
+ errors: ImportValidationError[]; // per-row validation errors
+ skipped: number; // count of skipped rows
+}
+```
+
+### WithBatchInserts
+
+Insert imported rows in configurable batch sizes.
+
+```typescript
+class BatchImport implements WithBatchInserts {
+ batchSize() {
+ return 100;
+ }
+
+ async handleBatch(batch: any[]) {
+ await this.userRepo.save(batch);
+ }
+}
+```
+
+### WithStartRow
+
+Skip rows before a given row number.
+
+```typescript
+startRow() {
+ return 3; // start reading from row 3
+}
+```
+
+### WithLimit
+
+Limit the number of data rows read.
+
+```typescript
+limit() {
+ return 1000; // only read first 1000 data rows
+}
+```
+
+### SkipsEmptyRows
+
+Ignore blank rows during import.
+
+```typescript
+class CleanImport implements SkipsEmptyRows {
+ readonly skipsEmptyRows = true as const;
+}
+```
+
+### SkipsOnError
+
+Skip invalid rows instead of throwing. Without this concern, the first validation failure throws an error with all collected errors attached.
+
+```typescript
+class TolerantImport implements WithValidation, SkipsOnError {
+ readonly skipsOnError = true as const;
+ rules() { /* ... */ }
+}
+```
+
## Using the Service Directly
Inject `ExcelService` and call its methods:
+### Export Methods
+
| Method | Returns | Description |
| ----------------------------------------------- | --------------------- | ------------------------------------------------------------ |
| `download(exportable, filename, type?)` | `ExcelDownloadResult` | Returns buffer + filename + content type |
@@ -487,6 +705,15 @@ Inject `ExcelService` and call its methods:
| `store(exportable, filePath, type?)` | `void` | Writes the export to a local file |
| `raw(exportable, type)` | `Buffer` | Returns the raw file buffer |
+### Import Methods
+
+| Method | Returns | Description |
+| --------------------------------------------------- | ---------------------------- | -------------------------------------------- |
+| `import(importable, filePath, type?)` | `ImportResult` | Read and process a local file |
+| `importFromBuffer(importable, buffer, type?)` | `ImportResult` | Read and process a buffer |
+| `toArray(filePath, type?)` | `any[][]` | Shorthand: returns raw 2D array |
+| `toCollection(filePath, type?)` | `Record[]` | Shorthand: returns objects using row 1 as headings |
+
## Configuration Options
| Option | Type | Default | Description |
diff --git a/package.json b/package.json
index 9908d97..80adcd7 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "@nestbolt/excel",
"version": "0.1.0",
"packageManager": "pnpm@9.15.4",
- "description": "Supercharged Excel and CSV exports for NestJS applications. Effortlessly create and download spreadsheets with powerful features and seamless integration.",
+ "description": "Supercharged Excel and CSV exports and imports for NestJS applications. Effortlessly create, download, and import spreadsheets with powerful features and seamless integration.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
@@ -31,6 +31,7 @@
"xlsx",
"csv",
"export",
+ "import",
"spreadsheet",
"download"
],
@@ -53,7 +54,13 @@
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
- "reflect-metadata": "^0.1.13 || ^0.2.0"
+ "reflect-metadata": "^0.1.13 || ^0.2.0",
+ "class-validator": "^0.14.0",
+ "class-transformer": "^0.5.0"
+ },
+ "peerDependenciesMeta": {
+ "class-validator": { "optional": true },
+ "class-transformer": { "optional": true }
},
"devDependencies": {
"@nestjs/common": "^11.0.0",
@@ -64,6 +71,8 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typescript": "^5.5.0",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.14.2",
"vitest": "^4.1.0"
}
}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8354830..d3e7c24 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,19 +14,25 @@ importers:
devDependencies:
'@nestjs/common':
specifier: ^11.0.0
- version: 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ version: 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^11.0.0
- version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/testing':
specifier: ^11.0.0
- version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
+ version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@types/node':
specifier: ^20.0.0
version: 20.19.37
'@vitest/coverage-v8':
specifier: ^4.1.0
version: 4.1.1(vitest@4.1.1(@types/node@20.19.37)(vite@8.0.2(@types/node@20.19.37)))
+ class-transformer:
+ specifier: ^0.5.1
+ version: 0.5.1
+ class-validator:
+ specifier: ^0.14.2
+ version: 0.14.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -270,6 +276,9 @@ packages:
'@types/node@20.19.37':
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
+ '@types/validator@13.15.10':
+ resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
+
'@vitest/coverage-v8@4.1.1':
resolution: {integrity: sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==}
peerDependencies:
@@ -376,6 +385,12 @@ packages:
chainsaw@0.1.0:
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
+ class-transformer@0.5.1:
+ resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
+
+ class-validator@0.14.4:
+ resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==}
+
compress-commons@4.1.2:
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
engines: {node: '>= 10'}
@@ -530,6 +545,9 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
+ libphonenumber-js@1.12.40:
+ resolution: {integrity: sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==}
+
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -849,6 +867,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
+ validator@13.15.26:
+ resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
+ engines: {node: '>= 0.10'}
+
vite@8.0.2:
resolution: {integrity: sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1014,7 +1036,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)':
+ '@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 21.3.2
iterare: 1.2.1
@@ -1023,12 +1045,15 @@ snapshots:
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
+ optionalDependencies:
+ class-transformer: 0.5.1
+ class-validator: 0.14.4
transitivePeerDependencies:
- supports-color
- '@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
+ '@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
- '@nestjs/common': 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxt/opencollective': 0.4.1
fast-safe-stringify: 2.1.1
iterare: 1.2.1
@@ -1038,10 +1063,10 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
- '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))':
+ '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
- '@nestjs/common': 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
'@nuxt/opencollective@0.4.1':
@@ -1130,6 +1155,8 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/validator@13.15.10': {}
+
'@vitest/coverage-v8@4.1.1(vitest@4.1.1(@types/node@20.19.37)(vite@8.0.2(@types/node@20.19.37)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
@@ -1276,6 +1303,14 @@ snapshots:
dependencies:
traverse: 0.3.9
+ class-transformer@0.5.1: {}
+
+ class-validator@0.14.4:
+ dependencies:
+ '@types/validator': 13.15.10
+ libphonenumber-js: 1.12.40
+ validator: 13.15.26
+
compress-commons@4.1.2:
dependencies:
buffer-crc32: 0.2.13
@@ -1424,6 +1459,8 @@ snapshots:
dependencies:
readable-stream: 2.3.8
+ libphonenumber-js@1.12.40: {}
+
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -1712,6 +1749,8 @@ snapshots:
uuid@8.3.2: {}
+ validator@13.15.26: {}
+
vite@8.0.2(@types/node@20.19.37):
dependencies:
lightningcss: 1.32.0
diff --git a/src/concerns/index.ts b/src/concerns/index.ts
index d1f3539..4f635bc 100644
--- a/src/concerns/index.ts
+++ b/src/concerns/index.ts
@@ -50,3 +50,26 @@ export {
BeforeSheetEventPayload,
AfterSheetEventPayload,
} from "./with-events.interface";
+
+// Import — data receivers
+export { ToArray } from "./to-array.interface";
+export { ToCollection } from "./to-collection.interface";
+
+// Import — row processing
+export { WithHeadingRow } from "./with-heading-row.interface";
+export { WithImportMapping } from "./with-import-mapping.interface";
+export { WithColumnMapping } from "./with-column-mapping.interface";
+
+// Import — validation
+export {
+ WithValidation,
+ ValidationRules,
+ ValidationRule,
+} from "./with-validation.interface";
+export { SkipsOnError } from "./skips-on-error.interface";
+export { SkipsEmptyRows } from "./skips-empty-rows.interface";
+
+// Import — limits & batching
+export { WithLimit } from "./with-limit.interface";
+export { WithStartRow } from "./with-start-row.interface";
+export { WithBatchInserts } from "./with-batch-inserts.interface";
diff --git a/src/concerns/skips-empty-rows.interface.ts b/src/concerns/skips-empty-rows.interface.ts
new file mode 100644
index 0000000..c3ec50b
--- /dev/null
+++ b/src/concerns/skips-empty-rows.interface.ts
@@ -0,0 +1,8 @@
+/**
+ * Ignore blank rows during import.
+ *
+ * Marker concern — set `skipsEmptyRows` to `true`.
+ */
+export interface SkipsEmptyRows {
+ readonly skipsEmptyRows: true;
+}
diff --git a/src/concerns/skips-on-error.interface.ts b/src/concerns/skips-on-error.interface.ts
new file mode 100644
index 0000000..8da4fc7
--- /dev/null
+++ b/src/concerns/skips-on-error.interface.ts
@@ -0,0 +1,8 @@
+/**
+ * Skip invalid rows instead of throwing during validation.
+ *
+ * Marker concern — set `skipsOnError` to `true`.
+ */
+export interface SkipsOnError {
+ readonly skipsOnError: true;
+}
diff --git a/src/concerns/to-array.interface.ts b/src/concerns/to-array.interface.ts
new file mode 100644
index 0000000..c42d84c
--- /dev/null
+++ b/src/concerns/to-array.interface.ts
@@ -0,0 +1,6 @@
+/**
+ * Receive imported data as a two-dimensional array.
+ */
+export interface ToArray {
+ handleArray(rows: any[][]): void | Promise;
+}
diff --git a/src/concerns/to-collection.interface.ts b/src/concerns/to-collection.interface.ts
new file mode 100644
index 0000000..3178845
--- /dev/null
+++ b/src/concerns/to-collection.interface.ts
@@ -0,0 +1,9 @@
+/**
+ * Receive imported data as an array of objects.
+ *
+ * Requires {@link WithHeadingRow} or {@link WithColumnMapping} to derive
+ * object keys.
+ */
+export interface ToCollection> {
+ handleCollection(rows: T[]): void | Promise;
+}
diff --git a/src/concerns/with-batch-inserts.interface.ts b/src/concerns/with-batch-inserts.interface.ts
new file mode 100644
index 0000000..75bdb76
--- /dev/null
+++ b/src/concerns/with-batch-inserts.interface.ts
@@ -0,0 +1,7 @@
+/**
+ * Insert imported rows in configurable batch sizes.
+ */
+export interface WithBatchInserts {
+ batchSize(): number;
+ handleBatch(batch: T[]): void | Promise;
+}
diff --git a/src/concerns/with-column-mapping.interface.ts b/src/concerns/with-column-mapping.interface.ts
new file mode 100644
index 0000000..ad81956
--- /dev/null
+++ b/src/concerns/with-column-mapping.interface.ts
@@ -0,0 +1,8 @@
+/**
+ * Map column letters or 1-based indices to named fields.
+ *
+ * Example: `{ name: 'A', email: 'C' }` or `{ name: 1, email: 3 }`.
+ */
+export interface WithColumnMapping {
+ columnMapping(): Record;
+}
diff --git a/src/concerns/with-heading-row.interface.ts b/src/concerns/with-heading-row.interface.ts
new file mode 100644
index 0000000..e40a294
--- /dev/null
+++ b/src/concerns/with-heading-row.interface.ts
@@ -0,0 +1,10 @@
+/**
+ * Use a row in the spreadsheet as column headings to derive object keys.
+ *
+ * Set `hasHeadingRow` to `true` as a marker. Optionally implement
+ * `headingRow()` to specify a custom row number (defaults to 1).
+ */
+export interface WithHeadingRow {
+ readonly hasHeadingRow: true;
+ headingRow?(): number;
+}
diff --git a/src/concerns/with-import-mapping.interface.ts b/src/concerns/with-import-mapping.interface.ts
new file mode 100644
index 0000000..3ff280b
--- /dev/null
+++ b/src/concerns/with-import-mapping.interface.ts
@@ -0,0 +1,12 @@
+/**
+ * Transform each row after reading during import.
+ *
+ * Named separately from the export `WithMapping` to allow a single class
+ * to implement both import and export mapping.
+ */
+export interface WithImportMapping<
+ TIn = Record,
+ TOut = Record,
+> {
+ mapRow(row: TIn): TOut;
+}
diff --git a/src/concerns/with-limit.interface.ts b/src/concerns/with-limit.interface.ts
new file mode 100644
index 0000000..eaed21a
--- /dev/null
+++ b/src/concerns/with-limit.interface.ts
@@ -0,0 +1,6 @@
+/**
+ * Limit the number of data rows read during import.
+ */
+export interface WithLimit {
+ limit(): number;
+}
diff --git a/src/concerns/with-start-row.interface.ts b/src/concerns/with-start-row.interface.ts
new file mode 100644
index 0000000..7a5a071
--- /dev/null
+++ b/src/concerns/with-start-row.interface.ts
@@ -0,0 +1,6 @@
+/**
+ * Skip rows before a given 1-based row number during import.
+ */
+export interface WithStartRow {
+ startRow(): number;
+}
diff --git a/src/concerns/with-validation.interface.ts b/src/concerns/with-validation.interface.ts
new file mode 100644
index 0000000..63e8fff
--- /dev/null
+++ b/src/concerns/with-validation.interface.ts
@@ -0,0 +1,18 @@
+/**
+ * Validate imported rows using custom rules or a class-validator DTO.
+ *
+ * Return an object with a `dto` key for class-validator integration,
+ * or a `ValidationRules` map for custom rule functions.
+ */
+export interface WithValidation> {
+ rules(): ValidationRules | { dto: new (...args: any[]) => any };
+}
+
+export type ValidationRules> = {
+ [K in keyof T]?: ValidationRule[];
+};
+
+export interface ValidationRule {
+ validate: (value: any, row: Record) => boolean;
+ message: string;
+}
diff --git a/src/excel.reader.ts b/src/excel.reader.ts
new file mode 100644
index 0000000..5aac4bd
--- /dev/null
+++ b/src/excel.reader.ts
@@ -0,0 +1,49 @@
+import { Workbook } from "exceljs";
+import { Readable } from "stream";
+import type { ExcelModuleOptions } from "./interfaces";
+import type { ImportResult } from "./interfaces";
+import { ExcelType } from "./excel.constants";
+import { resolveCsvSettings } from "./helpers/csv-settings";
+import { processSheet } from "./excel.sheet-reader";
+
+export async function readImport(
+ importable: object,
+ source: string | Buffer,
+ type: ExcelType,
+ options: ExcelModuleOptions,
+): Promise {
+ const workbook = new Workbook();
+
+ if (Buffer.isBuffer(source)) {
+ if (type === ExcelType.CSV) {
+ const csvOpts = resolveCsvSettings(importable, options);
+ await workbook.csv.read(Readable.from(source), {
+ parserOptions: {
+ delimiter: csvOpts.delimiter,
+ quote: csvOpts.quoteChar,
+ },
+ });
+ } else {
+ await workbook.xlsx.load(source as any);
+ }
+ } else {
+ if (type === ExcelType.CSV) {
+ const csvOpts = resolveCsvSettings(importable, options);
+ await workbook.csv.readFile(source, {
+ parserOptions: {
+ delimiter: csvOpts.delimiter,
+ quote: csvOpts.quoteChar,
+ },
+ });
+ } else {
+ await workbook.xlsx.readFile(source);
+ }
+ }
+
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("No worksheet found in the imported file.");
+ }
+
+ return processSheet(worksheet, importable);
+}
diff --git a/src/excel.service.ts b/src/excel.service.ts
index ee5ab26..0b5206b 100644
--- a/src/excel.service.ts
+++ b/src/excel.service.ts
@@ -2,9 +2,14 @@ import { Inject, Injectable, Logger, StreamableFile } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import { EXCEL_OPTIONS, ExcelType, CONTENT_TYPES } from "./excel.constants";
-import type { ExcelModuleOptions, ExcelDownloadResult } from "./interfaces";
+import type {
+ ExcelModuleOptions,
+ ExcelDownloadResult,
+ ImportResult,
+} from "./interfaces";
import { detectType } from "./helpers";
import { writeExport } from "./excel.writer";
+import { readImport } from "./excel.reader";
@Injectable()
export class ExcelService {
@@ -92,6 +97,60 @@ export class ExcelService {
return writeExport(exportable, writerType, this.options);
}
+ /* ---------------------------------------------------------------- */
+ /* Import */
+ /* ---------------------------------------------------------------- */
+
+ /**
+ * Read and process a local file through the importable's concerns.
+ */
+ async import(
+ importable: object,
+ filePath: string,
+ readerType?: ExcelType,
+ ): Promise {
+ const type = readerType ?? this.resolveType(path.basename(filePath));
+ return readImport(importable, filePath, type, this.options);
+ }
+
+ /**
+ * Read and process a buffer through the importable's concerns.
+ */
+ async importFromBuffer(
+ importable: object,
+ buffer: Buffer,
+ readerType?: ExcelType,
+ ): Promise {
+ const type = readerType ?? ExcelType.XLSX;
+ return readImport(importable, buffer, type, this.options);
+ }
+
+ /**
+ * Shorthand: read a file and return the raw 2D array.
+ */
+ async toArray(
+ filePath: string,
+ readerType?: ExcelType,
+ ): Promise {
+ const type = readerType ?? this.resolveType(path.basename(filePath));
+ const result = await readImport({}, filePath, type, this.options);
+ return result.rows;
+ }
+
+ /**
+ * Shorthand: read a file and return an array of objects using row 1
+ * as headings.
+ */
+ async toCollection(
+ filePath: string,
+ readerType?: ExcelType,
+ ): Promise[]> {
+ const type = readerType ?? this.resolveType(path.basename(filePath));
+ const importable = { hasHeadingRow: true as const };
+ const result = await readImport(importable, filePath, type, this.options);
+ return result.rows;
+ }
+
/* ---------------------------------------------------------------- */
/* Internal */
/* ---------------------------------------------------------------- */
diff --git a/src/excel.sheet-reader.ts b/src/excel.sheet-reader.ts
new file mode 100644
index 0000000..f4a6cc4
--- /dev/null
+++ b/src/excel.sheet-reader.ts
@@ -0,0 +1,276 @@
+import type { CellValue, Worksheet } from "exceljs";
+import type {
+ SkipsEmptyRows,
+ SkipsOnError,
+ ToArray,
+ ToCollection,
+ WithBatchInserts,
+ WithColumnMapping,
+ WithHeadingRow,
+ WithImportMapping,
+ WithLimit,
+ WithStartRow,
+ WithValidation,
+} from "./concerns";
+import { columnLetterToNumber } from "./helpers";
+import { validateRow } from "./helpers/validate-row";
+import type { ImportResult, ImportValidationError } from "./interfaces";
+
+/* ------------------------------------------------------------------ */
+/* Type guards */
+/* ------------------------------------------------------------------ */
+
+function isToArray(obj: any): obj is ToArray {
+ return typeof obj.handleArray === "function";
+}
+
+function isToCollection(obj: any): obj is ToCollection {
+ return typeof obj.handleCollection === "function";
+}
+
+function isWithHeadingRow(obj: any): obj is WithHeadingRow {
+ return obj.hasHeadingRow === true;
+}
+
+function isWithImportMapping(obj: any): obj is WithImportMapping {
+ return typeof obj.mapRow === "function";
+}
+
+function isWithColumnMapping(obj: any): obj is WithColumnMapping {
+ return typeof obj.columnMapping === "function";
+}
+
+function isWithValidation(obj: any): obj is WithValidation {
+ return typeof obj.rules === "function";
+}
+
+function isWithBatchInserts(obj: any): obj is WithBatchInserts {
+ return (
+ typeof obj.batchSize === "function" && typeof obj.handleBatch === "function"
+ );
+}
+
+function isWithLimit(obj: any): obj is WithLimit {
+ return typeof obj.limit === "function";
+}
+
+function isWithStartRow(obj: any): obj is WithStartRow {
+ return typeof obj.startRow === "function";
+}
+
+function isSkipsOnError(obj: any): obj is SkipsOnError {
+ return obj.skipsOnError === true;
+}
+
+function isSkipsEmptyRows(obj: any): obj is SkipsEmptyRows {
+ return obj.skipsEmptyRows === true;
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function extractCellValue(value: CellValue): any {
+ if (value === null || value === undefined) return null;
+ if (typeof value === "object" && "result" in value) {
+ return (value as any).result;
+ }
+ if (typeof value === "object" && "richText" in value) {
+ return (value as any).richText.map((rt: any) => rt.text).join("");
+ }
+ return value;
+}
+
+function isEmptyRow(row: any[]): boolean {
+ return row.every((v) => v === null || v === undefined || v === "");
+}
+
+/* ------------------------------------------------------------------ */
+/* Sheet reader */
+/* ------------------------------------------------------------------ */
+
+export async function processSheet(
+ worksheet: Worksheet,
+ importable: object,
+): Promise {
+ // --- extract all raw rows from worksheet --------------------------
+ const rawRows: { rowNumber: number; values: any[] }[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values: any[] = [];
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
+ // Expand values array to accommodate the column
+ while (values.length < colNumber) values.push(null);
+ values[colNumber - 1] = extractCellValue(cell.value);
+ });
+ rawRows.push({ rowNumber, values });
+ });
+
+ // --- determine heading row ----------------------------------------
+ let headings: string[] | null = null;
+ let headingRowNumber = 0;
+
+ if (isWithHeadingRow(importable)) {
+ headingRowNumber =
+ typeof importable.headingRow === "function" ? importable.headingRow() : 1;
+
+ const headingEntry = rawRows.find((r) => r.rowNumber === headingRowNumber);
+ if (headingEntry) {
+ headings = headingEntry.values.map((v) =>
+ v !== null && v !== undefined ? String(v) : "",
+ );
+ }
+ }
+
+ // --- determine data start row -------------------------------------
+ let dataStartRow: number;
+ if (isWithStartRow(importable)) {
+ dataStartRow = importable.startRow();
+ } else if (headingRowNumber > 0) {
+ dataStartRow = headingRowNumber + 1;
+ } else {
+ dataStartRow = 1;
+ }
+
+ // --- filter to data rows ------------------------------------------
+ let dataRows = rawRows.filter((r) => r.rowNumber >= dataStartRow);
+
+ // --- skip empty rows ----------------------------------------------
+ if (isSkipsEmptyRows(importable)) {
+ dataRows = dataRows.filter((r) => !isEmptyRow(r.values));
+ }
+
+ // --- apply limit --------------------------------------------------
+ if (isWithLimit(importable)) {
+ dataRows = dataRows.slice(0, importable.limit());
+ }
+
+ // --- convert to objects if headings or column mapping present ------
+ const useColumnMapping = isWithColumnMapping(importable);
+ const useObjects = headings !== null || useColumnMapping;
+
+ let processedRows: any[];
+ let objectKeyOrder: string[] | null = null;
+
+ if (useObjects) {
+ let columnMap: Record | null = null;
+
+ if (useColumnMapping) {
+ const raw = (importable as WithColumnMapping).columnMapping();
+ columnMap = {};
+ const entries: [string, number][] = [];
+ for (const [fieldName, colRef] of Object.entries(raw)) {
+ const idx =
+ typeof colRef === "number"
+ ? colRef - 1
+ : columnLetterToNumber(colRef) - 1;
+ columnMap[fieldName] = idx;
+ entries.push([fieldName, idx]);
+ }
+ // Deterministic key order: sorted by column index
+ objectKeyOrder = entries
+ .sort((a, b) => a[1] - b[1])
+ .map(([k]) => k);
+ }
+
+ if (!objectKeyOrder && headings) {
+ objectKeyOrder = headings.map((h, i) => h || `__col${i}`);
+ }
+
+ processedRows = dataRows.map((r) => {
+ const obj: Record = {};
+ if (columnMap) {
+ for (const [field, idx] of Object.entries(columnMap)) {
+ obj[field] = idx < r.values.length ? r.values[idx] : null;
+ }
+ } else if (headings) {
+ for (let i = 0; i < headings.length; i++) {
+ const key = headings[i] || `__col${i}`;
+ obj[key] = i < r.values.length ? r.values[i] : null;
+ }
+ }
+ return { rowNumber: r.rowNumber, data: obj };
+ });
+ } else {
+ processedRows = dataRows.map((r) => ({
+ rowNumber: r.rowNumber,
+ data: r.values,
+ }));
+ }
+
+ // --- apply import mapping -----------------------------------------
+ if (isWithImportMapping(importable)) {
+ processedRows = processedRows.map((r) => ({
+ rowNumber: r.rowNumber,
+ data: (importable as WithImportMapping).mapRow(r.data),
+ }));
+ }
+
+ // --- validation ---------------------------------------------------
+ const validationErrors: ImportValidationError[] = [];
+ let skipped = 0;
+ const validRows: any[] = [];
+
+ if (isWithValidation(importable)) {
+ const rules = importable.rules();
+ const skipOnError = isSkipsOnError(importable);
+
+ for (const row of processedRows) {
+ const error = await validateRow(row.data, rules, row.rowNumber);
+ if (error) {
+ validationErrors.push(error);
+ if (skipOnError) {
+ skipped++;
+ continue;
+ }
+ }
+ validRows.push(row.data);
+ }
+
+ if (!skipOnError && validationErrors.length > 0) {
+ const err = new Error(
+ `Import validation failed with ${validationErrors.length} error(s).`,
+ );
+ (err as any).validationErrors = validationErrors;
+ throw err;
+ }
+ } else {
+ for (const row of processedRows) {
+ validRows.push(row.data);
+ }
+ }
+
+ // --- deliver to importable ----------------------------------------
+ if (isWithBatchInserts(importable)) {
+ const size = importable.batchSize();
+ if (!Number.isInteger(size) || size < 1) {
+ throw new Error(
+ `WithBatchInserts.batchSize() must return a positive integer, got ${size}.`,
+ );
+ }
+ for (let i = 0; i < validRows.length; i += size) {
+ const batch = validRows.slice(i, i + size);
+ await importable.handleBatch(batch);
+ }
+ }
+
+ if (isToCollection(importable)) {
+ await importable.handleCollection(validRows);
+ }
+
+ if (isToArray(importable)) {
+ // If rows are objects, convert back to arrays for ToArray using
+ // deterministic key order so column ordering matches the spreadsheet.
+ const arrayRows =
+ useObjects && objectKeyOrder
+ ? validRows.map((obj) => objectKeyOrder!.map((k) => obj[k]))
+ : validRows;
+ await importable.handleArray(arrayRows);
+ }
+
+ return {
+ rows: validRows,
+ errors: validationErrors,
+ skipped,
+ };
+}
diff --git a/src/excel.writer.ts b/src/excel.writer.ts
index b1813a4..545238b 100644
--- a/src/excel.writer.ts
+++ b/src/excel.writer.ts
@@ -6,14 +6,13 @@ import type {
WithMultipleSheets,
WithProperties,
WithEvents,
- WithCsvSettings,
- CsvSettings,
FromTemplate,
WithTemplateData,
} from "./concerns";
import { ExcelExportEvent } from "./concerns";
import { populateSheet } from "./excel.sheet";
import { parseCellRef } from "./helpers";
+import { resolveCsvSettings } from "./helpers/csv-settings";
/* ------------------------------------------------------------------ */
/* Type guards */
@@ -31,10 +30,6 @@ function isWithEvents(obj: any): obj is WithEvents {
return typeof obj.registerEvents === "function";
}
-function isWithCsvSettings(obj: any): obj is WithCsvSettings {
- return typeof obj.csvSettings === "function";
-}
-
function isFromTemplate(obj: any): obj is FromTemplate {
return (
typeof obj.templatePath === "function" &&
@@ -160,30 +155,6 @@ export async function writeExport(
return Buffer.from(arrayBuffer);
}
-/* ------------------------------------------------------------------ */
-/* CSV settings resolution */
-/* ------------------------------------------------------------------ */
-
-function resolveCsvSettings(
- exportable: object,
- options: ExcelModuleOptions,
-): Required {
- const defaults: Required = {
- delimiter: ",",
- quoteChar: '"',
- lineEnding: "\n",
- useBom: false,
- encoding: "utf-8",
- };
-
- const global = options.csv ?? {};
- const perExport = isWithCsvSettings(exportable)
- ? exportable.csvSettings()
- : {};
-
- return { ...defaults, ...global, ...perExport };
-}
-
/* ------------------------------------------------------------------ */
/* Template export */
/* ------------------------------------------------------------------ */
diff --git a/src/helpers/csv-settings.ts b/src/helpers/csv-settings.ts
new file mode 100644
index 0000000..ed498d5
--- /dev/null
+++ b/src/helpers/csv-settings.ts
@@ -0,0 +1,26 @@
+import type { ExcelModuleOptions } from "../interfaces";
+import type { CsvSettings, WithCsvSettings } from "../concerns";
+
+function isWithCsvSettings(obj: any): obj is WithCsvSettings {
+ return typeof obj.csvSettings === "function";
+}
+
+export function resolveCsvSettings(
+ exportable: object,
+ options: ExcelModuleOptions,
+): Required {
+ const defaults: Required = {
+ delimiter: ",",
+ quoteChar: '"',
+ lineEnding: "\n",
+ useBom: false,
+ encoding: "utf-8",
+ };
+
+ const global = options.csv ?? {};
+ const perExport = isWithCsvSettings(exportable)
+ ? exportable.csvSettings()
+ : {};
+
+ return { ...defaults, ...global, ...perExport };
+}
diff --git a/src/helpers/index.ts b/src/helpers/index.ts
index a6393b3..37d61d3 100644
--- a/src/helpers/index.ts
+++ b/src/helpers/index.ts
@@ -1 +1,3 @@
export { detectType, parseCellRef, columnLetterToNumber, numberToColumnLetter } from "./file-type-detector";
+export { resolveCsvSettings } from "./csv-settings";
+export { validateRow } from "./validate-row";
diff --git a/src/helpers/validate-row.ts b/src/helpers/validate-row.ts
new file mode 100644
index 0000000..bd808f6
--- /dev/null
+++ b/src/helpers/validate-row.ts
@@ -0,0 +1,90 @@
+import type { ValidationRules } from "../concerns/with-validation.interface";
+import type {
+ FieldError,
+ ImportValidationError,
+} from "../interfaces/import-result.interface";
+
+export async function validateRow(
+ row: Record,
+ rulesOrDto: ValidationRules | { dto: new (...args: any[]) => any },
+ rowNumber: number,
+): Promise {
+ if ("dto" in rulesOrDto && typeof rulesOrDto.dto === "function") {
+ return validateWithDto(row, rulesOrDto.dto, rowNumber);
+ }
+ return validateWithRules(row, rulesOrDto as ValidationRules, rowNumber);
+}
+
+function validateWithRules(
+ row: Record,
+ rules: ValidationRules,
+ rowNumber: number,
+): ImportValidationError | null {
+ const fieldErrors: FieldError[] = [];
+
+ for (const [field, fieldRules] of Object.entries(rules)) {
+ if (!fieldRules) continue;
+ const messages: string[] = [];
+ for (const rule of fieldRules) {
+ if (!rule.validate(row[field], row)) {
+ messages.push(rule.message);
+ }
+ }
+ if (messages.length > 0) {
+ fieldErrors.push({ field, messages });
+ }
+ }
+
+ return fieldErrors.length > 0
+ ? { row: rowNumber, errors: fieldErrors }
+ : null;
+}
+
+let cachedValidator: any;
+let cachedTransformer: any;
+
+async function loadDtoDeps(): Promise<{ validator: any; transformer: any }> {
+ if (cachedValidator && cachedTransformer) {
+ return { validator: cachedValidator, transformer: cachedTransformer };
+ }
+ try {
+ cachedValidator = await import("class-validator");
+ cachedTransformer = await import("class-transformer");
+ } catch {
+ throw new Error(
+ "WithValidation with DTO requires class-validator and class-transformer. " +
+ "Install them: pnpm add class-validator class-transformer",
+ );
+ }
+ return { validator: cachedValidator, transformer: cachedTransformer };
+}
+
+async function validateWithDto(
+ row: Record,
+ dto: new (...args: any[]) => any,
+ rowNumber: number,
+): Promise {
+ const { validator, transformer } = await loadDtoDeps();
+
+ const instance = transformer.plainToInstance(dto, row);
+ const errors: any[] = validator.validateSync(instance);
+
+ return mapDtoErrors(errors, rowNumber);
+}
+
+/** @internal Exported for testing only. */
+export function mapDtoErrors(
+ errors: any[],
+ rowNumber: number,
+): ImportValidationError | null {
+ if (errors.length === 0) return null;
+
+ const fieldErrors: FieldError[] = errors.map((err: any) => ({
+ field: err.property,
+ messages: err.constraints
+ ? (Object.values(err.constraints) as string[])
+ : [],
+ }));
+
+ return { row: rowNumber, errors: fieldErrors };
+}
diff --git a/src/index.ts b/src/index.ts
index 222d07b..afaeafb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -60,3 +60,33 @@ export type {
AfterSheetEventPayload,
} from "./concerns";
export { ExcelExportEvent } from "./concerns";
+
+// Concerns — import data receivers
+export type { ToArray } from "./concerns";
+export type { ToCollection } from "./concerns";
+
+// Concerns — import row processing
+export type { WithHeadingRow } from "./concerns";
+export type { WithImportMapping } from "./concerns";
+export type { WithColumnMapping } from "./concerns";
+
+// Concerns — import validation
+export type {
+ WithValidation,
+ ValidationRules,
+ ValidationRule,
+} from "./concerns";
+export type { SkipsOnError } from "./concerns";
+export type { SkipsEmptyRows } from "./concerns";
+
+// Concerns — import limits & batching
+export type { WithLimit } from "./concerns";
+export type { WithStartRow } from "./concerns";
+export type { WithBatchInserts } from "./concerns";
+
+// Import result types
+export type {
+ ImportResult,
+ ImportValidationError,
+ FieldError,
+} from "./interfaces";
diff --git a/src/interfaces/import-result.interface.ts b/src/interfaces/import-result.interface.ts
new file mode 100644
index 0000000..1d339ca
--- /dev/null
+++ b/src/interfaces/import-result.interface.ts
@@ -0,0 +1,15 @@
+export interface ImportResult {
+ rows: T[];
+ errors: ImportValidationError[];
+ skipped: number;
+}
+
+export interface ImportValidationError {
+ row: number;
+ errors: FieldError[];
+}
+
+export interface FieldError {
+ field: string;
+ messages: string[];
+}
diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts
index c245fb1..64da50a 100644
--- a/src/interfaces/index.ts
+++ b/src/interfaces/index.ts
@@ -3,3 +3,8 @@ export {
ExcelAsyncOptions,
} from "./excel-options.interface";
export { ExcelDownloadResult } from "./export-result.interface";
+export {
+ ImportResult,
+ ImportValidationError,
+ FieldError,
+} from "./import-result.interface";
diff --git a/test/excel.import.spec.ts b/test/excel.import.spec.ts
new file mode 100644
index 0000000..9724c1f
--- /dev/null
+++ b/test/excel.import.spec.ts
@@ -0,0 +1,1077 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { Test, TestingModule } from "@nestjs/testing";
+import { Workbook } from "exceljs";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import { IsEmail, IsNotEmpty, IsString } from "class-validator";
+import { ExcelService } from "../src/excel.service";
+import { EXCEL_OPTIONS, ExcelType } from "../src/excel.constants";
+import type {
+ ToArray,
+ ToCollection,
+ WithHeadingRow,
+ WithImportMapping,
+ WithColumnMapping,
+ WithValidation,
+ WithBatchInserts,
+ WithLimit,
+ WithStartRow,
+ SkipsOnError,
+ SkipsEmptyRows,
+ WithCsvSettings,
+} from "../src/concerns";
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+async function createService(options = {}): Promise {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ { provide: EXCEL_OPTIONS, useValue: options },
+ ExcelService,
+ ],
+ }).compile();
+ return module.get(ExcelService);
+}
+
+async function createXlsxFile(
+ filePath: string,
+ data: any[][],
+ headings?: string[],
+): Promise {
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ if (headings) {
+ ws.addRow(headings);
+ }
+ for (const row of data) {
+ ws.addRow(row);
+ }
+ await wb.xlsx.writeFile(filePath);
+}
+
+async function createXlsxBuffer(
+ data: any[][],
+ headings?: string[],
+): Promise {
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ if (headings) {
+ ws.addRow(headings);
+ }
+ for (const row of data) {
+ ws.addRow(row);
+ }
+ const arrayBuffer = await wb.xlsx.writeBuffer();
+ return Buffer.from(arrayBuffer);
+}
+
+function createCsvFile(filePath: string, text: string): void {
+ fs.writeFileSync(filePath, text, "utf-8");
+}
+
+/* ------------------------------------------------------------------ */
+/* Test suite */
+/* ------------------------------------------------------------------ */
+
+describe("ExcelService — Import", () => {
+ let service: ExcelService;
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ service = await createService();
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "excel-import-"));
+ });
+
+ afterEach(() => {
+ if (fs.existsSync(tmpDir)) {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* Basic XLSX import */
+ /* ---------------------------------------------------------------- */
+
+ describe("basic XLSX import", () => {
+ it("should import a simple XLSX file via toArray()", async () => {
+ const filePath = path.join(tmpDir, "simple.xlsx");
+ await createXlsxFile(filePath, [
+ [1, "Alice", "alice@test.com"],
+ [2, "Bob", "bob@test.com"],
+ ]);
+
+ const rows = await service.toArray(filePath);
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toEqual([1, "Alice", "alice@test.com"]);
+ expect(rows[1]).toEqual([2, "Bob", "bob@test.com"]);
+ });
+
+ it("should import XLSX via toCollection() using row 1 as headings", async () => {
+ const filePath = path.join(tmpDir, "headings.xlsx");
+ await createXlsxFile(
+ filePath,
+ [
+ [1, "Alice", "alice@test.com"],
+ [2, "Bob", "bob@test.com"],
+ ],
+ ["ID", "Name", "Email"],
+ );
+
+ const rows = await service.toCollection(filePath);
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toEqual({ ID: 1, Name: "Alice", Email: "alice@test.com" });
+ expect(rows[1]).toEqual({ ID: 2, Name: "Bob", Email: "bob@test.com" });
+ });
+
+ it("should import from buffer", async () => {
+ const buffer = await createXlsxBuffer([
+ [10, "Charlie"],
+ [20, "Diana"],
+ ]);
+
+ const result = await service.importFromBuffer({}, buffer);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0]).toEqual([10, "Charlie"]);
+ });
+
+ it("should return ImportResult with errors and skipped", async () => {
+ const filePath = path.join(tmpDir, "result.xlsx");
+ await createXlsxFile(filePath, [[1, "Test"]]);
+
+ const result = await service.import({}, filePath);
+ expect(result.rows).toHaveLength(1);
+ expect(result.errors).toEqual([]);
+ expect(result.skipped).toBe(0);
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* Basic CSV import */
+ /* ---------------------------------------------------------------- */
+
+ describe("basic CSV import", () => {
+ it("should import a CSV file", async () => {
+ const filePath = path.join(tmpDir, "data.csv");
+ createCsvFile(filePath, "1,Alice,alice@test.com\n2,Bob,bob@test.com\n");
+
+ const rows = await service.toArray(filePath);
+ expect(rows).toHaveLength(2);
+ expect(rows[0][1]).toBe("Alice");
+ });
+
+ it("should import CSV from buffer", async () => {
+ const buffer = Buffer.from("a,b\n1,2\n3,4\n", "utf-8");
+ const result = await service.importFromBuffer({}, buffer, ExcelType.CSV);
+ expect(result.rows).toHaveLength(3); // includes heading row as data
+ });
+
+ it("should respect WithCsvSettings on import", async () => {
+ const filePath = path.join(tmpDir, "semicolons.csv");
+ createCsvFile(filePath, "1;Alice\n2;Bob\n");
+
+ class SemicolonImport implements WithCsvSettings {
+ csvSettings() {
+ return { delimiter: ";" };
+ }
+ }
+
+ const result = await service.import(
+ new SemicolonImport(),
+ filePath,
+ ExcelType.CSV,
+ );
+ expect(result.rows[0][0]).toBe(1);
+ expect(result.rows[0][1]).toBe("Alice");
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* ToArray concern */
+ /* ---------------------------------------------------------------- */
+
+ describe("ToArray", () => {
+ it("should call handleArray with data", async () => {
+ let captured: any[][] = [];
+
+ class ArrayImport implements ToArray {
+ handleArray(rows: any[][]) {
+ captured = rows;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "arr.xlsx");
+ await createXlsxFile(filePath, [
+ [1, "A"],
+ [2, "B"],
+ ]);
+
+ await service.import(new ArrayImport(), filePath);
+ expect(captured).toHaveLength(2);
+ expect(captured[0]).toEqual([1, "A"]);
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* ToCollection concern */
+ /* ---------------------------------------------------------------- */
+
+ describe("ToCollection", () => {
+ it("should call handleCollection with objects", async () => {
+ let captured: any[] = [];
+
+ class CollectionImport implements ToCollection, WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ handleCollection(rows: Record[]) {
+ captured = rows;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "coll.xlsx");
+ await createXlsxFile(filePath, [[1, "Alice"]], ["ID", "Name"]);
+
+ await service.import(new CollectionImport(), filePath);
+ expect(captured).toHaveLength(1);
+ expect(captured[0]).toEqual({ ID: 1, Name: "Alice" });
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithHeadingRow */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithHeadingRow", () => {
+ it("should use row 1 as default heading row", async () => {
+ class DefaultHeading implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ }
+
+ const filePath = path.join(tmpDir, "heading.xlsx");
+ await createXlsxFile(filePath, [[10, "Val"]], ["Col1", "Col2"]);
+
+ const result = await service.import(new DefaultHeading(), filePath);
+ expect(result.rows[0]).toEqual({ Col1: 10, Col2: "Val" });
+ });
+
+ it("should support custom heading row number", async () => {
+ class CustomHeading implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ headingRow() {
+ return 2;
+ }
+ }
+
+ // Row 1: title, Row 2: headings, Row 3+: data
+ const filePath = path.join(tmpDir, "custom-heading.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["Report Title", ""]);
+ ws.addRow(["Name", "Score"]);
+ ws.addRow(["Alice", 95]);
+ ws.addRow(["Bob", 88]);
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new CustomHeading(), filePath);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0]).toEqual({ Name: "Alice", Score: 95 });
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithImportMapping */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithImportMapping", () => {
+ it("should transform each row", async () => {
+ class MappedImport implements WithHeadingRow, WithImportMapping {
+ readonly hasHeadingRow = true as const;
+ mapRow(row: Record) {
+ return {
+ fullName: String(row.Name).toUpperCase(),
+ score: Number(row.Score) * 2,
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "mapped.xlsx");
+ await createXlsxFile(filePath, [["Alice", 50]], ["Name", "Score"]);
+
+ const result = await service.import(new MappedImport(), filePath);
+ expect(result.rows[0]).toEqual({ fullName: "ALICE", score: 100 });
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithStartRow */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithStartRow", () => {
+ it("should skip rows before startRow", async () => {
+ class SkipFirst implements WithStartRow {
+ startRow() {
+ return 3;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "startrow.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["Title"]);
+ ws.addRow(["Subtitle"]);
+ ws.addRow([1, "Data1"]);
+ ws.addRow([2, "Data2"]);
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new SkipFirst(), filePath);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0]).toEqual([1, "Data1"]);
+ });
+
+ it("should combine with WithHeadingRow", async () => {
+ class HeadingAtRow2 implements WithHeadingRow, WithStartRow {
+ readonly hasHeadingRow = true as const;
+ headingRow() {
+ return 2;
+ }
+ startRow() {
+ return 3;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "headstart.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["Junk"]);
+ ws.addRow(["Name", "Age"]);
+ ws.addRow(["Alice", 30]);
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new HeadingAtRow2(), filePath);
+ expect(result.rows[0]).toEqual({ Name: "Alice", Age: 30 });
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithLimit */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithLimit", () => {
+ it("should limit number of rows read", async () => {
+ class LimitedImport implements WithLimit {
+ limit() {
+ return 2;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "limit.xlsx");
+ await createXlsxFile(filePath, [
+ [1, "A"],
+ [2, "B"],
+ [3, "C"],
+ [4, "D"],
+ ]);
+
+ const result = await service.import(new LimitedImport(), filePath);
+ expect(result.rows).toHaveLength(2);
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* SkipsEmptyRows */
+ /* ---------------------------------------------------------------- */
+
+ describe("SkipsEmptyRows", () => {
+ it("should filter out blank rows", async () => {
+ class SkipEmpty implements SkipsEmptyRows {
+ readonly skipsEmptyRows = true as const;
+ }
+
+ const filePath = path.join(tmpDir, "empty.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow([1, "Alice"]);
+ ws.addRow([null, null]);
+ ws.addRow([2, "Bob"]);
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new SkipEmpty(), filePath);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0]).toEqual([1, "Alice"]);
+ expect(result.rows[1]).toEqual([2, "Bob"]);
+ });
+
+ it("should keep rows with at least one non-empty cell", async () => {
+ class SkipEmpty implements SkipsEmptyRows {
+ readonly skipsEmptyRows = true as const;
+ }
+
+ const filePath = path.join(tmpDir, "partial.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow([null, "Partial"]);
+ ws.addRow([null, null]);
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new SkipEmpty(), filePath);
+ expect(result.rows).toHaveLength(1);
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithColumnMapping */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithColumnMapping", () => {
+ it("should map column letters to field names", async () => {
+ class ColMapped implements WithColumnMapping {
+ columnMapping() {
+ return { name: "A", email: "C" };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "colmap.xlsx");
+ await createXlsxFile(filePath, [
+ ["Alice", 25, "alice@test.com"],
+ ]);
+
+ const result = await service.import(new ColMapped(), filePath);
+ expect(result.rows[0]).toEqual({
+ name: "Alice",
+ email: "alice@test.com",
+ });
+ });
+
+ it("should map 1-based column indices to field names", async () => {
+ class IdxMapped implements WithColumnMapping {
+ columnMapping() {
+ return { id: 1, score: 3 };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "idxmap.xlsx");
+ await createXlsxFile(filePath, [[100, "ignored", 95]]);
+
+ const result = await service.import(new IdxMapped(), filePath);
+ expect(result.rows[0]).toEqual({ id: 100, score: 95 });
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithValidation — custom rules */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithValidation — custom rules", () => {
+ it("should pass valid rows through", async () => {
+ class ValidImport implements WithHeadingRow, WithValidation {
+ readonly hasHeadingRow = true as const;
+ rules() {
+ return {
+ name: [
+ {
+ validate: (v: any) => typeof v === "string" && v.length > 0,
+ message: "Name is required",
+ },
+ ],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "valid.xlsx");
+ await createXlsxFile(filePath, [["Alice"]], ["name"]);
+
+ const result = await service.import(new ValidImport(), filePath);
+ expect(result.rows).toHaveLength(1);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it("should throw for invalid rows without SkipsOnError", async () => {
+ class StrictImport implements WithHeadingRow, WithValidation {
+ readonly hasHeadingRow = true as const;
+ rules() {
+ return {
+ email: [
+ {
+ validate: (v: any) => /^.+@.+\..+$/.test(String(v)),
+ message: "Invalid email",
+ },
+ ],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "invalid.xlsx");
+ await createXlsxFile(filePath, [["not-an-email"]], ["email"]);
+
+ await expect(
+ service.import(new StrictImport(), filePath),
+ ).rejects.toThrow("Import validation failed");
+ });
+
+ it("should collect multiple field errors per row", async () => {
+ class MultiRule
+ implements WithHeadingRow, WithValidation, SkipsOnError
+ {
+ readonly hasHeadingRow = true as const;
+ readonly skipsOnError = true as const;
+ rules() {
+ return {
+ name: [
+ {
+ validate: (v: any) => typeof v === "string" && v.length > 0,
+ message: "Name required",
+ },
+ ],
+ age: [
+ {
+ validate: (v: any) => typeof v === "number" && v > 0,
+ message: "Age must be positive",
+ },
+ ],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "multi-err.xlsx");
+ await createXlsxFile(filePath, [[null, -5]], ["name", "age"]);
+
+ const result = await service.import(new MultiRule(), filePath);
+ expect(result.errors).toHaveLength(1);
+ expect(result.errors[0].errors).toHaveLength(2);
+ expect(result.skipped).toBe(1);
+ expect(result.rows).toHaveLength(0);
+ });
+
+ it("should include row number in validation errors", async () => {
+ class RowNumCheck
+ implements WithHeadingRow, WithValidation, SkipsOnError
+ {
+ readonly hasHeadingRow = true as const;
+ readonly skipsOnError = true as const;
+ rules() {
+ return {
+ val: [
+ {
+ validate: (v: any) => v !== null && v !== "",
+ message: "Required",
+ },
+ ],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "rownum.xlsx");
+ await createXlsxFile(
+ filePath,
+ [
+ ["good"],
+ [""],
+ ["also good"],
+ ],
+ ["val"],
+ );
+
+ const result = await service.import(new RowNumCheck(), filePath);
+ expect(result.errors).toHaveLength(1);
+ expect(result.errors[0].row).toBe(3); // heading=row1, data starts row2, bad row is row3
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithValidation — DTO mode */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithValidation — DTO mode", () => {
+ class UserDto {
+ @IsString()
+ @IsNotEmpty()
+ name!: string;
+
+ @IsEmail()
+ email!: string;
+ }
+
+ it("should validate valid DTO rows", async () => {
+ class DtoImport implements WithHeadingRow, WithValidation {
+ readonly hasHeadingRow = true as const;
+ rules() {
+ return { dto: UserDto };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "dto-valid.xlsx");
+ await createXlsxFile(
+ filePath,
+ [["Alice", "alice@example.com"]],
+ ["name", "email"],
+ );
+
+ const result = await service.import(new DtoImport(), filePath);
+ expect(result.rows).toHaveLength(1);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it("should collect errors for invalid DTO rows", async () => {
+ class DtoSkipImport
+ implements WithHeadingRow, WithValidation, SkipsOnError
+ {
+ readonly hasHeadingRow = true as const;
+ readonly skipsOnError = true as const;
+ rules() {
+ return { dto: UserDto };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "dto-invalid.xlsx");
+ await createXlsxFile(
+ filePath,
+ [["", "not-email"]],
+ ["name", "email"],
+ );
+
+ const result = await service.import(new DtoSkipImport(), filePath);
+ expect(result.rows).toHaveLength(0);
+ expect(result.errors).toHaveLength(1);
+ expect(result.skipped).toBe(1);
+
+ const fieldNames = result.errors[0].errors.map((e) => e.field);
+ expect(fieldNames).toContain("name");
+ expect(fieldNames).toContain("email");
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* SkipsOnError */
+ /* ---------------------------------------------------------------- */
+
+ describe("SkipsOnError", () => {
+ it("should skip invalid rows and continue", async () => {
+ class SkippingImport
+ implements WithHeadingRow, WithValidation, SkipsOnError
+ {
+ readonly hasHeadingRow = true as const;
+ readonly skipsOnError = true as const;
+ rules() {
+ return {
+ score: [
+ {
+ validate: (v: any) => typeof v === "number" && v >= 0,
+ message: "Score must be non-negative",
+ },
+ ],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "skip.xlsx");
+ await createXlsxFile(
+ filePath,
+ [
+ ["Alice", 100],
+ ["Bob", -5],
+ ["Charlie", 85],
+ ],
+ ["name", "score"],
+ );
+
+ const result = await service.import(new SkippingImport(), filePath);
+ expect(result.rows).toHaveLength(2);
+ expect(result.skipped).toBe(1);
+ expect(result.errors).toHaveLength(1);
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* WithBatchInserts */
+ /* ---------------------------------------------------------------- */
+
+ describe("WithBatchInserts", () => {
+ it("should deliver rows in batches", async () => {
+ const batches: any[][] = [];
+
+ class BatchImport implements WithBatchInserts {
+ batchSize() {
+ return 2;
+ }
+ handleBatch(batch: any[]) {
+ batches.push([...batch]);
+ }
+ }
+
+ const filePath = path.join(tmpDir, "batch.xlsx");
+ await createXlsxFile(filePath, [
+ [1, "A"],
+ [2, "B"],
+ [3, "C"],
+ [4, "D"],
+ [5, "E"],
+ ]);
+
+ await service.import(new BatchImport(), filePath);
+ expect(batches).toHaveLength(3); // 2, 2, 1
+ expect(batches[0]).toHaveLength(2);
+ expect(batches[1]).toHaveLength(2);
+ expect(batches[2]).toHaveLength(1);
+ });
+
+ it("should support async handleBatch", async () => {
+ const results: number[] = [];
+
+ class AsyncBatch implements WithBatchInserts {
+ batchSize() {
+ return 3;
+ }
+ async handleBatch(batch: any[]) {
+ results.push(batch.length);
+ }
+ }
+
+ const filePath = path.join(tmpDir, "async-batch.xlsx");
+ await createXlsxFile(filePath, [
+ [1],
+ [2],
+ [3],
+ [4],
+ ]);
+
+ await service.import(new AsyncBatch(), filePath);
+ expect(results).toEqual([3, 1]);
+ });
+
+ it("should throw when batchSize is zero", async () => {
+ class ZeroBatch implements WithBatchInserts {
+ batchSize() {
+ return 0;
+ }
+ handleBatch() {}
+ }
+
+ const filePath = path.join(tmpDir, "zero-batch.xlsx");
+ await createXlsxFile(filePath, [[1]]);
+
+ await expect(
+ service.import(new ZeroBatch(), filePath),
+ ).rejects.toThrow("batchSize() must return a positive integer");
+ });
+
+ it("should throw when batchSize is negative", async () => {
+ class NegBatch implements WithBatchInserts {
+ batchSize() {
+ return -5;
+ }
+ handleBatch() {}
+ }
+
+ const filePath = path.join(tmpDir, "neg-batch.xlsx");
+ await createXlsxFile(filePath, [[1]]);
+
+ await expect(
+ service.import(new NegBatch(), filePath),
+ ).rejects.toThrow("batchSize() must return a positive integer");
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* Combined concerns */
+ /* ---------------------------------------------------------------- */
+
+ describe("combined concerns", () => {
+ it("should run full pipeline: heading + mapping + validation + skip", async () => {
+ class FullPipeline
+ implements
+ WithHeadingRow,
+ WithImportMapping,
+ WithValidation,
+ SkipsOnError
+ {
+ readonly hasHeadingRow = true as const;
+ readonly skipsOnError = true as const;
+
+ mapRow(row: Record) {
+ return {
+ name: String(row.name).trim(),
+ score: Number(row.score),
+ };
+ }
+
+ rules() {
+ return {
+ name: [
+ {
+ validate: (v: any) => v.length > 0,
+ message: "Name required",
+ },
+ ],
+ score: [
+ {
+ validate: (v: any) => !isNaN(v) && v >= 0,
+ message: "Score must be non-negative number",
+ },
+ ],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "full.xlsx");
+ await createXlsxFile(
+ filePath,
+ [
+ [" Alice ", 95],
+ ["", 50],
+ ["Charlie", -10],
+ ["Diana", 88],
+ ],
+ ["name", "score"],
+ );
+
+ const result = await service.import(new FullPipeline(), filePath);
+ expect(result.rows).toHaveLength(2);
+ expect(result.rows[0]).toEqual({ name: "Alice", score: 95 });
+ expect(result.rows[1]).toEqual({ name: "Diana", score: 88 });
+ expect(result.skipped).toBe(2);
+ expect(result.errors).toHaveLength(2);
+ });
+
+ it("should combine StartRow + Limit + SkipsEmptyRows", async () => {
+ class CombinedLimits implements WithStartRow, WithLimit, SkipsEmptyRows {
+ readonly skipsEmptyRows = true as const;
+ startRow() {
+ return 2;
+ }
+ limit() {
+ return 3;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "combined-limits.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["Header"]); // row 1, skipped by startRow
+ ws.addRow([1, "A"]); // row 2
+ ws.addRow([null, null]); // row 3, empty → skipped
+ ws.addRow([2, "B"]); // row 4
+ ws.addRow([3, "C"]); // row 5
+ ws.addRow([4, "D"]); // row 6
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new CombinedLimits(), filePath);
+ // After skipping row 1 (startRow=2), filtering empties, then limit 3
+ expect(result.rows).toHaveLength(3);
+ expect(result.rows[0]).toEqual([1, "A"]);
+ });
+
+ it("should import and re-export round-trip", async () => {
+ // Export
+ const exportFilePath = path.join(tmpDir, "roundtrip.xlsx");
+ await createXlsxFile(
+ exportFilePath,
+ [
+ [1, "Alice"],
+ [2, "Bob"],
+ ],
+ ["ID", "Name"],
+ );
+
+ // Import
+ const data = await service.toCollection(exportFilePath);
+ expect(data).toHaveLength(2);
+ expect(data[0]).toEqual({ ID: 1, Name: "Alice" });
+
+ // Re-export
+ class ReExport {
+ collection() {
+ return data.map((r) => [r.ID, r.Name]);
+ }
+ }
+
+ const buffer = await service.raw(new ReExport(), ExcelType.XLSX);
+ expect(buffer.length).toBeGreaterThan(0);
+ });
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* Edge cases */
+ /* ---------------------------------------------------------------- */
+
+ describe("edge cases", () => {
+ it("should return empty result for empty worksheet", async () => {
+ const filePath = path.join(tmpDir, "empty-ws.xlsx");
+ const wb = new Workbook();
+ wb.addWorksheet("Empty");
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import({}, filePath);
+ expect(result.rows).toHaveLength(0);
+ expect(result.errors).toEqual([]);
+ expect(result.skipped).toBe(0);
+ });
+
+ it("should handle heading row with no data rows", async () => {
+ class EmptyData implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ }
+
+ const filePath = path.join(tmpDir, "heading-only.xlsx");
+ await createXlsxFile(filePath, [], ["Col1", "Col2"]);
+
+ const result = await service.import(new EmptyData(), filePath);
+ expect(result.rows).toHaveLength(0);
+ });
+
+ it("should handle rich text cells", async () => {
+ const filePath = path.join(tmpDir, "richtext.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.getCell("A1").value = {
+ richText: [
+ { text: "Hello " },
+ { font: { bold: true }, text: "World" },
+ ],
+ } as any;
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import({}, filePath);
+ expect(result.rows[0][0]).toBe("Hello World");
+ });
+
+ it("should convert objects to arrays for ToArray with headings", async () => {
+ let captured: any[][] = [];
+
+ class ArrayWithHeadings implements ToArray, WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ handleArray(rows: any[][]) {
+ captured = rows;
+ }
+ }
+
+ const filePath = path.join(tmpDir, "arr-head.xlsx");
+ await createXlsxFile(filePath, [[1, "Alice"]], ["ID", "Name"]);
+
+ await service.import(new ArrayWithHeadings(), filePath);
+ expect(captured).toHaveLength(1);
+ expect(captured[0]).toEqual([1, "Alice"]);
+ });
+
+ it("should handle formula cells by extracting result", async () => {
+ const filePath = path.join(tmpDir, "formula.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.getCell("A1").value = 10;
+ ws.getCell("B1").value = { formula: "A1*2", result: 20 } as any;
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import({}, filePath);
+ expect(result.rows[0][0]).toBe(10);
+ expect(result.rows[0][1]).toBe(20);
+ });
+
+ it("should throw when no worksheet exists in buffer", async () => {
+ // Create a workbook with no worksheets — ExcelJS always adds one on
+ // xlsx.writeBuffer, so we manipulate the workbook after creation.
+ const wb = new Workbook();
+ wb.addWorksheet("TempSheet");
+ const buf = await wb.xlsx.writeBuffer();
+ const wb2 = new Workbook();
+ await wb2.xlsx.load(Buffer.from(buf));
+ // Remove all worksheets
+ while (wb2.worksheets.length > 0) {
+ wb2.removeWorksheet(wb2.worksheets[0].id);
+ }
+ const emptyBuf = Buffer.from(await wb2.xlsx.writeBuffer());
+
+ await expect(
+ service.importFromBuffer({}, emptyBuf),
+ ).rejects.toThrow("No worksheet found");
+ });
+
+ it("should use __col fallback for empty heading names", async () => {
+ class EmptyHeading implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ }
+
+ const filePath = path.join(tmpDir, "empty-heading.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["Name", null, "Email"]); // middle heading is null → empty
+ ws.addRow(["Alice", 25, "alice@test.com"]);
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new EmptyHeading(), filePath);
+ expect(result.rows[0]).toHaveProperty("Name", "Alice");
+ expect(result.rows[0]).toHaveProperty("__col1"); // fallback key
+ expect(result.rows[0]).toHaveProperty("Email", "alice@test.com");
+ });
+
+ it("should return null for column mapping index beyond row length", async () => {
+ class WideMapping implements WithColumnMapping {
+ columnMapping() {
+ return { name: "A", missing: "Z" }; // column Z won't exist in data
+ }
+ }
+
+ const filePath = path.join(tmpDir, "wide-map.xlsx");
+ await createXlsxFile(filePath, [["Alice"]]);
+
+ const result = await service.import(new WideMapping(), filePath);
+ expect(result.rows[0].name).toBe("Alice");
+ expect(result.rows[0].missing).toBeNull();
+ });
+
+ it("should return null for heading beyond row value length", async () => {
+ class WideHeading implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ }
+
+ const filePath = path.join(tmpDir, "wide-heading.xlsx");
+ const wb = new Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["Col1", "Col2", "Col3"]); // 3 headings
+ ws.addRow(["A"]); // only 1 value — Col2 and Col3 should be null
+ await wb.xlsx.writeFile(filePath);
+
+ const result = await service.import(new WideHeading(), filePath);
+ expect(result.rows[0]).toEqual({ Col1: "A", Col2: null, Col3: null });
+ });
+
+ it("should handle heading row beyond worksheet range", async () => {
+ class FarHeading implements WithHeadingRow {
+ readonly hasHeadingRow = true as const;
+ headingRow() {
+ return 999; // way beyond actual data
+ }
+ }
+
+ const filePath = path.join(tmpDir, "far-heading.xlsx");
+ await createXlsxFile(filePath, [["data"]]);
+
+ // Should still work — no headings detected, data returned as arrays
+ const result = await service.import(new FarHeading(), filePath);
+ // Row 1 is before startRow (999+1=1000), so no data
+ expect(result.rows).toHaveLength(0);
+ });
+
+ it("should attach validationErrors to thrown error", async () => {
+ class StrictValidation implements WithHeadingRow, WithValidation {
+ readonly hasHeadingRow = true as const;
+ rules() {
+ return {
+ val: [{ validate: () => false, message: "Always fails" }],
+ };
+ }
+ }
+
+ const filePath = path.join(tmpDir, "throw-err.xlsx");
+ await createXlsxFile(filePath, [["test"]], ["val"]);
+
+ try {
+ await service.import(new StrictValidation(), filePath);
+ expect.fail("Should have thrown");
+ } catch (err: any) {
+ expect(err.validationErrors).toBeDefined();
+ expect(err.validationErrors).toHaveLength(1);
+ expect(err.validationErrors[0].row).toBe(2);
+ }
+ });
+ });
+});
diff --git a/test/excel.service.spec.ts b/test/excel.service.spec.ts
index 82989fe..75c6843 100644
--- a/test/excel.service.spec.ts
+++ b/test/excel.service.spec.ts
@@ -1860,6 +1860,54 @@ describe("ExcelService", () => {
]);
});
+ it("should handle template with partial properties", async () => {
+ class PartialPropsTemplate implements FromTemplate, WithProperties {
+ templatePath() {
+ return templatePath;
+ }
+ bindings() {
+ return { "{{company}}": "TestCo" };
+ }
+ properties() {
+ return { creator: "OnlyCreator" };
+ }
+ }
+
+ const buffer = await service.raw(
+ new PartialPropsTemplate(),
+ ExcelType.XLSX,
+ );
+ const wb = await readXlsx(buffer);
+ expect(wb.creator).toBe("OnlyCreator");
+ // Other props should remain default/empty
+ expect(wb.title).toBeFalsy();
+ });
+
+ it("should skip non-string cells in template placeholder replacement", async () => {
+ const numericPath = path.join(tmpDir, "numeric-tpl.xlsx");
+ const nwb = new Workbook();
+ const nws = nwb.addWorksheet("Sheet1");
+ nws.getCell("A1").value = "{{name}}";
+ nws.getCell("B1").value = 12345; // numeric cell — not a string
+ nws.getCell("C1").value = true; // boolean cell
+ await nwb.xlsx.writeFile(numericPath);
+
+ class NumericTemplate implements FromTemplate {
+ templatePath() {
+ return numericPath;
+ }
+ bindings() {
+ return { "{{name}}": "Replaced" };
+ }
+ }
+
+ const buffer = await service.raw(new NumericTemplate(), ExcelType.XLSX);
+ const wb = await readXlsx(buffer);
+ expect(wb.worksheets[0].getCell("A1").value).toBe("Replaced");
+ expect(wb.worksheets[0].getCell("B1").value).toBe(12345);
+ expect(wb.worksheets[0].getCell("C1").value).toBe(true);
+ });
+
// cleanup
afterEach(() => {
if (fs.existsSync(tmpDir)) {
diff --git a/test/validate-row.spec.ts b/test/validate-row.spec.ts
new file mode 100644
index 0000000..32e23fc
--- /dev/null
+++ b/test/validate-row.spec.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from "vitest";
+import { validateRow, mapDtoErrors } from "../src/helpers/validate-row";
+import type { ValidationRules } from "../src/concerns/with-validation.interface";
+
+describe("validateRow", () => {
+ it("should skip undefined rule entries", async () => {
+ const rules: ValidationRules = {
+ name: [{ validate: (v) => v === "ok", message: "fail" }],
+ age: undefined, // should be skipped gracefully
+ };
+
+ const result = await validateRow({ name: "ok", age: 10 }, rules, 1);
+ expect(result).toBeNull();
+ });
+});
+
+describe("mapDtoErrors", () => {
+ it("should return null for empty errors array", () => {
+ expect(mapDtoErrors([], 1)).toBeNull();
+ });
+
+ it("should map errors with constraints", () => {
+ const errors = [
+ { property: "email", constraints: { isEmail: "email must be valid" } },
+ ];
+ const result = mapDtoErrors(errors, 3);
+ expect(result).not.toBeNull();
+ expect(result!.row).toBe(3);
+ expect(result!.errors[0].field).toBe("email");
+ expect(result!.errors[0].messages).toEqual(["email must be valid"]);
+ });
+
+ it("should return empty messages when constraints is undefined", () => {
+ const errors = [{ property: "name", constraints: undefined }];
+ const result = mapDtoErrors(errors, 2);
+ expect(result).not.toBeNull();
+ expect(result!.errors[0].field).toBe("name");
+ expect(result!.errors[0].messages).toEqual([]);
+ });
+
+ it("should return empty messages when constraints is null", () => {
+ const errors = [{ property: "age", constraints: null }];
+ const result = mapDtoErrors(errors, 5);
+ expect(result).not.toBeNull();
+ expect(result!.errors[0].field).toBe("age");
+ expect(result!.errors[0].messages).toEqual([]);
+ });
+});