diff --git a/README.md b/README.md index 8b4f910..6de85e6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ pnpm install ``` outport/ ├── .github/ # GitHub Actions workflows and configs +├── docs/ # Documentation +│ └── csv-writer.md # CSV Writer usage guide ├── src/ # Source TypeScript files │ ├── index.ts # Main entry point │ └── index.test.ts # Test files @@ -51,6 +53,10 @@ outport/ └── .prettierrc # Prettier configuration ``` +## 📚 Documentation + +- **[CSV Writer Guide](docs/csv-writer.md)** - Examples and usage patterns for the CSV writer + ## 🧪 Testing This project uses [Vitest](https://vitest.dev/) for testing with the following features: diff --git a/__tests__/io/FileWriter.test.ts b/__tests__/io/FileWriter.test.ts new file mode 100644 index 0000000..052e807 --- /dev/null +++ b/__tests__/io/FileWriter.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { NodeFileWriter } from '../../src/io/FileWriter'; + +describe('NodeFileWriter', () => { + const testDir = path.join(process.cwd(), '__tests__', 'temp', 'file-writer'); + let testFile: string; + let fileWriter: NodeFileWriter; + + 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()}.txt`); + fileWriter = new NodeFileWriter(); + }); + + afterEach(() => { + // Clean up test files + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + }); + + describe('writeSync', () => { + it('should write content to a new file successfully', () => { + // Arrange + const content = 'Hello, World!'; + + // Act + const result = fileWriter.writeSync(testFile, content); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(testFile)).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(content); + }); + + it('should overwrite existing file content', () => { + // Arrange + fs.writeFileSync(testFile, 'Old content'); + const newContent = 'New content'; + + // Act + const result = fileWriter.writeSync(testFile, newContent); + + // Assert + expect(result.success).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(newContent); + }); + + it('should return error result for invalid path', () => { + // Arrange + const invalidPath = '/invalid/path/that/does/not/exist/file.txt'; + + // Act + const result = fileWriter.writeSync(invalidPath, 'content'); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Failed to write file'); + } + }); + }); + + describe('write (async)', () => { + it('should write content to a new file successfully', async () => { + // Arrange + const content = 'Hello, Async World!'; + + // Act + const result = await fileWriter.write(testFile, content); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(testFile)).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(content); + }); + + it('should overwrite existing file content', async () => { + // Arrange + fs.writeFileSync(testFile, 'Old content'); + const newContent = 'New async content'; + + // Act + const result = await fileWriter.write(testFile, newContent); + + // Assert + expect(result.success).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(newContent); + }); + + it('should return error result for invalid path', async () => { + // Arrange + const invalidPath = '/invalid/path/that/does/not/exist/file.txt'; + + // Act + const result = await fileWriter.write(invalidPath, 'content'); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Failed to write file'); + } + }); + }); + + describe('appendSync', () => { + it('should append content to existing file', () => { + // Arrange + const initialContent = 'Line 1\n'; + const appendContent = 'Line 2\n'; + fs.writeFileSync(testFile, initialContent); + + // Act + const result = fileWriter.appendSync(testFile, appendContent); + + // Assert + expect(result.success).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(initialContent + appendContent); + }); + + it('should create file if it does not exist', () => { + // Arrange + const content = 'New file content\n'; + + // Act + const result = fileWriter.appendSync(testFile, content); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(testFile)).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(content); + }); + + it('should return error result for invalid path', () => { + // Arrange + const invalidPath = '/invalid/path/that/does/not/exist/file.txt'; + + // Act + const result = fileWriter.appendSync(invalidPath, 'content'); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Failed to append to file'); + } + }); + }); + + describe('append (async)', () => { + it('should append content to existing file', async () => { + // Arrange + const initialContent = 'Line 1\n'; + const appendContent = 'Line 2\n'; + fs.writeFileSync(testFile, initialContent); + + // Act + const result = await fileWriter.append(testFile, appendContent); + + // Assert + expect(result.success).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(initialContent + appendContent); + }); + + it('should create file if it does not exist', async () => { + // Arrange + const content = 'New async file content\n'; + + // Act + const result = await fileWriter.append(testFile, content); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(testFile)).toBe(true); + expect(fs.readFileSync(testFile, 'utf-8')).toBe(content); + }); + + it('should return error result for invalid path', async () => { + // Arrange + const invalidPath = '/invalid/path/that/does/not/exist/file.txt'; + + // Act + const result = await fileWriter.append(invalidPath, 'content'); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Failed to append to file'); + } + }); + }); + + describe('existsSync', () => { + it('should return true for existing file', () => { + // Arrange + fs.writeFileSync(testFile, 'content'); + + // Act + const result = fileWriter.existsSync(testFile); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for non-existing file', () => { + // Act + const result = fileWriter.existsSync(testFile); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('exists (async)', () => { + it('should return true for existing file', async () => { + // Arrange + fs.writeFileSync(testFile, 'content'); + + // Act + const result = await fileWriter.exists(testFile); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for non-existing file', async () => { + // Act + const result = await fileWriter.exists(testFile); + + // Assert + expect(result).toBe(false); + }); + }); + + // Cleanup temp directory after all tests + afterAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/writers/csv/CsvFormatter.test.ts b/__tests__/writers/csv/CsvFormatter.test.ts new file mode 100644 index 0000000..9665d4e --- /dev/null +++ b/__tests__/writers/csv/CsvFormatter.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import { CsvFormatter } from '../../../src/writers/csv/CsvFormatter'; + +describe('CsvFormatter', () => { + describe('with default delimiter and quote', () => { + it('should format simple values', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', 'Doe', 30, true]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,Doe,30,true'); + }); + + it('should handle null and undefined values', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', null, undefined, 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,,,30'); + }); + + it('should quote values containing commas', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John Doe', 'New York, NY', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John Doe,"New York, NY",30'); + }); + + it('should quote values containing newlines', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', 'Line1\nLine2', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,"Line1\nLine2",30'); + }); + + it('should quote values containing quotes and escape them', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', 'He said "Hello"', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,"He said ""Hello""",30'); + }); + + it('should handle multiple quotes in a value', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['Test', '"Quote" and "Another"', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('Test,"""Quote"" and ""Another""",30'); + }); + + it('should format empty array', () => { + // Arrange + const formatter = new CsvFormatter(); + + // Act + const result = formatter.formatRow([]); + + // Assert + expect(result).toBe(''); + }); + + it('should handle objects by JSON stringifying them', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', { age: 30, city: 'NYC' }, 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,"{""age"":30,""city"":""NYC""}",30'); + }); + + it('should handle arrays by JSON stringifying them', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', [1, 2, 3], 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,"[1,2,3]",30'); + }); + }); + + describe('with custom delimiter', () => { + it('should use custom delimiter', () => { + // Arrange + const formatter = new CsvFormatter('\t'); + const values = ['John', 'Doe', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John\tDoe\t30'); + }); + + it('should quote values containing the custom delimiter', () => { + // Arrange + const formatter = new CsvFormatter('\t'); + const values = ['John\tDoe', 'NYC', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('"John\tDoe"\tNYC\t30'); + }); + + it('should use semicolon delimiter', () => { + // Arrange + const formatter = new CsvFormatter(';'); + const values = ['John', 'Doe', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John;Doe;30'); + }); + + it('should quote values containing semicolon when using semicolon delimiter', () => { + // Arrange + const formatter = new CsvFormatter(';'); + const values = ['John;Doe', 'NYC', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('"John;Doe";NYC;30'); + }); + }); + + describe('with custom quote character', () => { + it('should use custom quote character', () => { + // Arrange + const formatter = new CsvFormatter(',', "'"); + const values = ['John', 'New York, NY', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe("John,'New York, NY',30"); + }); + + it('should escape custom quote character', () => { + // Arrange + const formatter = new CsvFormatter(',', "'"); + const values = ['John', "It's great", 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe("John,'It''s great',30"); + }); + }); + + describe('edge cases', () => { + it('should handle boolean values', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = [true, false, true]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('true,false,true'); + }); + + it('should handle numeric values including zero', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = [0, -1, 3.14, 1000000]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('0,-1,3.14,1000000'); + }); + + it('should handle empty strings', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['', 'John', '']; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe(',John,'); + }); + + it('should handle carriage returns', () => { + // Arrange + const formatter = new CsvFormatter(); + const values = ['John', 'Line1\r\nLine2', 30]; + + // Act + const result = formatter.formatRow(values); + + // Assert + expect(result).toBe('John,"Line1\r\nLine2",30'); + }); + }); +}); diff --git a/__tests__/writers/csv/CsvHeaderManager.test.ts b/__tests__/writers/csv/CsvHeaderManager.test.ts new file mode 100644 index 0000000..53e1123 --- /dev/null +++ b/__tests__/writers/csv/CsvHeaderManager.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest'; +import { CsvHeaderManager } from '../../../src/writers/csv/CsvHeaderManager'; +import type { CsvConfig } from '../../../src/types'; + +interface TestUser extends Record { + id: number; + firstName: string; + lastName: string; + email: string; + age: number; +} + +describe('CsvHeaderManager', () => { + const sampleUser: TestUser = { + id: 1, + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + age: 30, + }; + + describe('initialization without config', () => { + it('should initialize headers from object keys', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Act + const result = manager.initialize(sampleUser); + + // Assert + expect(result.success).toBe(true); + expect(manager.getHeaders()).toEqual(['id', 'firstName', 'lastName', 'email', 'age']); + }); + + it('should initialize keys from object keys', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Act + manager.initialize(sampleUser); + + // Assert + expect(manager.getKeys()).toEqual(['id', 'firstName', 'lastName', 'email', 'age']); + }); + + it('should return isInitialized as true after initialization', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Act + manager.initialize(sampleUser); + + // Assert + expect(manager.isInitialized()).toBe(true); + }); + + it('should return isInitialized as false before initialization', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Assert + expect(manager.isInitialized()).toBe(false); + }); + }); + + describe('initialization with explicit headers', () => { + it('should use provided headers', () => { + // Arrange + const config: CsvConfig = { + headers: ['ID', 'First Name', 'Last Name', 'Email', 'Age'], + }; + const manager = new CsvHeaderManager(config); + + // Act + const result = manager.initialize(sampleUser); + + // Assert + expect(result.success).toBe(true); + expect(manager.getHeaders()).toEqual(['ID', 'First Name', 'Last Name', 'Email', 'Age']); + }); + + it('should still infer keys from data object', () => { + // Arrange + const config: CsvConfig = { + headers: ['ID', 'First Name', 'Last Name', 'Email', 'Age'], + }; + const manager = new CsvHeaderManager(config); + + // Act + manager.initialize(sampleUser); + + // Assert + expect(manager.getKeys()).toEqual(['id', 'firstName', 'lastName', 'email', 'age']); + }); + }); + + describe('initialization with column mapping', () => { + it('should map column names', () => { + // Arrange + const config: CsvConfig = { + columnMapping: { + id: 'User ID', + firstName: 'First Name', + lastName: 'Last Name', + email: 'Email Address', + age: 'Age', + }, + }; + const manager = new CsvHeaderManager(config); + + // Act + const result = manager.initialize(sampleUser); + + // Assert + expect(result.success).toBe(true); + expect(manager.getHeaders()).toEqual([ + 'User ID', + 'First Name', + 'Last Name', + 'Email Address', + 'Age', + ]); + }); + + it('should use key name for unmapped columns', () => { + // Arrange + const config: CsvConfig = { + columnMapping: { + firstName: 'First Name', + lastName: 'Last Name', + }, + }; + const manager = new CsvHeaderManager(config); + + // Act + manager.initialize(sampleUser); + + // Assert + expect(manager.getHeaders()).toEqual(['id', 'First Name', 'Last Name', 'email', 'age']); + }); + }); + + describe('initialization with includeKeys', () => { + it('should only include specified keys', () => { + // Arrange + const config: CsvConfig = { + includeKeys: ['firstName', 'lastName', 'email'], + }; + const manager = new CsvHeaderManager(config); + + // Act + const result = manager.initialize(sampleUser); + + // Assert + expect(result.success).toBe(true); + expect(manager.getHeaders()).toEqual(['firstName', 'lastName', 'email']); + expect(manager.getKeys()).toEqual(['firstName', 'lastName', 'email']); + }); + + it('should work with column mapping and includeKeys together', () => { + // Arrange + const config: CsvConfig = { + includeKeys: ['firstName', 'lastName', 'email'], + columnMapping: { + firstName: 'First Name', + lastName: 'Last Name', + email: 'Email Address', + }, + }; + const manager = new CsvHeaderManager(config); + + // Act + manager.initialize(sampleUser); + + // Assert + expect(manager.getHeaders()).toEqual(['First Name', 'Last Name', 'Email Address']); + expect(manager.getKeys()).toEqual(['firstName', 'lastName', 'email']); + }); + }); + + describe('objectToValues', () => { + it('should convert object to values in correct order', () => { + // Arrange + const manager = new CsvHeaderManager(); + manager.initialize(sampleUser); + + // Act + const values = manager.objectToValues(sampleUser); + + // Assert + expect(values).toEqual([1, 'John', 'Doe', 'john@example.com', 30]); + }); + + it('should convert object with includeKeys to filtered values', () => { + // Arrange + const config: CsvConfig = { + includeKeys: ['firstName', 'email'], + }; + const manager = new CsvHeaderManager(config); + manager.initialize(sampleUser); + + // Act + const values = manager.objectToValues(sampleUser); + + // Assert + expect(values).toEqual(['John', 'john@example.com']); + }); + + it('should throw error if not initialized', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Act & Assert + expect(() => manager.objectToValues(sampleUser)).toThrow('Keys not initialized'); + }); + }); + + describe('getHeaders', () => { + it('should throw error if not initialized', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Act & Assert + expect(() => manager.getHeaders()).toThrow('Headers not initialized'); + }); + }); + + describe('getKeys', () => { + it('should throw error if not initialized', () => { + // Arrange + const manager = new CsvHeaderManager(); + + // Act & Assert + expect(() => manager.getKeys()).toThrow('Keys not initialized'); + }); + }); + + describe('multiple initialization attempts', () => { + it('should not reinitialize headers', () => { + // Arrange + const manager = new CsvHeaderManager(); + manager.initialize(sampleUser); + const firstHeaders = manager.getHeaders(); + + const differentUser: TestUser = { + id: 2, + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + age: 25, + }; + + // Act + const result = manager.initialize(differentUser); + + // Assert + expect(result.success).toBe(true); + expect(manager.getHeaders()).toEqual(firstHeaders); + }); + }); + + describe('error cases', () => { + it('should return error for empty object', () => { + // Arrange + const manager = new CsvHeaderManager>(); + const emptyObject = {}; + + // Act + const result = manager.initialize(emptyObject); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Cannot determine headers from empty object'); + } + }); + }); +}); diff --git a/__tests__/writers/csv/CsvWriter.test.ts b/__tests__/writers/csv/CsvWriter.test.ts new file mode 100644 index 0000000..4d36b36 --- /dev/null +++ b/__tests__/writers/csv/CsvWriter.test.ts @@ -0,0 +1,535 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CsvWriter } from '../../../src/writers/csv/CsvWriter'; +import type { WriterOptions, FileWriter } from '../../../src/types'; + +interface TestUser extends Record { + id: number; + name: string; + email: string; +} + +describe('CsvWriter', () => { + const testDir = path.join(process.cwd(), '__tests__', 'temp', 'csv-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()}.csv`); + }); + + afterEach(() => { + // Clean up test files + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + }); + + describe('constructor validation', () => { + it('should throw error for non-csv type', () => { + // Arrange + const options: WriterOptions = { + type: 'json', + mode: 'write', + file: testFile, + }; + + // Act & Assert + expect(() => new CsvWriter(options)).toThrow('Invalid writer type for CsvWriter'); + }); + + it('should throw error for empty file path', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: '', + }; + + // Act & Assert + expect(() => new CsvWriter(options)).toThrow('File path must be provided for CsvWriter'); + }); + + it('should throw error for non-csv file extension', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: 'test.txt', + }; + + // Act & Assert + expect(() => new CsvWriter(options)).toThrow('File extension must be .csv for CsvWriter'); + }); + + it('should throw error for multi-character delimiter', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + delimiter: ',,', + }, + }; + + // Act & Assert + expect(() => new CsvWriter(options)).toThrow('Delimiter must be a single character'); + }); + + it('should throw error for multi-character quote', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + quote: '""', + }, + }; + + // Act & Assert + expect(() => new CsvWriter(options)).toThrow('Quote character must be a single character'); + }); + }); + + describe('writeSync', () => { + it('should write data with inferred headers', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + }; + const writer = new CsvWriter(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'); + expect(content).toBe('id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com\n'); + }); + + it('should write data with custom headers', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + headers: ['ID', 'Name', 'Email'], + }, + }; + const writer = new CsvWriter(options); + 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'); + expect(content).toBe('ID,Name,Email\n1,John,john@example.com\n'); + }); + + it('should write data with column mapping', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + columnMapping: { + id: 'User ID', + name: 'Full Name', + email: 'Email Address', + }, + }, + }; + const writer = new CsvWriter(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).toBe('User ID,Full Name,Email Address\n1,John,john@example.com\n'); + }); + + it('should write data with includeKeys', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + includeKeys: ['name', 'email'], + }, + }; + const writer = new CsvWriter(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).toBe('name,email\nJohn,john@example.com\n'); + }); + + it('should use custom delimiter', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + delimiter: '\t', + }, + }; + const writer = new CsvWriter(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).toBe('id\tname\temail\n1\tJohn\tjohn@example.com\n'); + }); + + it('should add UTF-8 BOM when configured', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + csvConfig: { + includeUtf8Bom: true, + }, + }; + const writer = new CsvWriter(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).toBe('\uFEFFid,name,email\n1,John,john@example.com\n'); + }); + + it('should return error for empty data array', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + }; + const writer = new CsvWriter(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: 'csv', + mode: 'write', + file: testFile, + }; + const writer = new CsvWriter(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'); + expect(content).toBe('id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com\n'); + }); + + it('should return error for empty data array', async () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + }; + const writer = new CsvWriter(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: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Write initial data + writer.writeSync([{ id: 1, name: 'John', email: 'john@example.com' }]); + + // Act + const result = writer.appendSync({ + id: 2, + name: 'Jane', + email: 'jane@example.com', + }); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toBe('id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com\n'); + }); + + it('should append multiple rows to existing file', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Write initial data + writer.writeSync([{ id: 1, name: 'John', email: 'john@example.com' }]); + + // Act + const result = writer.appendSync([ + { id: 2, name: 'Jane', email: 'jane@example.com' }, + { id: 3, name: 'Bob', email: 'bob@example.com' }, + ]); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toBe( + 'id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com\n3,Bob,bob@example.com\n' + ); + }); + + it('should create file with headers if it does not exist in append mode', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Act + const result = writer.appendSync({ + id: 1, + name: 'John', + email: 'john@example.com', + }); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toBe('id,name,email\n1,John,john@example.com\n'); + }); + + it('should handle empty array gracefully', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Act + const result = writer.appendSync([]); + + // Assert + expect(result.success).toBe(true); + }); + }); + + describe('append (async)', () => { + it('should append single row to existing file asynchronously', async () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Write initial data + await writer.write([{ id: 1, name: 'John', email: 'john@example.com' }]); + + // Act + const result = await writer.append({ + id: 2, + name: 'Jane', + email: 'jane@example.com', + }); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toBe('id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com\n'); + }); + + it('should append multiple rows asynchronously', async () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Write initial data + await writer.write([{ id: 1, name: 'John', email: 'john@example.com' }]); + + // Act + const result = await writer.append([ + { id: 2, name: 'Jane', email: 'jane@example.com' }, + { id: 3, name: 'Bob', email: 'bob@example.com' }, + ]); + + // Assert + expect(result.success).toBe(true); + const content = fs.readFileSync(testFile, 'utf-8'); + expect(content).toBe( + 'id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com\n3,Bob,bob@example.com\n' + ); + }); + }); + + describe('dependency injection', () => { + it('should accept custom FileWriter implementation', () => { + // Arrange + const writeSyncSpy = vi.fn(() => ({ success: true as const, value: undefined as void })); + const writeSpy = vi.fn(() => + Promise.resolve({ success: true as const, value: undefined as void }) + ); + const appendSyncSpy = vi.fn(() => ({ success: true as const, value: undefined as void })); + const appendSpy = vi.fn(() => + Promise.resolve({ success: true as const, value: undefined as void }) + ); + const existsSyncSpy = vi.fn(() => false); + const existsSpy = vi.fn(() => Promise.resolve(false)); + + const mockFileWriter: FileWriter = { + writeSync: writeSyncSpy, + write: writeSpy, + appendSync: appendSyncSpy, + append: appendSpy, + existsSync: existsSyncSpy, + exists: existsSpy, + }; + + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + }; + const writer = new CsvWriter(options, mockFileWriter); + const data: TestUser[] = [{ id: 1, name: 'John', email: 'john@example.com' }]; + + // Act + writer.writeSync(data); + + // Assert + expect(writeSyncSpy).toHaveBeenCalledWith(testFile, 'id,name,email\n'); + expect(appendSyncSpy).toHaveBeenCalledWith(testFile, '1,John,john@example.com\n'); + }); + }); + + describe('mode behavior', () => { + it('should overwrite file in write mode', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'write', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Write initial data + writer.writeSync([{ id: 1, name: 'John', email: 'john@example.com' }]); + + // Act - write again + writer.writeSync([{ id: 2, name: 'Jane', email: 'jane@example.com' }]); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + // File should only contain the second write (not appended) + expect(content).toBe('id,name,email\n2,Jane,jane@example.com\n'); + }); + + it('should not write headers again in append mode if file exists', () => { + // Arrange + const options: WriterOptions = { + type: 'csv', + mode: 'append', + file: testFile, + }; + const writer = new CsvWriter(options); + + // Write initial data + writer.writeSync([{ id: 1, name: 'John', email: 'john@example.com' }]); + + // Create new writer instance with same file + const writer2 = new CsvWriter(options); + + // Act - append more data + writer2.appendSync({ id: 2, name: 'Jane', email: 'jane@example.com' }); + + // Assert + const content = fs.readFileSync(testFile, 'utf-8'); + const lines = content.split('\n').filter((line) => line.length > 0); + const headerCount = lines.filter((line) => line === 'id,name,email').length; + expect(headerCount).toBe(1); // Should only have one header row + }); + }); + + // Cleanup temp directory after all tests + afterAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/docs/csv-writer.md b/docs/csv-writer.md new file mode 100644 index 0000000..b762771 --- /dev/null +++ b/docs/csv-writer.md @@ -0,0 +1,195 @@ +# CSV Writer Guide + +Quick reference for using the `CsvWriter` class to export data to CSV files. + +## Basic Usage + +### Simple Write + +```typescript +import { CsvWriter } from 'outport'; + +interface User { + id: number; + name: string; + email: string; +} + +const writer = new CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/users.csv', +}); + +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 CsvWriter({ + type: 'csv', + mode: 'append', + file: './output/users.csv', +}); + +// Append single row +writer.appendSync({ id: 3, name: 'Charlie', email: 'charlie@example.com' }); + +// Append multiple rows +await writer.append([ + { id: 4, name: 'Diana', email: 'diana@example.com' }, + { id: 5, name: 'Eve', email: 'eve@example.com' }, +]); +``` + +### 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 CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/large-dataset.csv', +}); + +// Process first batch to initialize headers +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 rows +for await (const user of generator) { + await writer.append(user); +} +``` + +## Custom Configuration + +### Custom Delimiter + +```typescript +const writer = new CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/users.tsv', + csv: { + delimiter: '\t', // Tab-separated values + }, +}); +``` + +### Custom Headers + +```typescript +const writer = new CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/users.csv', + csv: { + headers: ['ID', 'Full Name', 'Email Address'], + }, +}); +``` + +### Column Mapping + +```typescript +const writer = new CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/users.csv', + csv: { + columnMapping: { + id: 'ID', + name: 'Full Name', + email: 'Email Address', + }, + }, +}); +``` + +### Include Keys as First Row + +```typescript +const writer = new CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/users.csv', + csv: { + includeKeys: true, // Uses object keys as headers + }, +}); +``` + +### UTF-8 BOM + +```typescript +const writer = new CsvWriter({ + type: 'csv', + mode: 'write', + file: './output/users.csv', + csv: { + includeUtf8Bom: true, // Adds BOM for Excel 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: 'csv', + mode: 'write', + file: './output/users.csv', +}); +``` + +## Tips + +- Use `mode: 'write'` to overwrite files each time +- Use `mode: 'append'` to add rows to existing files +- Headers are automatically inferred from the first data object +- Custom delimiters must be single characters +- File paths should end with `.csv` extension diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..935d051 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,95 @@ +/** + * Base error class for all Outport errors. + * + * All custom errors in the Outport library extend from this class, + * making it easy to catch all Outport-specific errors. + * + * @example + * ```typescript + * try { + * writer.writeSync(data); + * } catch (error) { + * if (error instanceof OutportError) { + * console.error('Outport error:', error.message); + * } + * } + * ``` + */ +export class OutportError extends Error { + constructor(message: string) { + super(message); + this.name = 'OutportError'; + } +} + +/** + * Error thrown when input validation fails. + * + * This error occurs when invalid configuration is provided, + * such as empty file paths, invalid delimiters, or empty data arrays. + * + * @example + * ```typescript + * // Throws ValidationError: Cannot write empty data array + * writer.writeSync([]); + * ``` + */ +export class ValidationError extends OutportError { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +/** + * Error thrown when CSV formatting fails. + * + * This error occurs when data cannot be properly formatted as CSV, + * typically due to unexpected data types or formatting issues. + */ +export class CsvFormattingError extends OutportError { + constructor(message: string) { + super(message); + this.name = 'CsvFormattingError'; + } +} + +/** + * Error thrown when file write operation fails. + * + * This error wraps underlying file system errors, providing context + * about the failed operation while preserving the original error. + * + * @property originalError - The underlying error that caused the failure + * + * @example + * ```typescript + * const result = writer.writeSync(data); + * if (!result.success && result.error instanceof FileWriteError) { + * console.error('File operation failed:', result.error.message); + * console.error('Original error:', result.error.originalError); + * } + * ``` + */ +export class FileWriteError extends OutportError { + public readonly originalError?: Error; + + constructor(message: string, originalError?: Error) { + super(message); + this.name = 'FileWriteError'; + this.originalError = originalError; + } +} + +/** + * Error thrown when header initialization fails. + * + * This error occurs when headers cannot be properly initialized from + * the provided data or configuration. + */ +export class HeaderInitializationError extends OutportError { + constructor(message: string) { + super(message); + this.name = 'HeaderInitializationError'; + } +} diff --git a/src/index.test.ts b/src/index.test.ts index b7c70d5..02baaa2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,64 +1,29 @@ import { describe, it, expect } from 'vitest'; -import { greet, add } from './index.js'; +import { WriterFactory, ValidationError } from './index.js'; -describe('greet', () => { - it('should greet a person with their name', () => { - // Arrange - const name = 'Alice'; - - // Act - const result = greet(name); - - // Assert - expect(result).toBe('Hello, Alice!'); +describe('WriterFactory', () => { + it('should export WriterFactory', () => { + expect(WriterFactory).toBeDefined(); + expect(typeof WriterFactory.create).toBe('function'); }); - it('should handle empty string', () => { - // Arrange - const name = ''; - - // Act - const result = greet(name); - - // Assert - expect(result).toBe('Hello, !'); + it('should throw ValidationError for unknown writer type', () => { + expect(() => { + WriterFactory.create({ + type: 'unknown' as never, + mode: 'write', + file: 'test.csv', + }); + }).toThrow(ValidationError); }); -}); - -describe('add', () => { - it('should add two positive numbers', () => { - // Arrange - const a = 2; - const b = 3; - - // Act - const result = add(a, b); - - // Assert - expect(result).toBe(5); - }); - - it('should add negative numbers', () => { - // Arrange - const a = -5; - const b = 3; - - // Act - const result = add(a, b); - - // Assert - expect(result).toBe(-2); - }); - - it('should add zero', () => { - // Arrange - const a = 10; - const b = 0; - - // Act - const result = add(a, b); - // Assert - expect(result).toBe(10); + it('should throw ValidationError for JSON writer (not yet implemented)', () => { + expect(() => { + WriterFactory.create({ + type: 'json', + mode: 'write', + file: 'test.json', + }); + }).toThrow('JSON writer not yet implemented'); }); }); diff --git a/src/index.ts b/src/index.ts index fb2dfbd..d57499f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,27 @@ -/** - * Greets a person with a custom message. - * - * @param name - The name of the person to greet - * @returns A greeting message - * - * @example - * ```ts - * const message = greet('Alice'); - * console.log(message); // "Hello, Alice!" - * ``` - */ -export function greet(name: string): string { - return `Hello, ${name}!`; -} +// Export types +export type { + OutportWriter, + WriterOptions, + WriterConfig, + WriterType, + WriterMode, + CsvConfig, + Result, + FileWriter, +} from './types'; -/** - * Adds two numbers together. - * - * @param a - The first number - * @param b - The second number - * @returns The sum of a and b - * - * @example - * ```ts - * const result = add(2, 3); - * console.log(result); // 5 - * ``` - */ -export function add(a: number, b: number): number { - return a + b; -} +// Export errors +export { + OutportError, + ValidationError, + CsvFormattingError, + FileWriteError, + HeaderInitializationError, +} from './errors'; + +// Export writers +export { CsvWriter } from './writers/csv/CsvWriter'; +export { WriterFactory } from './writers/WriterFactory'; + +// Export file writer implementation +export { NodeFileWriter } from './io/FileWriter'; diff --git a/src/io/FileWriter.ts b/src/io/FileWriter.ts new file mode 100644 index 0000000..e0aa727 --- /dev/null +++ b/src/io/FileWriter.ts @@ -0,0 +1,96 @@ +import * as fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; +import type { FileWriter as IFileWriter, Result } from '../types'; +import { FileWriteError } from '../errors'; + +/** + * Default file writer implementation using Node.js fs module. + * + * Provides both synchronous and asynchronous file operations with + * proper error handling using the Result type pattern. + * + * @example + * ```typescript + * const writer = new NodeFileWriter(); + * + * // Synchronous write + * const result = writer.writeSync('./output.txt', 'Hello, World!'); + * + * // Asynchronous write + * const result = await writer.write('./output.txt', 'Hello, World!'); + * ``` + */ +export class NodeFileWriter implements IFileWriter { + writeSync(path: string, content: string): Result { + try { + fs.writeFileSync(path, content, 'utf-8'); + return { success: true, value: undefined }; + } catch (error) { + return { + success: false, + error: new FileWriteError( + `Failed to write file: ${path}`, + error instanceof Error ? error : undefined + ), + }; + } + } + + async write(path: string, content: string): Promise> { + try { + await fsPromises.writeFile(path, content, 'utf-8'); + return { success: true, value: undefined }; + } catch (error) { + return { + success: false, + error: new FileWriteError( + `Failed to write file: ${path}`, + error instanceof Error ? error : undefined + ), + }; + } + } + + appendSync(path: string, content: string): Result { + try { + fs.appendFileSync(path, content, 'utf-8'); + return { success: true, value: undefined }; + } catch (error) { + return { + success: false, + error: new FileWriteError( + `Failed to append to file: ${path}`, + error instanceof Error ? error : undefined + ), + }; + } + } + + async append(path: string, content: string): Promise> { + try { + await fsPromises.appendFile(path, content, 'utf-8'); + return { success: true, value: undefined }; + } catch (error) { + return { + success: false, + error: new FileWriteError( + `Failed to append to file: ${path}`, + error instanceof Error ? error : undefined + ), + }; + } + } + + existsSync(path: string): boolean { + return fs.existsSync(path); + } + + async exists(path: string): Promise { + try { + await fsPromises.access(path); + return true; + } catch { + return false; + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5b51be0 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,313 @@ +/** + * Result type for operations that can fail. + * + * This discriminated union provides type-safe error handling without exceptions. + * Check the `success` property to determine if the operation succeeded. + * + * @template T - The type of the success value + * @template E - The type of the error (defaults to Error) + * + * @example + * ```typescript + * const result = writer.writeSync(data); + * if (result.success) { + * console.log('Success!', result.value); + * } else { + * console.error('Error:', result.error.message); + * } + * ``` + */ +export type Result = { success: true; value: T } | { success: false; error: E }; + +// Writer interface with generics and async support +/** + * Generic writer interface for all data exporters. + * + * Provides both synchronous and asynchronous methods for writing and appending data. + * All methods use the Result type for error handling instead of throwing exceptions. + * + * @template T - The type of data objects being written. Must extend Record + * + * @example + * ```typescript + * interface User extends Record { + * id: number; + * name: string; + * } + * + * const writer: OutportWriter = new CsvWriter({...}); + * + * // Synchronous write + * const result = writer.writeSync([{ id: 1, name: 'Alice' }]); + * + * // Asynchronous append + * await writer.append({ id: 2, name: 'Bob' }); + * ``` + */ +export interface OutportWriter> { + /** + * Synchronously write multiple rows of data. + * + * In 'write' mode, overwrites the file. In 'append' mode, adds to existing content. + * + * @param data - Array of data objects to write + * @returns Result indicating success or failure + */ + writeSync(data: T[]): Result; + + /** + * Asynchronously write multiple rows of data. + * + * In 'write' mode, overwrites the file. In 'append' mode, adds to existing content. + * + * @param data - Array of data objects to write + * @returns Promise of Result indicating success or failure + */ + write(data: T[]): Promise>; + + /** + * Synchronously append one or more rows to the file. + * + * If the file doesn't exist, creates it with headers. + * + * @param data - Single data object or array of objects to append + * @returns Result indicating success or failure + */ + appendSync(data: T | T[]): Result; + + /** + * Asynchronously append one or more rows to the file. + * + * If the file doesn't exist, creates it with headers. + * + * @param data - Single data object or array of objects to append + * @returns Promise of Result indicating success or failure + */ + append(data: T | T[]): Promise>; +} + +/** + * Supported writer types for data export. + * + * - `csv` - Comma-separated values format + * - `json` - JavaScript Object Notation format (coming soon) + */ +export type WriterType = 'csv' | 'json'; + +/** + * Write mode determining how the writer handles existing files. + * + * - `write` - Overwrites the entire file on each write operation + * - `append` - Adds data to the end of existing files + * + * @example + * ```typescript + * // Overwrite mode - clears file each time + * const writer1 = new CsvWriter({ type: 'csv', mode: 'write', file: 'data.csv' }); + * + * // Append mode - adds to existing file + * const writer2 = new CsvWriter({ type: 'csv', mode: 'append', file: 'data.csv' }); + * ``` + */ +export type WriterMode = 'write' | 'append'; + +/** + * CSV-specific configuration options. + * + * Customize CSV output format, headers, delimiters, and encoding. + * + * @template T - The type of data objects being written + * + * @example + * ```typescript + * // Tab-separated with custom headers + * const config: CsvConfig = { + * delimiter: '\t', + * headers: ['ID', 'Name', 'Email'], + * includeUtf8Bom: true + * }; + * ``` + */ +export interface CsvConfig { + /** + * Column delimiter character (default: ','). + * + * Must be a single character. Common values: + * - ',' - Comma (default) + * - '\t' - Tab (TSV files) + * - ';' - Semicolon (European CSV) + * - '|' - Pipe + */ + delimiter?: string; + + /** + * Quote character for escaping values (default: '"'). + * + * Must be a single character. Values containing delimiters, + * newlines, or quotes will be wrapped in this character. + */ + quote?: string; + + /** + * Map object keys to custom column names. + * + * @example + * ```typescript + * columnMapping: { + * userId: 'User ID', + * fullName: 'Full Name' + * } + * ``` + */ + columnMapping?: Partial>; + + /** + * Explicitly set column headers in desired order. + * + * If not provided, headers are inferred from the first data object. + * + * @example + * ```typescript + * headers: ['ID', 'Name', 'Email'] + * ``` + */ + headers?: string[]; + + /** + * Keys to include from data objects, in the desired order. + * + * If not provided, all keys from the first data object are used. + * + * @example + * ```typescript + * includeKeys: ['id', 'name'] // Only export these columns + * ``` + */ + includeKeys?: (keyof T)[]; + + /** + * Include UTF-8 Byte Order Mark (BOM) at the start of the file. + * + * Set to true for better Excel compatibility when your data contains + * non-ASCII characters (accents, emoji, etc.). Modern tools ignore the BOM. + * + * @default false + */ + includeUtf8Bom?: boolean; +} + +/** + * Complete writer options including type-specific configuration. + * + * Includes the base writer configuration plus optional format-specific settings. + * + * @template T - The type of data objects being written + * + * @example + * ```typescript + * const options: WriterOptions = { + * type: 'csv', + * mode: 'write', + * file: './output/users.csv', + * csvConfig: { + * delimiter: '\t', + * includeUtf8Bom: true + * } + * }; + * ``` + */ +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; +} + +/** + * Alias for WriterOptions - used by factory pattern. + * + * @template T - The type of data objects being written + */ +export type WriterConfig = WriterOptions; + +/** + * File writer abstraction for testability and dependency injection. + * + * Provides a consistent interface for file I/O operations, making it easy + * to mock file operations in tests or swap implementations. + * + * @example + * ```typescript + * // Use custom file writer for testing + * const mockWriter: FileWriter = { + * writeSync: (path, content) => ({ success: true, value: undefined }), + * write: async (path, content) => ({ success: true, value: undefined }), + * appendSync: (path, content) => ({ success: true, value: undefined }), + * append: async (path, content) => ({ success: true, value: undefined }), + * existsSync: (path) => false, + * exists: async (path) => false, + * }; + * + * const writer = new CsvWriter(config, mockWriter); + * ``` + */ +export interface FileWriter { + /** + * Synchronously write content to a file, overwriting if it exists. + * + * @param path - Absolute or relative file path + * @param content - String content to write + * @returns Result indicating success or failure + */ + writeSync(path: string, content: string): Result; + + /** + * Asynchronously write content to a file, overwriting if it exists. + * + * @param path - Absolute or relative file path + * @param content - String content to write + * @returns Promise of Result indicating success or failure + */ + write(path: string, content: string): Promise>; + + /** + * Synchronously append content to a file, creating it if it doesn't exist. + * + * @param path - Absolute or relative file path + * @param content - String content to append + * @returns Result indicating success or failure + */ + appendSync(path: string, content: string): Result; + + /** + * Asynchronously append content to a file, creating it if it doesn't exist. + * + * @param path - Absolute or relative file path + * @param content - String content to append + * @returns Promise of Result indicating success or failure + */ + append(path: string, content: string): Promise>; + + /** + * Synchronously check if a file exists. + * + * @param path - Absolute or relative file path + * @returns true if the file exists, false otherwise + */ + existsSync(path: string): boolean; + + /** + * Asynchronously check if a file exists. + * + * @param path - Absolute or relative file path + * @returns Promise resolving to true if the file exists, false otherwise + */ + exists(path: string): Promise; +} diff --git a/src/writers/WriterFactory.ts b/src/writers/WriterFactory.ts new file mode 100644 index 0000000..126de2e --- /dev/null +++ b/src/writers/WriterFactory.ts @@ -0,0 +1,57 @@ +import type { OutportWriter, WriterConfig, FileWriter } from '../types'; +import { CsvWriter } from './csv/CsvWriter'; +import { ValidationError } from '../errors'; + +/** + * Factory for creating data writer instances. + * + * Provides a centralized way to instantiate writers based on configuration, + * making it easy to switch between different output formats. + * + * @example + * ```typescript + * const writer = WriterFactory.create({ + * type: 'csv', + * mode: 'write', + * file: './output.csv', + * csvConfig: { delimiter: '\t' } + * }); + * ``` + */ +export class WriterFactory { + /** + * Creates a writer instance based on the provided configuration. + * + * @template T - The type of data objects being written + * @param config - Writer configuration including type and options + * @param fileWriter - Optional custom file writer for dependency injection + * @returns A writer instance matching the specified type + * + * @throws {ValidationError} If an unsupported writer type is specified + * + * @example + * ```typescript + * const csvWriter = WriterFactory.create({ + * type: 'csv', + * mode: 'write', + * file: './users.csv' + * }); + * ``` + */ + static create>( + config: WriterConfig, + fileWriter?: FileWriter + ): OutportWriter { + switch (config.type) { + case 'csv': + return new CsvWriter(config, fileWriter); + case 'json': + throw new ValidationError('JSON writer not yet implemented'); + default: { + // Exhaustive check - this should never be reached + const _exhaustive: never = config.type; + throw new ValidationError(`Unknown writer type: ${String(_exhaustive)}`); + } + } + } +} diff --git a/src/writers/csv/CsvFormatter.ts b/src/writers/csv/CsvFormatter.ts new file mode 100644 index 0000000..1acdced --- /dev/null +++ b/src/writers/csv/CsvFormatter.ts @@ -0,0 +1,49 @@ +/** + * Handles CSV formatting logic - converting values to properly escaped CSV format + */ +export class CsvFormatter { + constructor( + private readonly delimiter: string = ',', + private readonly quote: string = '"' + ) {} + + /** + * Formats a row as CSV + */ + formatRow(values: unknown[]): string { + return values.map((value) => this.formatValue(value)).join(this.delimiter); + } + + /** + * Formats a single value with proper CSV escaping + */ + private formatValue(value: unknown): string { + if (value == null) { + return ''; + } + + // Convert to string representation + let stringValue: string; + if (typeof value === 'string') { + stringValue = value; + } else if (typeof value === 'number' || typeof value === 'boolean') { + stringValue = String(value); + } else { + // For objects, arrays, and other types, use JSON serialization + stringValue = JSON.stringify(value); + } + + // If contains delimiter, newline, or quotes, wrap in quotes and escape existing quotes + if ( + stringValue.includes(this.delimiter) || + stringValue.includes('\n') || + stringValue.includes('\r') || + stringValue.includes(this.quote) + ) { + const escaped = stringValue.replace(new RegExp(this.quote, 'g'), this.quote + this.quote); + return `${this.quote}${escaped}${this.quote}`; + } + + return stringValue; + } +} diff --git a/src/writers/csv/CsvHeaderManager.ts b/src/writers/csv/CsvHeaderManager.ts new file mode 100644 index 0000000..4c10301 --- /dev/null +++ b/src/writers/csv/CsvHeaderManager.ts @@ -0,0 +1,84 @@ +import type { CsvConfig, Result } from '../../types'; +import { HeaderInitializationError } from '../../errors'; + +/** + * Manages CSV header initialization and key determination + */ +export class CsvHeaderManager> { + private headers: string[] | null = null; + private keys: (keyof T)[] | null = null; + + constructor(private readonly config?: CsvConfig) {} + + /** + * Initializes headers from config or first data object + */ + initialize(firstDataObject: T): Result { + if (this.headers !== null && this.keys !== null) { + return { success: true, value: undefined }; + } + + // Determine which keys to use + if (this.config?.includeKeys) { + this.keys = this.config.includeKeys; + } else if (this.config?.headers && this.config.headers.length > 0) { + // If explicit headers provided, infer keys from first object + this.keys = Object.keys(firstDataObject) as (keyof T)[]; + } else { + this.keys = Object.keys(firstDataObject) as (keyof T)[]; + } + + if (this.keys.length === 0) { + return { + success: false, + error: new HeaderInitializationError('Cannot determine headers from empty object'), + }; + } + + // Determine header names + if (this.config?.headers) { + this.headers = this.config.headers; + } else if (this.config?.columnMapping) { + const mapping = this.config.columnMapping; + this.headers = this.keys.map((key) => mapping[key] ?? String(key)); + } else { + this.headers = this.keys.map(String); + } + + return { success: true, value: undefined }; + } + + /** + * Gets the header row values + */ + getHeaders(): string[] { + if (this.headers === null) { + throw new HeaderInitializationError('Headers not initialized'); + } + return this.headers; + } + + /** + * Gets the data keys in order + */ + getKeys(): (keyof T)[] { + if (this.keys === null) { + throw new HeaderInitializationError('Keys not initialized'); + } + return this.keys; + } + + /** + * Converts a data object to array of values in correct order + */ + objectToValues(data: T): unknown[] { + return this.getKeys().map((key) => data[key]); + } + + /** + * Checks if headers have been initialized + */ + isInitialized(): boolean { + return this.headers !== null && this.keys !== null; + } +} diff --git a/src/writers/csv/CsvWriter.ts b/src/writers/csv/CsvWriter.ts new file mode 100644 index 0000000..fde1812 --- /dev/null +++ b/src/writers/csv/CsvWriter.ts @@ -0,0 +1,404 @@ +import type { OutportWriter, WriterOptions, Result, FileWriter } from '../../types'; +import { ValidationError, CsvFormattingError } from '../../errors'; +import { NodeFileWriter } from '../../io/FileWriter'; +import { CsvFormatter } from './CsvFormatter'; +import { CsvHeaderManager } from './CsvHeaderManager'; + +/** + * CSV Writer for exporting data to CSV files. + * + * Provides both synchronous and asynchronous methods for writing and appending + * data with support for custom delimiters, headers, column mapping, and more. + * + * The writer handles header initialization, value formatting, and file I/O, + * delegating to specialized helper classes for each concern. + * + * @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 CsvWriter({ + * type: 'csv', + * mode: 'write', + * file: './users.csv', + * csvConfig: { + * headers: ['ID', 'Name', 'Email'], + * delimiter: ',', + * includeUtf8Bom: true + * } + * }); + * + * 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 CsvWriter> implements OutportWriter { + private readonly formatter: CsvFormatter; + private readonly headerManager: CsvHeaderManager; + private readonly fileWriter: FileWriter; + private readonly includeUtf8Bom: boolean; + + /** + * Creates a new CSV writer instance. + * + * @param options - Configuration options for the CSV writer + * @param fileWriter - Optional custom file writer for dependency injection (useful for testing) + * + * @throws {ValidationError} If configuration is invalid (e.g., non-csv type, empty file path, multi-character delimiter) + * + * @example + * ```typescript + * const writer = new CsvWriter({ + * type: 'csv', + * mode: 'write', + * file: './output.csv' + * }); + * ``` + */ + constructor( + private readonly options: WriterOptions, + fileWriter: FileWriter = new NodeFileWriter() + ) { + this.validate(options); + this.fileWriter = fileWriter; + + // Initialize formatter with config + const delimiter = options.csvConfig?.delimiter ?? ','; + const quote = options.csvConfig?.quote ?? '"'; + this.formatter = new CsvFormatter(delimiter, quote); + + // Initialize header manager + this.headerManager = new CsvHeaderManager(options.csvConfig); + + this.includeUtf8Bom = options.csvConfig?.includeUtf8Bom ?? false; + } + + /** + * Validates writer options + */ + private validate(options: WriterOptions): void { + if (options.type !== 'csv') { + throw new ValidationError('Invalid writer type for CsvWriter'); + } + + if (options.file == null || options.file.length === 0) { + throw new ValidationError('File path must be provided for CsvWriter'); + } + + if (!options.file.endsWith('.csv')) { + throw new ValidationError('File extension must be .csv for CsvWriter'); + } + + const delimiter = options.csvConfig?.delimiter ?? ','; + if (delimiter.length !== 1) { + throw new ValidationError('Delimiter must be a single character'); + } + + const quote = options.csvConfig?.quote ?? '"'; + if (quote.length !== 1) { + throw new ValidationError('Quote character must be a single character'); + } + } + + /** + * Writes headers to file (sync) + */ + private writeHeadersSync(): Result { + const headerLine = this.formatter.formatRow(this.headerManager.getHeaders()); + const content = this.includeUtf8Bom ? '\uFEFF' + headerLine + '\n' : headerLine + '\n'; + + if (this.options.mode === 'write') { + return this.fileWriter.writeSync(this.options.file, content); + } else if (this.options.mode === 'append') { + if (!this.fileWriter.existsSync(this.options.file)) { + return this.fileWriter.writeSync(this.options.file, content); + } + } + + return { success: true, value: undefined }; + } + + /** + * Writes headers to file (async) + */ + private async writeHeaders(): Promise> { + const headerLine = this.formatter.formatRow(this.headerManager.getHeaders()); + const content = this.includeUtf8Bom ? '\uFEFF' + headerLine + '\n' : headerLine + '\n'; + + if (this.options.mode === 'write') { + return await this.fileWriter.write(this.options.file, content); + } else if (this.options.mode === 'append') { + const exists = await this.fileWriter.exists(this.options.file); + if (!exists) { + return await this.fileWriter.write(this.options.file, content); + } + } + + return { success: true, value: undefined }; + } + + /** + * Formats and writes data rows (sync) + */ + private writeRowsSync(data: T[], isFirstWrite: boolean): Result { + try { + const lines = data + .map((obj) => this.headerManager.objectToValues(obj)) + .map((values) => this.formatter.formatRow(values)) + .join('\n'); + + if (isFirstWrite && this.options.mode === 'write') { + // In write mode on first write, we need to append to headers (not overwrite) + return this.fileWriter.appendSync(this.options.file, lines + '\n'); + } + + return this.fileWriter.appendSync(this.options.file, lines + '\n'); + } catch (error) { + return { + success: false, + error: new CsvFormattingError(error instanceof Error ? error.message : String(error)), + }; + } + } + + /** + * Formats and writes data rows (async) + */ + private async writeRows(data: T[], isFirstWrite: boolean): Promise> { + try { + const lines = data + .map((obj) => this.headerManager.objectToValues(obj)) + .map((values) => this.formatter.formatRow(values)) + .join('\n'); + + if (isFirstWrite && this.options.mode === 'write') { + // In write mode on first write, we need to append to headers (not overwrite) + return await this.fileWriter.append(this.options.file, lines + '\n'); + } + + return await this.fileWriter.append(this.options.file, lines + '\n'); + } catch (error) { + return { + success: false, + error: new CsvFormattingError(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. In 'append' mode, + * this adds data to the end of the file. + * + * Headers are automatically initialized from the first data object if not + * already set. In 'write' mode, headers are written on each call. + * + * @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'), + }; + } + + let headersJustInitialized = false; + + // Initialize headers if needed + if (!this.headerManager.isInitialized()) { + const initResult = this.headerManager.initialize(data[0]!); + if (!initResult.success) { + return initResult; + } + headersJustInitialized = true; + } + + // In write mode, always write headers (overwriting existing content) + // In append mode, only write headers if file doesn't exist + if (this.options.mode === 'write' || headersJustInitialized) { + const writeResult = this.writeHeadersSync(); + if (!writeResult.success) { + return writeResult; + } + } + + return this.writeRowsSync(data, true); + } + + /** + * Asynchronously writes multiple rows of data to the file. + * + * In 'write' mode, this overwrites the entire file. In 'append' mode, + * this adds data to the end of the file. + * + * Headers are automatically initialized from the first data object if not + * already set. In 'write' mode, headers are written on each call. + * + * @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'), + }; + } + + let headersJustInitialized = false; + + // Initialize headers if needed + if (!this.headerManager.isInitialized()) { + const initResult = this.headerManager.initialize(data[0]!); + if (!initResult.success) { + return initResult; + } + headersJustInitialized = true; + } + + // In write mode, always write headers (overwriting existing content) + // In append mode, only write headers if file doesn't exist + if (this.options.mode === 'write' || headersJustInitialized) { + const writeResult = await this.writeHeaders(); + if (!writeResult.success) { + return writeResult; + } + } + + return await this.writeRows(data, true); + } + + /** + * Synchronously appends one or more rows to the file. + * + * If the file doesn't exist, creates it with headers. If headers haven't been + * initialized, they are inferred from the first data object. + * + * @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 }; + } + + // Initialize headers if needed + if (!this.headerManager.isInitialized()) { + const initResult = this.headerManager.initialize(dataArray[0]!); + if (!initResult.success) { + return initResult; + } + + const writeResult = this.writeHeadersSync(); + if (!writeResult.success) { + return writeResult; + } + } + + return this.writeRowsSync(dataArray, false); + } + + /** + * Asynchronously appends one or more rows to the file. + * + * If the file doesn't exist, creates it with headers. If headers haven't been + * initialized, they are inferred from the first data object. + * + * 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 }; + } + + // Initialize headers if needed + if (!this.headerManager.isInitialized()) { + const initResult = this.headerManager.initialize(dataArray[0]!); + if (!initResult.success) { + return initResult; + } + + const writeResult = await this.writeHeaders(); + if (!writeResult.success) { + return writeResult; + } + } + + return await this.writeRows(dataArray, false); + } +} diff --git a/tsconfig.json b/tsconfig.json index b2df079..69a31a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,6 @@ /* Completeness */ "skipLibCheck": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "__tests__/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts index 2ffb3c1..be4a8ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ }, // Test file patterns - include: ['src/**/*.{test,spec}.ts'], + include: ['src/**/*.{test,spec}.ts', '__tests__/**/*.{test,spec}.ts'], // Timeout testTimeout: 10000,