From 658d069f744cf8c87f1048791a66fbd24316b05d Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:51:47 -0400 Subject: [PATCH 01/13] add types for json writer --- src/types.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/types.ts b/src/types.ts index 5b51be0..7cf2e51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -196,6 +196,51 @@ export interface CsvConfig { includeUtf8Bom?: boolean; } +/** + * JSON-specific configuration options. + * + * Customize JSON output format, indentation, and structure. + * + * @example + * ```typescript + * // Pretty-printed with 2-space indentation + * const config: JsonConfig = { + * prettyPrint: true, + * indent: 2 + * }; + * ``` + */ +export interface JsonConfig { + /** + * Enable pretty-printing (formatted with indentation and newlines). + * + * When true, output will be human-readable with proper indentation. + * When false, output will be compact single-line JSON. + * + * @default true + */ + prettyPrint?: boolean; + + /** + * Number of spaces for indentation when prettyPrint is enabled. + * + * Only applies when prettyPrint is true. + * + * @default 2 + */ + indent?: number; + + /** + * Include UTF-8 Byte Order Mark (BOM) at the start of the file. + * + * Set to true for better compatibility with some legacy tools when your data + * contains non-ASCII characters (accents, emoji, etc.). + * + * @default false + */ + includeUtf8Bom?: boolean; +} + /** * Complete writer options including type-specific configuration. * @@ -228,6 +273,9 @@ export interface WriterOptions { /** CSV-specific configuration options */ csvConfig?: CsvConfig; + + /** JSON-specific configuration options */ + jsonConfig?: JsonConfig; } /** From b4f4a1a06c80e014ab55bfb7d2fd18b8bcf087a3 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:51:56 -0400 Subject: [PATCH 02/13] create json formatter --- src/writers/json/JsonFormatter.ts | 78 +++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/writers/json/JsonFormatter.ts diff --git a/src/writers/json/JsonFormatter.ts b/src/writers/json/JsonFormatter.ts new file mode 100644 index 0000000..744e754 --- /dev/null +++ b/src/writers/json/JsonFormatter.ts @@ -0,0 +1,78 @@ +import { JsonFormattingError } from '../../errors'; + +/** + * Handles JSON formatting logic - converting data to properly formatted JSON strings. + * + * Provides control over formatting options like pretty-printing and indentation, + * following the Single Responsibility Principle by focusing solely on formatting. + * + * @example + * ```typescript + * const formatter = new JsonFormatter({ prettyPrint: true, indent: 2 }); + * const json = formatter.format([{ id: 1, name: 'Alice' }]); + * ``` + */ +export class JsonFormatter { + private readonly prettyPrint: boolean; + private readonly indent: number; + + /** + * Creates a new JSON formatter instance. + * + * @param prettyPrint - Whether to format JSON with indentation and newlines (default: true) + * @param indent - Number of spaces for indentation when prettyPrint is true (default: 2) + */ + constructor(prettyPrint: boolean = true, indent: number = 2) { + this.prettyPrint = prettyPrint; + this.indent = indent; + } + + /** + * Formats data as a JSON string. + * + * In write mode, formats the data as a complete JSON array. + * In append mode, formats individual items without array brackets. + * + * @param data - Data to format as JSON + * @param isArrayContext - If true, format as complete array; if false, format as individual items + * @returns Formatted JSON string + * @throws {JsonFormattingError} If data cannot be serialized to JSON + */ + format(data: T[], isArrayContext: boolean = true): string { + try { + if (isArrayContext) { + // Format as complete JSON array + return this.prettyPrint ? JSON.stringify(data, null, this.indent) : JSON.stringify(data); + } else { + // Format individual items for appending + // Each item on its own line, no outer array brackets + return data + .map((item) => + this.prettyPrint ? JSON.stringify(item, null, this.indent) : JSON.stringify(item) + ) + .join('\n'); + } + } catch (error) { + throw new JsonFormattingError( + `Failed to format data as JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Formats a single data item as JSON. + * + * @param data - Single data item to format + * @returns Formatted JSON string + * @throws {JsonFormattingError} If data cannot be serialized to JSON + */ + formatItem(data: T): string { + try { + return this.prettyPrint ? JSON.stringify(data, null, this.indent) : JSON.stringify(data); + } catch (error) { + throw new JsonFormattingError( + `Failed to format data as JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} From 7cd822f9cd289fb2380c84c6bc8e34852e18f838 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:52:04 -0400 Subject: [PATCH 03/13] create json writer --- src/writers/json/JsonWriter.ts | 386 +++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/writers/json/JsonWriter.ts diff --git a/src/writers/json/JsonWriter.ts b/src/writers/json/JsonWriter.ts new file mode 100644 index 0000000..157fb12 --- /dev/null +++ b/src/writers/json/JsonWriter.ts @@ -0,0 +1,386 @@ +import type { OutportWriter, WriterOptions, Result, FileWriter } from '../../types'; +import { ValidationError, JsonFormattingError } from '../../errors'; +import { NodeFileWriter } from '../../io/FileWriter'; +import { JsonFormatter } from './JsonFormatter'; +import * as fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; + +/** + * JSON Writer for exporting data to JSON files. + * + * Provides both synchronous and asynchronous methods for writing and appending + * data with support for pretty-printing, custom indentation, and UTF-8 BOM. + * + * The writer handles JSON formatting and file I/O, following SOLID principles + * by delegating formatting concerns to the JsonFormatter class. + * + * @template T - The type of data objects being written. Must extend Record + * + * @example + * ```typescript + * interface User extends Record { + * id: number; + * name: string; + * email: string; + * } + * + * const writer = new JsonWriter({ + * type: 'json', + * mode: 'write', + * file: './users.json', + * jsonConfig: { + * prettyPrint: true, + * indent: 2, + * includeUtf8Bom: false + * } + * }); + * + * const users = [ + * { id: 1, name: 'Alice', email: 'alice@example.com' }, + * { id: 2, name: 'Bob', email: 'bob@example.com' } + * ]; + * + * // Synchronous write + * const result = writer.writeSync(users); + * + * // Asynchronous append + * await writer.append({ id: 3, name: 'Charlie', email: 'charlie@example.com' }); + * ``` + */ +export class JsonWriter> implements OutportWriter { + private readonly formatter: JsonFormatter; + private readonly fileWriter: FileWriter; + private readonly includeUtf8Bom: boolean; + private dataArray: T[] = []; + private isInitialized: boolean = false; + + /** + * Creates a new JSON writer instance. + * + * @param options - Configuration options for the JSON writer + * @param fileWriter - Optional custom file writer for dependency injection (useful for testing) + * + * @throws {ValidationError} If configuration is invalid (e.g., non-json type, empty file path) + * + * @example + * ```typescript + * const writer = new JsonWriter({ + * type: 'json', + * mode: 'write', + * file: './output.json' + * }); + * ``` + */ + constructor( + private readonly options: WriterOptions, + fileWriter: FileWriter = new NodeFileWriter() + ) { + this.validate(options); + this.fileWriter = fileWriter; + + // Initialize formatter with config + const prettyPrint = options.jsonConfig?.prettyPrint ?? true; + const indent = options.jsonConfig?.indent ?? 2; + this.formatter = new JsonFormatter(prettyPrint, indent); + + this.includeUtf8Bom = options.jsonConfig?.includeUtf8Bom ?? false; + } + + /** + * Validates writer options + */ + private validate(options: WriterOptions): void { + if (options.type !== 'json') { + throw new ValidationError('Invalid writer type for JsonWriter'); + } + + if (options.file == null || options.file.length === 0) { + throw new ValidationError('File path must be provided for JsonWriter'); + } + + if (!options.file.endsWith('.json')) { + throw new ValidationError('File extension must be .json for JsonWriter'); + } + + const indent = options.jsonConfig?.indent ?? 2; + if (indent < 0 || indent > 10) { + throw new ValidationError('Indent must be between 0 and 10'); + } + } + + /** + * Loads existing data from file if it exists and is in append mode + */ + private loadExistingDataSync(): Result { + if (this.options.mode === 'append' && this.fileWriter.existsSync(this.options.file)) { + try { + const content: string = fs.readFileSync(this.options.file, 'utf-8'); + // Remove BOM if present + const cleanContent: string = content.replace(/^\uFEFF/, ''); + if (cleanContent.trim()) { + const parsed: unknown = JSON.parse(cleanContent); + this.dataArray = Array.isArray(parsed) ? (parsed as T[]) : [parsed as T]; + } + return { success: true, value: undefined }; + } catch (error) { + return { + success: false, + error: new JsonFormattingError( + `Failed to parse existing JSON file: ${error instanceof Error ? error.message : String(error)}` + ), + }; + } + } + return { success: true, value: undefined }; + } + + /** + * Loads existing data from file if it exists and is in append mode + */ + private async loadExistingData(): Promise> { + if (this.options.mode === 'append') { + const exists = await this.fileWriter.exists(this.options.file); + if (exists) { + try { + const content: string = await fsPromises.readFile(this.options.file, 'utf-8'); + // Remove BOM if present + const cleanContent: string = content.replace(/^\uFEFF/, ''); + if (cleanContent.trim()) { + const parsed: unknown = JSON.parse(cleanContent); + this.dataArray = Array.isArray(parsed) ? (parsed as T[]) : [parsed as T]; + } + return { success: true, value: undefined }; + } catch (error) { + return { + success: false, + error: new JsonFormattingError( + `Failed to parse existing JSON file: ${error instanceof Error ? error.message : String(error)}` + ), + }; + } + } + } + return { success: true, value: undefined }; + } + + /** + * Writes the complete data array to file (sync) + */ + private writeDataSync(): Result { + try { + const json = this.formatter.format(this.dataArray, true); + const content = this.includeUtf8Bom ? '\uFEFF' + json : json; + return this.fileWriter.writeSync(this.options.file, content); + } catch (error) { + return { + success: false, + error: + error instanceof JsonFormattingError + ? error + : new JsonFormattingError(error instanceof Error ? error.message : String(error)), + }; + } + } + + /** + * Writes the complete data array to file (async) + */ + private async writeData(): Promise> { + try { + const json = this.formatter.format(this.dataArray, true); + const content = this.includeUtf8Bom ? '\uFEFF' + json : json; + return await this.fileWriter.write(this.options.file, content); + } catch (error) { + return { + success: false, + error: + error instanceof JsonFormattingError + ? error + : new JsonFormattingError(error instanceof Error ? error.message : String(error)), + }; + } + } + + // ==================== PUBLIC API ==================== + + /** + * Synchronously writes multiple rows of data to the file. + * + * In 'write' mode, this overwrites the entire file with a JSON array. + * In 'append' mode, this adds data to the existing array in the file. + * + * @param data - Array of data objects to write + * @returns Result indicating success or failure + * + * @example + * ```typescript + * const result = writer.writeSync([ + * { id: 1, name: 'Alice' }, + * { id: 2, name: 'Bob' } + * ]); + * + * if (result.success) { + * console.log('Write successful!'); + * } else { + * console.error('Write failed:', result.error.message); + * } + * ``` + */ + writeSync(data: T[]): Result { + if (data.length === 0) { + return { + success: false, + error: new ValidationError('Cannot write empty data array'), + }; + } + + if (this.options.mode === 'write') { + // In write mode, replace all data + this.dataArray = [...data]; + this.isInitialized = true; + return this.writeDataSync(); + } else { + // In append mode, load existing data first if not initialized + if (!this.isInitialized) { + const loadResult = this.loadExistingDataSync(); + if (!loadResult.success) { + return loadResult; + } + this.isInitialized = true; + } + this.dataArray.push(...data); + return this.writeDataSync(); + } + } + + /** + * Asynchronously writes multiple rows of data to the file. + * + * In 'write' mode, this overwrites the entire file with a JSON array. + * In 'append' mode, this adds data to the existing array in the file. + * + * @param data - Array of data objects to write + * @returns Promise of Result indicating success or failure + * + * @example + * ```typescript + * const result = await writer.write([ + * { id: 1, name: 'Alice' }, + * { id: 2, name: 'Bob' } + * ]); + * + * if (result.success) { + * console.log('Write successful!'); + * } + * ``` + */ + async write(data: T[]): Promise> { + if (data.length === 0) { + return { + success: false, + error: new ValidationError('Cannot write empty data array'), + }; + } + + if (this.options.mode === 'write') { + // In write mode, replace all data + this.dataArray = [...data]; + this.isInitialized = true; + return await this.writeData(); + } else { + // In append mode, load existing data first if not initialized + if (!this.isInitialized) { + const loadResult = await this.loadExistingData(); + if (!loadResult.success) { + return loadResult; + } + this.isInitialized = true; + } + this.dataArray.push(...data); + return await this.writeData(); + } + } + + /** + * Synchronously appends one or more rows to the file. + * + * Loads the existing JSON array from the file, adds new items, and writes + * the complete array back. If the file doesn't exist, creates a new array. + * + * @param data - Single data object or array of objects to append + * @returns Result indicating success or failure + * + * @example + * ```typescript + * // Append single row + * writer.appendSync({ id: 3, name: 'Charlie' }); + * + * // Append multiple rows + * writer.appendSync([ + * { id: 4, name: 'Diana' }, + * { id: 5, name: 'Eve' } + * ]); + * + * // Append empty array is allowed (no-op) + * writer.appendSync([]); + * ``` + */ + appendSync(data: T | T[]): Result { + const dataArray = Array.isArray(data) ? data : [data]; + + if (dataArray.length === 0) { + return { success: true, value: undefined }; + } + + // Load existing data if not initialized + if (!this.isInitialized) { + const loadResult = this.loadExistingDataSync(); + if (!loadResult.success) { + return loadResult; + } + this.isInitialized = true; + } + + this.dataArray.push(...dataArray); + return this.writeDataSync(); + } + + /** + * Asynchronously appends one or more rows to the file. + * + * Loads the existing JSON array from the file, adds new items, and writes + * the complete array back. If the file doesn't exist, creates a new array. + * + * Useful for streaming large datasets or processing async generators. + * + * @param data - Single data object or array of objects to append + * @returns Promise of Result indicating success or failure + * + * @example + * ```typescript + * // Append from async generator + * for await (const user of fetchUsers()) { + * await writer.append(user); + * } + * ``` + */ + async append(data: T | T[]): Promise> { + const dataArray = Array.isArray(data) ? data : [data]; + + if (dataArray.length === 0) { + return { success: true, value: undefined }; + } + + // Load existing data if not initialized + if (!this.isInitialized) { + const loadResult = await this.loadExistingData(); + if (!loadResult.success) { + return loadResult; + } + this.isInitialized = true; + } + + this.dataArray.push(...dataArray); + return await this.writeData(); + } +} From 625b0eee8b99beca80679ed9181722d88f172647 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:52:13 -0400 Subject: [PATCH 04/13] update error types --- src/errors.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/errors.ts b/src/errors.ts index 935d051..7167cd6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -54,6 +54,19 @@ export class CsvFormattingError extends OutportError { } } +/** + * Error thrown when JSON formatting fails. + * + * This error occurs when data cannot be properly formatted as JSON, + * typically due to circular references or non-serializable values. + */ +export class JsonFormattingError extends OutportError { + constructor(message: string) { + super(message); + this.name = 'JsonFormattingError'; + } +} + /** * Error thrown when file write operation fails. * From 8beddab20acdf257774ced20b0a23eda2ac293ee Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:52:35 -0400 Subject: [PATCH 05/13] update index exports --- src/index.test.ts | 4 ++-- src/index.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 02baaa2..8fdcc80 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -17,13 +17,13 @@ describe('WriterFactory', () => { }).toThrow(ValidationError); }); - it('should throw ValidationError for JSON writer (not yet implemented)', () => { + it('should create JsonWriter successfully', () => { expect(() => { WriterFactory.create({ type: 'json', mode: 'write', file: 'test.json', }); - }).toThrow('JSON writer not yet implemented'); + }).not.toThrow(); }); }); diff --git a/src/index.ts b/src/index.ts index d57499f..0b0b0a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export type { WriterType, WriterMode, CsvConfig, + JsonConfig, Result, FileWriter, } from './types'; @@ -15,12 +16,14 @@ export { OutportError, ValidationError, CsvFormattingError, + JsonFormattingError, FileWriteError, HeaderInitializationError, } from './errors'; // Export writers export { CsvWriter } from './writers/csv/CsvWriter'; +export { JsonWriter } from './writers/json/JsonWriter'; export { WriterFactory } from './writers/WriterFactory'; // Export file writer implementation From 8e308380c4ea664c38efb5e894b04653c78e3c21 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:52:45 -0400 Subject: [PATCH 06/13] add json writer to factory --- src/writers/WriterFactory.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/writers/WriterFactory.ts b/src/writers/WriterFactory.ts index 126de2e..a5923d7 100644 --- a/src/writers/WriterFactory.ts +++ b/src/writers/WriterFactory.ts @@ -1,5 +1,6 @@ import type { OutportWriter, WriterConfig, FileWriter } from '../types'; import { CsvWriter } from './csv/CsvWriter'; +import { JsonWriter } from './json/JsonWriter'; import { ValidationError } from '../errors'; /** @@ -46,7 +47,7 @@ export class WriterFactory { case 'csv': return new CsvWriter(config, fileWriter); case 'json': - throw new ValidationError('JSON writer not yet implemented'); + return new JsonWriter(config, fileWriter); default: { // Exhaustive check - this should never be reached const _exhaustive: never = config.type; From 7be181913cada47906952a3a23622b060c265465 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:52:56 -0400 Subject: [PATCH 07/13] export types --- src/writers/json/index.ts | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/writers/json/index.ts diff --git a/src/writers/json/index.ts b/src/writers/json/index.ts new file mode 100644 index 0000000..7574f47 --- /dev/null +++ b/src/writers/json/index.ts @@ -0,0 +1,2 @@ +export { JsonWriter } from './JsonWriter'; +export { JsonFormatter } from './JsonFormatter'; From 394f86a84b635c3d9e2e12d335c411c674784f03 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:53:03 -0400 Subject: [PATCH 08/13] add doc --- docs/json-writer.md | 290 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/json-writer.md diff --git a/docs/json-writer.md b/docs/json-writer.md new file mode 100644 index 0000000..795b603 --- /dev/null +++ b/docs/json-writer.md @@ -0,0 +1,290 @@ +# JSON Writer Guide + +Quick reference for using the `JsonWriter` class to export data to JSON files. + +## Basic Usage + +### Simple Write + +```typescript +import { JsonWriter } from 'outport'; + +interface User { + id: number; + name: string; + email: string; +} + +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './output/users.json', +}); + +const users: User[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, +]; + +// Synchronous +const result = writer.writeSync(users); + +// Asynchronous +const result = await writer.write(users); +``` + +### Append Mode + +```typescript +const writer = new JsonWriter({ + type: 'json', + mode: 'append', + file: './output/users.json', +}); + +// Append single object +writer.appendSync({ id: 3, name: 'Charlie', email: 'charlie@example.com' }); + +// Append multiple objects +await writer.append([ + { id: 4, name: 'Diana', email: 'diana@example.com' }, + { id: 5, name: 'Eve', email: 'eve@example.com' }, +]); +``` + +**Note:** Unlike CSV append mode which adds rows to the end of a file, JSON append mode loads the entire JSON array, adds new items, and rewrites the complete array. This is necessary to maintain valid JSON structure. + +### Async Generator (Streaming Large Datasets) + +```typescript +async function* fetchUsers(): AsyncGenerator { + // Simulate fetching data in batches from database/API + for (let i = 0; i < 1000; i += 100) { + const batch = await fetchUserBatch(i, 100); + for (const user of batch) { + yield user; + } + } +} + +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './output/large-dataset.json', +}); + +// Process first batch to initialize +const generator = fetchUsers(); +const firstBatch = []; +for (let i = 0; i < 100; i++) { + const { value, done } = await generator.next(); + if (done) break; + firstBatch.push(value); +} +await writer.write(firstBatch); + +// Stream remaining items +for await (const user of generator) { + await writer.append(user); +} +``` + +## Custom Configuration + +### Compact Output (No Pretty Print) + +```typescript +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './output/users.json', + jsonConfig: { + prettyPrint: false, // Single-line, compact JSON + }, +}); +``` + +### Custom Indentation + +```typescript +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './output/users.json', + jsonConfig: { + prettyPrint: true, + indent: 4, // Use 4 spaces instead of default 2 + }, +}); +``` + +### UTF-8 BOM + +```typescript +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './output/users.json', + jsonConfig: { + includeUtf8Bom: true, // Adds BOM for compatibility with some legacy tools + }, +}); +``` + +## Configuration Options + +| Option | Type | Default | Description | +| ---------------- | --------- | ------- | ------------------------------------------------------- | +| `prettyPrint` | `boolean` | `true` | Format JSON with indentation and newlines | +| `indent` | `number` | `2` | Number of spaces for indentation (0-10) | +| `includeUtf8Bom` | `boolean` | `false` | Add UTF-8 BOM at start of file for legacy compatibility | + +## Error Handling + +The writer uses a Result type pattern for error handling: + +```typescript +const result = writer.writeSync(users); + +if (result.success) { + console.log('Write successful!'); +} else { + console.error('Write failed:', result.error.message); +} +``` + +## Factory Pattern + +Use the `WriterFactory` to create writers: + +```typescript +import { WriterFactory } from 'outport'; + +const writer = WriterFactory.create({ + type: 'json', + mode: 'write', + file: './output/users.json', + jsonConfig: { + prettyPrint: true, + indent: 2, + }, +}); +``` + +## Output Format + +The JSON writer always outputs data as a JSON array: + +```json +[ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com" + }, + { + "id": 2, + "name": "Bob", + "email": "bob@example.com" + } +] +``` + +With `prettyPrint: false`: + +```json +[ + { "id": 1, "name": "Alice", "email": "alice@example.com" }, + { "id": 2, "name": "Bob", "email": "bob@example.com" } +] +``` + +## Comparison with CSV Writer + +| Feature | CSV Writer | JSON Writer | +| ---------------------- | ------------------------- | --------------------------------- | +| **Output Format** | Tabular rows | Structured JSON array | +| **Headers** | Required, customizable | Not applicable (uses object keys) | +| **Nested Objects** | Serialized to string | Native support | +| **Arrays** | Serialized to string | Native support | +| **Append Performance** | Fast (file append) | Slower (reload + rewrite) | +| **File Size** | Typically smaller | Typically larger | +| **Human Readable** | Yes (with column headers) | Yes (with prettyPrint) | +| **Excel Compatible** | Yes | No (requires conversion) | +| **API Friendly** | Less common | Very common | + +## Tips + +- Use `mode: 'write'` to overwrite files each time +- Use `mode: 'append'` to add items to existing JSON arrays (note: this reloads and rewrites the entire file) +- JSON append mode is less efficient than CSV append for large files since it must rewrite the entire array +- Set `prettyPrint: false` for smaller file sizes and faster writes +- JSON is ideal for nested/complex data structures that don't fit well in CSV format +- File paths should end with `.json` extension +- Consider using streaming CSV for very large datasets where JSON's memory requirements might be problematic + +## Special Data Types + +The JSON writer handles TypeScript/JavaScript data types according to JSON specification: + +- **Strings**: Preserved as-is +- **Numbers**: Preserved as-is +- **Booleans**: Preserved as-is +- **null**: Preserved as-is +- **undefined**: Omitted from output (JSON.stringify behavior) +- **Dates**: Serialized as ISO 8601 strings +- **Nested Objects**: Fully supported +- **Arrays**: Fully supported +- **Functions**: Omitted (JSON.stringify behavior) +- **Symbols**: Omitted (JSON.stringify behavior) + +## Common Use Cases + +### API Response Export + +```typescript +// Export API responses to JSON files +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './api-backup.json', +}); + +const responses = await fetchAllFromAPI(); +await writer.write(responses); +``` + +### Configuration File Generation + +```typescript +// Generate configuration files +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './config/generated.json', + jsonConfig: { + prettyPrint: true, + indent: 2, + }, +}); + +const config = generateConfig(); +writer.writeSync([config]); +``` + +### Data Pipeline Output + +```typescript +// Output processed data for downstream systems +const writer = new JsonWriter({ + type: 'json', + mode: 'write', + file: './output/processed.json', + jsonConfig: { + prettyPrint: false, // Compact for smaller files + }, +}); + +const processed = await processDataPipeline(); +await writer.write(processed); +``` From 01b20c7ac804bf50773f6cc0ce7ae220ca145798 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:53:08 -0400 Subject: [PATCH 09/13] add tests --- __tests__/writers/json/JsonFormatter.test.ts | 307 +++++++++ __tests__/writers/json/JsonWriter.test.ts | 653 +++++++++++++++++++ 2 files changed, 960 insertions(+) create mode 100644 __tests__/writers/json/JsonFormatter.test.ts create mode 100644 __tests__/writers/json/JsonWriter.test.ts diff --git a/__tests__/writers/json/JsonFormatter.test.ts b/__tests__/writers/json/JsonFormatter.test.ts new file mode 100644 index 0000000..ad10fdf --- /dev/null +++ b/__tests__/writers/json/JsonFormatter.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from 'vitest'; +import { JsonFormatter } from '../../../src/writers/json/JsonFormatter'; +import { JsonFormattingError } from '../../../src/errors'; + +interface TestData extends Record { + id: number; + name: string; +} + +describe('JsonFormatter', () => { + describe('constructor', () => { + it('should create formatter with default options', () => { + // Arrange & Act + const formatter = new JsonFormatter(); + + // Assert + expect(formatter).toBeInstanceOf(JsonFormatter); + }); + + it('should create formatter with custom options', () => { + // Arrange & Act + const formatter = new JsonFormatter(false, 4); + + // Assert + expect(formatter).toBeInstanceOf(JsonFormatter); + }); + }); + + describe('format', () => { + describe('array context (isArrayContext = true)', () => { + it('should format data as pretty-printed JSON array by default', () => { + // Arrange + const formatter = new JsonFormatter(); + const data: TestData[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toBe(JSON.stringify(data, null, 2)); + expect(result).toContain('[\n'); + expect(result).toContain(' {\n'); + expect(result).toContain(' "id": 1'); + }); + + it('should format data as compact JSON array when prettyPrint is false', () => { + // Arrange + const formatter = new JsonFormatter(false); + const data: TestData[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toBe(JSON.stringify(data)); + expect(result).not.toContain('\n'); + expect(result).toBe('[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]'); + }); + + it('should format data with custom indentation', () => { + // Arrange + const formatter = new JsonFormatter(true, 4); + const data: TestData[] = [{ id: 1, name: 'Alice' }]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toBe(JSON.stringify(data, null, 4)); + expect(result).toContain(' {\n'); + expect(result).toContain(' "id": 1'); + }); + + it('should format empty array', () => { + // Arrange + const formatter = new JsonFormatter(); + const data: TestData[] = []; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toBe('[]'); + }); + + it('should format array with complex nested objects', () => { + // Arrange + const formatter = new JsonFormatter(); + interface ComplexData extends Record { + id: number; + metadata: { tags: string[]; count: number }; + } + const data: ComplexData[] = [ + { + id: 1, + metadata: { + tags: ['tag1', 'tag2'], + count: 5, + }, + }, + ]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toContain('"tags": ['); + expect(result).toContain('"tag1"'); + expect(result).toContain('"count": 5'); + }); + }); + + describe('non-array context (isArrayContext = false)', () => { + it('should format items without array brackets', () => { + // Arrange + const formatter = new JsonFormatter(); + const data: TestData[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + // Act + const result = formatter.format(data, false); + + // Assert + expect(result).not.toContain('['); + expect(result).toContain('{\n "id": 1'); + expect(result).toContain('{\n "id": 2'); + expect(result.split('\n{\n').length).toBe(2); // Two separate objects + }); + + it('should format items without array brackets in compact mode', () => { + // Arrange + const formatter = new JsonFormatter(false); + const data: TestData[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + // Act + const result = formatter.format(data, false); + + // Assert + expect(result).not.toContain('['); + expect(result).toBe('{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}'); + }); + }); + + describe('error handling', () => { + it('should throw JsonFormattingError for circular references', () => { + // Arrange + const formatter = new JsonFormatter(); + interface CircularData extends Record { + id: number; + self?: CircularData; + } + const circular: CircularData = { id: 1 }; + circular.self = circular; + + // Act & Assert + expect(() => formatter.format([circular], true)).toThrow(JsonFormattingError); + expect(() => formatter.format([circular], true)).toThrow(/Failed to format data as JSON/); + }); + }); + + describe('special values', () => { + it('should handle null values', () => { + // Arrange + const formatter = new JsonFormatter(); + interface NullableData extends Record { + id: number; + name: string | null; + } + const data: NullableData[] = [{ id: 1, name: null }]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toContain('"name": null'); + }); + + it('should handle undefined values (converted to null by JSON.stringify)', () => { + // Arrange + const formatter = new JsonFormatter(); + interface UndefinedData extends Record { + id: number; + name?: string; + } + const data: UndefinedData[] = [{ id: 1 }]; + + // Act + const result = formatter.format(data, true); + + // Assert + // undefined values are omitted by JSON.stringify + expect(result).not.toContain('name'); + expect(result).toContain('"id": 1'); + }); + + it('should handle boolean values', () => { + // Arrange + const formatter = new JsonFormatter(); + interface BooleanData extends Record { + id: number; + active: boolean; + } + const data: BooleanData[] = [ + { id: 1, active: true }, + { id: 2, active: false }, + ]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toContain('"active": true'); + expect(result).toContain('"active": false'); + }); + + it('should handle number values including zero and negative', () => { + // Arrange + const formatter = new JsonFormatter(); + interface NumberData extends Record { + id: number; + value: number; + } + const data: NumberData[] = [ + { id: 1, value: 0 }, + { id: 2, value: -42 }, + { id: 3, value: 3.14 }, + ]; + + // Act + const result = formatter.format(data, true); + + // Assert + expect(result).toContain('"value": 0'); + expect(result).toContain('"value": -42'); + expect(result).toContain('"value": 3.14'); + }); + }); + }); + + describe('formatItem', () => { + it('should format single item as pretty-printed JSON', () => { + // Arrange + const formatter = new JsonFormatter(); + const data: TestData = { id: 1, name: 'Alice' }; + + // Act + const result = formatter.formatItem(data); + + // Assert + expect(result).toBe(JSON.stringify(data, null, 2)); + expect(result).toContain('{\n'); + expect(result).toContain(' "id": 1'); + }); + + it('should format single item as compact JSON when prettyPrint is false', () => { + // Arrange + const formatter = new JsonFormatter(false); + const data: TestData = { id: 1, name: 'Alice' }; + + // Act + const result = formatter.formatItem(data); + + // Assert + expect(result).toBe(JSON.stringify(data)); + expect(result).toBe('{"id":1,"name":"Alice"}'); + }); + + it('should format single item with custom indentation', () => { + // Arrange + const formatter = new JsonFormatter(true, 4); + const data: TestData = { id: 1, name: 'Alice' }; + + // Act + const result = formatter.formatItem(data); + + // Assert + expect(result).toBe(JSON.stringify(data, null, 4)); + expect(result).toContain(' "id": 1'); + }); + + it('should throw JsonFormattingError for circular references', () => { + // Arrange + const formatter = new JsonFormatter(); + interface CircularData extends Record { + id: number; + self?: CircularData; + } + const circular: CircularData = { id: 1 }; + circular.self = circular; + + // Act & Assert + expect(() => formatter.formatItem(circular)).toThrow(JsonFormattingError); + expect(() => formatter.formatItem(circular)).toThrow(/Failed to format data as JSON/); + }); + }); +}); diff --git a/__tests__/writers/json/JsonWriter.test.ts b/__tests__/writers/json/JsonWriter.test.ts new file mode 100644 index 0000000..54a475b --- /dev/null +++ b/__tests__/writers/json/JsonWriter.test.ts @@ -0,0 +1,653 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { JsonWriter } from '../../../src/writers/json/JsonWriter'; +import type { WriterOptions, FileWriter } from '../../../src/types'; + +interface TestUser extends Record { + id: number; + name: string; + email: string; +} + +describe('JsonWriter', () => { + const testDir = path.join(process.cwd(), '__tests__', 'temp', 'json-writer'); + let testFile: string; + + beforeEach(() => { + // Create test directory + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + // Create unique test file for each test + testFile = path.join(testDir, `test-${Date.now()}-${Math.random()}.json`); + }); + + afterEach(() => { + // Clean up test files + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + }); + + describe('constructor validation', () => { + it('should throw error for non-json type', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + }; + + // Act & Assert + expect(() => new JsonWriter(options)).toThrow('Invalid writer type for JsonWriter'); + }); + + it('should throw error for empty file path', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: '', + }; + + // Act & Assert + expect(() => new JsonWriter(options)).toThrow('File path must be provided for JsonWriter'); + }); + + it('should throw error for non-json file extension', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: 'test.txt', + }; + + // Act & Assert + expect(() => new JsonWriter(options)).toThrow('File extension must be .json for JsonWriter'); + }); + + it('should throw error for invalid indent value (negative)', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + jsonConfig: { + indent: -1, + }, + }; + + // Act & Assert + expect(() => new JsonWriter(options)).toThrow('Indent must be between 0 and 10'); + }); + + it('should throw error for invalid indent value (too large)', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + jsonConfig: { + indent: 11, + }, + }; + + // Act & Assert + expect(() => new JsonWriter(options)).toThrow('Indent must be between 0 and 10'); + }); + }); + + describe('writeSync', () => { + it('should write data as pretty-printed JSON array by default', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [ + { id: 1, name: 'John', email: 'john@example.com' }, + { id: 2, name: 'Jane', email: 'jane@example.com' }, + ]; + + // Act + const result = writer.writeSync(data); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content) as TestUser[]; + expect(parsed).toEqual(data); + expect(content).toContain('[\n'); + expect(content).toContain(' {\n'); + }); + + it('should write data as compact JSON when prettyPrint is false', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + jsonConfig: { + prettyPrint: false, + }, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [ + { id: 1, name: 'John', email: 'john@example.com' }, + { id: 2, name: 'Jane', email: 'jane@example.com' }, + ]; + + // Act + writer.writeSync(data); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).not.toContain('\n'); + expect(content).toBe(JSON.stringify(data)); + }); + + it('should write data with custom indentation', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + jsonConfig: { + prettyPrint: true, + indent: 4, + }, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + writer.writeSync(data); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toContain(' {\n'); + expect(content).toContain(' "id": 1'); + }); + + it('should write data with UTF-8 BOM when configured', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + jsonConfig: { + includeUtf8Bom: true, + }, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + writer.writeSync(data); + + // Assert + const buffer = fs.readFileSync(testFile); + expect(buffer[0]).toBe(0xef); + expect(buffer[1]).toBe(0xbb); + expect(buffer[2]).toBe(0xbf); + }); + + it('should overwrite file in write mode on multiple calls', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + const data1: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + const data2: TestUser[] = [{ id: 2, name: 'Jane', email: 'jane@example.com' }]; + + // Act + writer.writeSync(data1); + writer.writeSync(data2); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toEqual(data2); + expect(parsed.length).toBe(1); + }); + + it('should return error for empty data array', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + + // Act + const result = writer.writeSync([]); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Cannot write empty data array'); + } + }); + }); + + describe('write (async)', () => { + it('should write data asynchronously', async () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [ + { id: 1, name: 'John', email: 'john@example.com' }, + { id: 2, name: 'Jane', email: 'jane@example.com' }, + ]; + + // Act + const result = await writer.write(data); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toEqual(data); + }); + + it('should return error for empty data array', async () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + + // Act + const result = await writer.write([]); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Cannot write empty data array'); + } + }); + }); + + describe('appendSync', () => { + it('should append single row to existing file', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data1: TestUser[] = [ + { id: 1, name: 'John', email: 'john@example.com' }, + { id: 2, name: 'Jane', email: 'jane@example.com' }, + ]; + const data2: TestUser = { id: 3, name: 'Bob', email: 'bob@example.com' }; + + // Act + writer.writeSync(data1); + const result = writer.appendSync(data2); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toHaveLength(3); + expect(parsed[2]).toEqual(data2); + }); + + it('should append multiple rows to existing file', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data1: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + const data2: TestUser[] = [ + { id: 2, name: 'Jane', email: 'jane@example.com' }, + { id: 3, name: 'Bob', email: 'bob@example.com' }, + ]; + + // Act + writer.writeSync(data1); + writer.appendSync(data2); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toHaveLength(3); + expect(parsed[1]).toEqual(data2[0]); + expect(parsed[2]).toEqual(data2[1]); + }); + + it('should create new file if it does not exist', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: TestUser = { id: 1, name: 'John', email: 'john@example.com' }; + + // Act + const result = writer.appendSync(data); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(testFile)).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toEqual([data]); + }); + + it('should handle empty array as no-op', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + writer.writeSync(data); + const result = writer.appendSync([]); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toEqual(data); + }); + + it('should load existing data when appending to file created outside writer', () => { + // Arrange + const existingData: TestUser[] = [{ id: 1, name: 'Existing', email: 'existing@example.com' }]; + fs.writeFileSync(testFile, JSON.stringify(existingData), 'utf-8'); + + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const newData: TestUser = { id: 2, name: 'New', email: 'new@example.com' }; + + // Act + writer.appendSync(newData); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual(existingData[0]); + expect(parsed[1]).toEqual(newData); + }); + + it('should handle file with UTF-8 BOM', () => { + // Arrange + const existingData: TestUser[] = [{ id: 1, name: 'Existing', email: 'existing@example.com' }]; + fs.writeFileSync(testFile, '\uFEFF' + JSON.stringify(existingData), 'utf-8'); + + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const newData: TestUser = { id: 2, name: 'New', email: 'new@example.com' }; + + // Act + writer.appendSync(newData); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content.replace(/^\uFEFF/, '')); + expect(parsed).toHaveLength(2); + }); + }); + + describe('append (async)', () => { + it('should append single row to existing file asynchronously', async () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data1: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + const data2: TestUser = { id: 2, name: 'Jane', email: 'jane@example.com' }; + + // Act + await writer.write(data1); + const result = await writer.append(data2); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toHaveLength(2); + expect(parsed[1]).toEqual(data2); + }); + + it('should handle empty array as no-op', async () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + await writer.write(data); + const result = await writer.append([]); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toEqual(data); + }); + }); + + describe('append mode with writeSync', () => { + it('should append to existing file when using writeSync in append mode', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data1: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + const data2: TestUser[] = [{ id: 2, name: 'Jane', email: 'jane@example.com' }]; + + // Act + writer.writeSync(data1); + writer.writeSync(data2); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual(data1[0]); + expect(parsed[1]).toEqual(data2[0]); + }); + }); + + describe('integration with WriterFactory', () => { + it('should work when created via factory', async () => { + // Arrange + const { WriterFactory } = await import('../../../src/writers/WriterFactory'); + const writer = WriterFactory.create({ + type: 'json', + mode: 'write', + file: testFile, + }); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + const result = writer.writeSync(data); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content) as TestUser[]; + expect(parsed).toEqual(data); + }); + }); + + describe('error handling', () => { + it('should handle file write errors', () => { + // Arrange + const mockFileWriter: FileWriter = { + writeSync: vi.fn().mockReturnValue({ success: false, error: new Error('Write failed') }), + write: vi.fn(), + appendSync: vi.fn(), + append: vi.fn(), + existsSync: vi.fn().mockReturnValue(false), + exists: vi.fn(), + }; + + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options, mockFileWriter); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + const result = writer.writeSync(data); + + // Assert + expect(result.success).toBe(false); + }); + + it('should handle invalid JSON in existing file', () => { + // Arrange + fs.writeFileSync(testFile, 'invalid json content', 'utf-8'); + + const options: WriterOptions = { + type: 'json', + mode: 'append', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: TestUser = { id: 1, name: 'John', email: 'john@example.com' }; + + // Act + const result = writer.appendSync(data); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Failed to parse existing JSON file'); + } + }); + }); + + describe('special data types', () => { + it('should handle nested objects', () => { + // Arrange + interface ComplexUser extends Record { + id: number; + profile: { + name: string; + tags: string[]; + }; + } + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: ComplexUser[] = [ + { + id: 1, + profile: { + name: 'John', + tags: ['developer', 'typescript'], + }, + }, + ]; + + // Act + writer.writeSync(data); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed[0]!.profile.tags).toEqual(['developer', 'typescript']); + }); + + it('should handle null values', () => { + // Arrange + interface NullableUser extends Record { + id: number; + name: string | null; + } + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: NullableUser[] = [{ id: 1, name: null }]; + + // Act + writer.writeSync(data); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toContain('"name": null'); + }); + + it('should handle boolean values', () => { + // Arrange + interface BoolUser extends Record { + id: number; + active: boolean; + } + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + const writer = new JsonWriter(options); + const data: BoolUser[] = [ + { id: 1, active: true }, + { id: 2, active: false }, + ]; + + // Act + writer.writeSync(data); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toContain('"active": true'); + expect(content).toContain('"active": false'); + }); + }); +}); From 9363ba495477fd036d697376ff1e37bb947e02d4 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:53:43 -0400 Subject: [PATCH 10/13] update note --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6de85e6..868d57b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ outport/ ## ๐Ÿ“š Documentation - **[CSV Writer Guide](docs/csv-writer.md)** - Examples and usage patterns for the CSV writer +- **[JSON Writer Guide](docs/json-writer.md)** - Examples and usage patterns for the JSON writer ## ๐Ÿงช Testing From a7da8234cceab1fa9cc5834860788166aa282d33 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:55:23 -0400 Subject: [PATCH 11/13] fix test in wrong location --- .../writers/WriterFactory.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) rename src/index.test.ts => __tests__/writers/WriterFactory.test.ts (63%) diff --git a/src/index.test.ts b/__tests__/writers/WriterFactory.test.ts similarity index 63% rename from src/index.test.ts rename to __tests__/writers/WriterFactory.test.ts index 8fdcc80..93e7dad 100644 --- a/src/index.test.ts +++ b/__tests__/writers/WriterFactory.test.ts @@ -1,20 +1,21 @@ import { describe, it, expect } from 'vitest'; -import { WriterFactory, ValidationError } from './index.js'; +import { WriterFactory } from '../../src/writers/WriterFactory'; +import { ValidationError } from '../../src/errors'; describe('WriterFactory', () => { - it('should export WriterFactory', () => { + it('should have create method', () => { expect(WriterFactory).toBeDefined(); expect(typeof WriterFactory.create).toBe('function'); }); - it('should throw ValidationError for unknown writer type', () => { + it('should create CsvWriter successfully', () => { expect(() => { WriterFactory.create({ - type: 'unknown' as never, + type: 'csv', mode: 'write', file: 'test.csv', }); - }).toThrow(ValidationError); + }).not.toThrow(); }); it('should create JsonWriter successfully', () => { @@ -26,4 +27,14 @@ describe('WriterFactory', () => { }); }).not.toThrow(); }); + + it('should throw ValidationError for unknown writer type', () => { + expect(() => { + WriterFactory.create({ + type: 'unknown' as never, + mode: 'write', + file: 'test.csv', + }); + }).toThrow(ValidationError); + }); }); From 096a13010bea45387ae948ca13076232fa4ba820 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 20:56:41 -0400 Subject: [PATCH 12/13] remove unnecessary file --- src/writers/json/index.ts | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 src/writers/json/index.ts diff --git a/src/writers/json/index.ts b/src/writers/json/index.ts deleted file mode 100644 index 7574f47..0000000 --- a/src/writers/json/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { JsonWriter } from './JsonWriter'; -export { JsonFormatter } from './JsonFormatter'; From b14e2b2c334d3b36fd0804763ce0a97e4fd40fb1 Mon Sep 17 00:00:00 2001 From: Scott Lusk Date: Mon, 20 Oct 2025 21:06:26 -0400 Subject: [PATCH 13/13] single property for config, switched to using discriminated union to address --- __tests__/writers/csv/CsvWriter.test.ts | 17 +-- __tests__/writers/json/JsonWriter.test.ts | 13 +-- docs/json-writer.md | 12 +-- docs/type-safety-example.md | 122 ++++++++++++++++++++++ src/types.ts | 59 +++++++---- src/writers/WriterFactory.ts | 8 +- src/writers/csv/CsvWriter.ts | 14 +-- src/writers/json/JsonWriter.ts | 10 +- 8 files changed, 201 insertions(+), 54 deletions(-) create mode 100644 docs/type-safety-example.md diff --git a/__tests__/writers/csv/CsvWriter.test.ts b/__tests__/writers/csv/CsvWriter.test.ts index 4d36b36..3091014 100644 --- a/__tests__/writers/csv/CsvWriter.test.ts +++ b/__tests__/writers/csv/CsvWriter.test.ts @@ -40,7 +40,8 @@ describe('CsvWriter', () => { }; // Act & Assert - expect(() => new CsvWriter(options)).toThrow('Invalid writer type for CsvWriter'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect(() => new CsvWriter(options as any)).toThrow('Invalid writer type for CsvWriter'); }); it('should throw error for empty file path', () => { @@ -73,7 +74,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { delimiter: ',,', }, }; @@ -88,7 +89,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { quote: '""', }, }; @@ -127,7 +128,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { headers: ['ID', 'Name', 'Email'], }, }; @@ -149,7 +150,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { columnMapping: { id: 'User ID', name: 'Full Name', @@ -174,7 +175,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { includeKeys: ['name', 'email'], }, }; @@ -195,7 +196,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { delimiter: '\t', }, }; @@ -216,7 +217,7 @@ describe('CsvWriter', () => { type: 'csv', mode: 'write', file: testFile, - csvConfig: { + config: { includeUtf8Bom: true, }, }; diff --git a/__tests__/writers/json/JsonWriter.test.ts b/__tests__/writers/json/JsonWriter.test.ts index 54a475b..0a5c505 100644 --- a/__tests__/writers/json/JsonWriter.test.ts +++ b/__tests__/writers/json/JsonWriter.test.ts @@ -41,7 +41,8 @@ describe('JsonWriter', () => { }; // Act & Assert - expect(() => new JsonWriter(options)).toThrow('Invalid writer type for JsonWriter'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect(() => new JsonWriter(options as any)).toThrow('Invalid writer type for JsonWriter'); }); it('should throw error for empty file path', () => { @@ -74,7 +75,7 @@ describe('JsonWriter', () => { type: 'json', mode: 'write', file: testFile, - jsonConfig: { + config: { indent: -1, }, }; @@ -89,7 +90,7 @@ describe('JsonWriter', () => { type: 'json', mode: 'write', file: testFile, - jsonConfig: { + config: { indent: 11, }, }; @@ -131,7 +132,7 @@ describe('JsonWriter', () => { type: 'json', mode: 'write', file: testFile, - jsonConfig: { + config: { prettyPrint: false, }, }; @@ -156,7 +157,7 @@ describe('JsonWriter', () => { type: 'json', mode: 'write', file: testFile, - jsonConfig: { + config: { prettyPrint: true, indent: 4, }, @@ -179,7 +180,7 @@ describe('JsonWriter', () => { type: 'json', mode: 'write', file: testFile, - jsonConfig: { + config: { includeUtf8Bom: true, }, }; diff --git a/docs/json-writer.md b/docs/json-writer.md index 795b603..9c25722 100644 --- a/docs/json-writer.md +++ b/docs/json-writer.md @@ -98,7 +98,7 @@ const writer = new JsonWriter({ type: 'json', mode: 'write', file: './output/users.json', - jsonConfig: { + config: { prettyPrint: false, // Single-line, compact JSON }, }); @@ -111,7 +111,7 @@ const writer = new JsonWriter({ type: 'json', mode: 'write', file: './output/users.json', - jsonConfig: { + config: { prettyPrint: true, indent: 4, // Use 4 spaces instead of default 2 }, @@ -125,7 +125,7 @@ const writer = new JsonWriter({ type: 'json', mode: 'write', file: './output/users.json', - jsonConfig: { + config: { includeUtf8Bom: true, // Adds BOM for compatibility with some legacy tools }, }); @@ -164,7 +164,7 @@ const writer = WriterFactory.create({ type: 'json', mode: 'write', file: './output/users.json', - jsonConfig: { + config: { prettyPrint: true, indent: 2, }, @@ -262,7 +262,7 @@ const writer = new JsonWriter({ type: 'json', mode: 'write', file: './config/generated.json', - jsonConfig: { + config: { prettyPrint: true, indent: 2, }, @@ -280,7 +280,7 @@ const writer = new JsonWriter({ type: 'json', mode: 'write', file: './output/processed.json', - jsonConfig: { + config: { prettyPrint: false, // Compact for smaller files }, }); diff --git a/docs/type-safety-example.md b/docs/type-safety-example.md new file mode 100644 index 0000000..6e1133f --- /dev/null +++ b/docs/type-safety-example.md @@ -0,0 +1,122 @@ +# Type Safety with Discriminated Union + +The refactored `WriterOptions` type uses a discriminated union pattern to provide compile-time type safety when configuring writers. + +## Benefits + +### Before: Separate Config Properties + +```typescript +// Old approach - could mix incompatible configs +interface WriterOptions { + type: 'csv' | 'json'; + csvConfig?: CsvConfig; + jsonConfig?: JsonConfig; + // ... +} + +// Nothing prevented this invalid combination: +const options: WriterOptions = { + type: 'csv', + jsonConfig: { prettyPrint: true }, // Wrong config for CSV! + // ... +}; +``` + +### After: Discriminated Union + +```typescript +// New approach - type system enforces correctness +type WriterOptions = + | (WriterOptionsBase & { type: 'csv'; config?: CsvConfig }) + | (WriterOptionsBase & { type: 'json'; config?: JsonConfig }); + +// TypeScript now prevents invalid combinations: +const csvOptions: WriterOptions = { + type: 'csv', + config: { prettyPrint: true }, // โŒ Type error! prettyPrint doesn't exist on CsvConfig + // ... +}; + +const jsonOptions: WriterOptions = { + type: 'json', + config: { delimiter: ',' }, // โŒ Type error! delimiter doesn't exist on JsonConfig + // ... +}; +``` + +## Type Narrowing + +The factory uses TypeScript's type narrowing to ensure type safety: + +```typescript +export function createWriter>( + options: WriterOptions +): CsvWriter | JsonWriter { + switch (options.type) { + case 'csv': + // TypeScript knows options.config is CsvConfig here + return new CsvWriter(options); + case 'json': + // TypeScript knows options.config is JsonConfig here + return new JsonWriter(options); + default: + // Exhaustiveness check + const _exhaustive: never = options; + throw new Error(`Unknown writer type: ${(_exhaustive as WriterOptions).type}`); + } +} +``` + +## IntelliSense Support + +When you set the `type` property, your IDE will automatically suggest only the valid config options: + +```typescript +const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: 'users.csv', + config: { + // โœ… IntelliSense suggests: delimiter, quoteStrings, includeHeaders, etc. + delimiter: '\t', + quoteStrings: 'always', + }, +}; + +const jsonOptions: WriterOptions = { + type: 'json', + mode: 'write', + file: 'users.json', + config: { + // โœ… IntelliSense suggests: prettyPrint, indent + prettyPrint: true, + indent: 2, + }, +}; +``` + +## Scalability + +Adding new writer types is straightforward and maintains type safety: + +```typescript +// Add XML writer +type WriterOptions = + | (WriterOptionsBase & { type: 'csv'; config?: CsvConfig }) + | (WriterOptionsBase & { type: 'json'; config?: JsonConfig }) + | (WriterOptionsBase & { type: 'xml'; config?: XmlConfig }); // New type + +// TypeScript will now require updating the factory's switch statement +// (exhaustiveness check catches missing cases) +``` + +## Summary + +The discriminated union pattern provides: + +- **Compile-time safety**: Invalid config combinations are caught at compile time +- **Better IntelliSense**: IDE autocomplete shows only valid options +- **Exhaustiveness checking**: TypeScript ensures all cases are handled +- **Scalability**: Easy to add new writer types without breaking existing code +- **Maintainability**: Single `config` property is cleaner than multiple optional configs diff --git a/src/types.ts b/src/types.ts index 7cf2e51..1afe366 100644 --- a/src/types.ts +++ b/src/types.ts @@ -241,42 +241,63 @@ export interface JsonConfig { includeUtf8Bom?: boolean; } +/** + * Base writer options shared across all writer types. + */ +interface WriterOptionsBase { + /** Write mode: 'write' to overwrite, 'append' to add to existing file */ + mode: WriterMode; + + /** Destination file path (absolute or relative) */ + file: string; +} + /** * Complete writer options including type-specific configuration. * - * Includes the base writer configuration plus optional format-specific settings. + * Uses a discriminated union pattern to ensure type-safe configuration. + * The config property type is automatically inferred based on the writer type. * * @template T - The type of data objects being written * * @example * ```typescript - * const options: WriterOptions = { + * // CSV writer with type-safe config + * const csvOptions: WriterOptions = { * type: 'csv', * mode: 'write', * file: './output/users.csv', - * csvConfig: { + * config: { * delimiter: '\t', * includeUtf8Bom: true * } * }; + * + * // JSON writer with type-safe config + * const jsonOptions: WriterOptions = { + * type: 'json', + * mode: 'write', + * file: './output/users.json', + * config: { + * prettyPrint: true, + * indent: 2 + * } + * }; * ``` */ -export interface WriterOptions { - /** The type of writer to use (e.g., 'csv', 'json') */ - type: WriterType; - - /** Write mode: 'write' to overwrite, 'append' to add to existing file */ - mode: WriterMode; - - /** Destination file path (absolute or relative) */ - file: string; - - /** CSV-specific configuration options */ - csvConfig?: CsvConfig; - - /** JSON-specific configuration options */ - jsonConfig?: JsonConfig; -} +export type WriterOptions = + | (WriterOptionsBase & { + /** The type of writer to use */ + type: 'csv'; + /** CSV-specific configuration options */ + config?: CsvConfig; + }) + | (WriterOptionsBase & { + /** The type of writer to use */ + type: 'json'; + /** JSON-specific configuration options */ + config?: JsonConfig; + }); /** * Alias for WriterOptions - used by factory pattern. diff --git a/src/writers/WriterFactory.ts b/src/writers/WriterFactory.ts index a5923d7..0bb6f5e 100644 --- a/src/writers/WriterFactory.ts +++ b/src/writers/WriterFactory.ts @@ -15,7 +15,7 @@ import { ValidationError } from '../errors'; * type: 'csv', * mode: 'write', * file: './output.csv', - * csvConfig: { delimiter: '\t' } + * config: { delimiter: '\t' } * }); * ``` */ @@ -50,8 +50,10 @@ export class WriterFactory { return new JsonWriter(config, fileWriter); default: { // Exhaustive check - this should never be reached - const _exhaustive: never = config.type; - throw new ValidationError(`Unknown writer type: ${String(_exhaustive)}`); + const _exhaustive: never = config; + throw new ValidationError( + `Unknown writer type: ${String((_exhaustive as WriterConfig).type)}` + ); } } } diff --git a/src/writers/csv/CsvWriter.ts b/src/writers/csv/CsvWriter.ts index fde1812..176e8c8 100644 --- a/src/writers/csv/CsvWriter.ts +++ b/src/writers/csv/CsvWriter.ts @@ -70,21 +70,21 @@ export class CsvWriter> implements OutportWrit * ``` */ constructor( - private readonly options: WriterOptions, + private readonly options: WriterOptions & { type: 'csv' }, fileWriter: FileWriter = new NodeFileWriter() ) { this.validate(options); this.fileWriter = fileWriter; // Initialize formatter with config - const delimiter = options.csvConfig?.delimiter ?? ','; - const quote = options.csvConfig?.quote ?? '"'; + const delimiter = options.config?.delimiter ?? ','; + const quote = options.config?.quote ?? '"'; this.formatter = new CsvFormatter(delimiter, quote); // Initialize header manager - this.headerManager = new CsvHeaderManager(options.csvConfig); + this.headerManager = new CsvHeaderManager(options.config); - this.includeUtf8Bom = options.csvConfig?.includeUtf8Bom ?? false; + this.includeUtf8Bom = options.config?.includeUtf8Bom ?? false; } /** @@ -103,12 +103,12 @@ export class CsvWriter> implements OutportWrit throw new ValidationError('File extension must be .csv for CsvWriter'); } - const delimiter = options.csvConfig?.delimiter ?? ','; + const delimiter = options.config?.delimiter ?? ','; if (delimiter.length !== 1) { throw new ValidationError('Delimiter must be a single character'); } - const quote = options.csvConfig?.quote ?? '"'; + const quote = options.config?.quote ?? '"'; if (quote.length !== 1) { throw new ValidationError('Quote character must be a single character'); } diff --git a/src/writers/json/JsonWriter.ts b/src/writers/json/JsonWriter.ts index 157fb12..2bab5b0 100644 --- a/src/writers/json/JsonWriter.ts +++ b/src/writers/json/JsonWriter.ts @@ -72,18 +72,18 @@ export class JsonWriter> implements OutportWri * ``` */ constructor( - private readonly options: WriterOptions, + private readonly options: WriterOptions & { type: 'json' }, fileWriter: FileWriter = new NodeFileWriter() ) { this.validate(options); this.fileWriter = fileWriter; // Initialize formatter with config - const prettyPrint = options.jsonConfig?.prettyPrint ?? true; - const indent = options.jsonConfig?.indent ?? 2; + const prettyPrint = options.config?.prettyPrint ?? true; + const indent = options.config?.indent ?? 2; this.formatter = new JsonFormatter(prettyPrint, indent); - this.includeUtf8Bom = options.jsonConfig?.includeUtf8Bom ?? false; + this.includeUtf8Bom = options.config?.includeUtf8Bom ?? false; } /** @@ -102,7 +102,7 @@ export class JsonWriter> implements OutportWri throw new ValidationError('File extension must be .json for JsonWriter'); } - const indent = options.jsonConfig?.indent ?? 2; + const indent = options.config?.indent ?? 2; if (indent < 0 || indent > 10) { throw new ValidationError('Indent must be between 0 and 10'); }