]> git.ardis.lu - csvio/commitdiff
Add initial `TransformStream` CSVWriter implementation
authorArdis Lu <ardisglu@gmail.com>
Sun, 17 Aug 2025 06:40:55 +0000 (23:40 -0700)
committerArdis Lu <ardisglu@gmail.com>
Sun, 17 Aug 2025 06:40:55 +0000 (23:40 -0700)
This change enables users to create a `TransformStream` by doing `new CSVWriter()` instead of always creating an ultimate sink `WritableStream`. This behavior is useful when there is some transformation that must be performed on the CSV data before writing (e.g., compressing into a `.csv.gz` file using `CompressionStream`).

src/core.js
test/core.test.js
test/data/simple.csv.gz
test/utils.js

index 68eb00645fb97db5ddd0043160c88d2964761bf5..5d35eee91523facca11e089a98c01a2f4de5f648 100644 (file)
@@ -437,7 +437,8 @@ export class CSVTransformer extends TransformStream {
 
 /**
  * @typedef CSVWriterStatus The current status of the CSVWriter.
- * @property {string} name The name of the output CSV file that this writer is writing to.
+ * @property {string|null} name The name of the output CSV file that this writer is writing to, or `null` if the writer is
+ * a `TransformStream`.
  * @property {number} elapsed The number of milliseconds that have elapsed between this writer's creation and the output CSV file
  * handle closing, or until now if the writer is still writing. Measured using `performance.now()`.
  * @property {number} rows The estimated number of CSV rows that have been written. If the data passed to the writer is
@@ -445,73 +446,107 @@ export class CSVTransformer extends TransformStream {
  * @property {boolean} done If `true`, the writer is finished writing to the output CSV file and the file handle is closed.
  */
 
+/** @type {WeakMap<CSVWriter, CSVWriterStatus>} Simulated private field for CSVWriter, required to simulate a dynamic superclass. */
+const _status = new WeakMap();
+
 /**
  * Write a streamed CSV file to disk.
  * 
  * A simple wrapper around Node.js's [`fs.writeFile`](https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options) to write
  * streamed data to a file. If the input data is JSON, the data is converted to a string representing a RFC 4180 CSV record. If the
  * data is not JSON (e.g., if it is already converted to a CSV string), the data is written to the CSV file directly.
- * @extends WritableStream
  */
-export class CSVWriter extends WritableStream {
-  #status;
-
+export class CSVWriter {
   /**
+   * @overload
    * @param {PathLike} path A `string`, `Buffer`, or `URL` representing a path to a local CSV file destination. If the file already
    * exists, its **data will be overwritten**.
+   * @returns {CSVWriter&WritableStream<any>}
    */
-  constructor(path) {
-    const fullPath = parsePathLike(path);
-    const dir = dirname(fullPath);
+  /**
+   * @overload
+   * @param {null} [path] A `string`, `Buffer`, or `URL` representing a path to a local CSV file destination. If the file already
+   * exists, its **data will be overwritten**.
+   * @returns {CSVWriter&TransformStream<Array<string>,string>}
+   */
+  constructor(path = null) {
     const status = {
-      name: basename(fullPath),
       start: performance.now(),
       elapsed: 0,
       rows: 0,
       done: false
-    };
-    /** @type {FileHandle} */
-    let handle;
-    super({
-      async start() {
-        await mkdir(dir, { recursive: true });
-        handle = await open(fullPath, 'w');
-      },
-      async write(chunk) {
-        let data;
-        if (Array.isArray(chunk)) {
-          data = CSVWriter.arrayToCSVString(chunk);
-          status.rows += chunk.length;
+    }
+
+    /** @type {WritableStream<any>|TransformStream<Array<string>,string>} */
+    let stream;
+    if (path === null) {
+      status.name = null;
+      stream = new TransformStream({
+        transform(chunk, controller) {
+          let data;
+          if (Array.isArray(chunk)) {
+            data = CSVWriter.arrayToCSVString(chunk);
+            status.rows += chunk.length;
+          }
+          else {
+            data = chunk;
+            status.rows++;
+          }
+          controller.enqueue(data);
         }
-        else {
-          data = chunk;
-          status.rows++;
+      });
+    }
+    else {
+      const fullPath = parsePathLike(path);
+      const dir = dirname(fullPath);
+      status.name = basename(fullPath);
+      /** @type {FileHandle} */
+      let handle;
+      stream = new WritableStream({
+        async start() {
+          await mkdir(dir, { recursive: true });
+          handle = await open(fullPath, 'w');
+        },
+        async write(chunk) {
+          let data;
+          if (Array.isArray(chunk)) {
+            data = CSVWriter.arrayToCSVString(chunk);
+            status.rows += chunk.length;
+          }
+          else {
+            data = chunk;
+            status.rows++;
+          }
+          await handle.write(data);
+        },
+        async close() {
+          await handle.close();
+          status.elapsed = performance.now() - status.start;
+          status.done = true;
+        },
+        async abort() {
+          await this.close();
         }
-        await handle.write(data);
-      },
-      async close() {
-        await handle.close();
-        status.elapsed = performance.now() - status.start;
-        status.done = true;
-      },
-      async abort() {
-        await this.close();
-      }
-    }, new CountQueuingStrategy({ highWaterMark: 4 })); // "4" picked based on manual benchmarks
+      }, new CountQueuingStrategy({ highWaterMark: 4 })); // "4" picked based on manual benchmarks
+    }
 
-    this.#status = status;
+    // Proxy is required to simulate a dynamic superclass
+    const proxy = createProxy(this, stream);
+    _status.set(proxy, status);
+    return proxy;
   }
 
   /** @type {CSVWriterStatus} */
   get status() {
-    if (!this.#status.done) {
-      this.#status.elapsed = performance.now() - this.#status.start;
+    const status = _status.get(this);
+    if (!status.done) {
+      status.elapsed = performance.now() - status.start;
     }
     return {
-      name: this.#status.name,
-      elapsed: this.#status.elapsed,
-      rows: this.#status.rows,
-      done: this.#status.done
+      name: status.name,
+      elapsed: status.elapsed,
+      rows: status.rows,
+      done: status.done
     }
   }
 
index c4c8eb1507af43bd6f2c967785e45dd953fe2284..510b032f2fde9f16319bbdce8ad0dafe9c357776 100644 (file)
@@ -1,12 +1,12 @@
 import { suite, test } from 'node:test';
 import { ok, deepStrictEqual, throws, rejects } from 'node:assert/strict';
-import { unlink } from 'node:fs/promises';
+import { unlink, readFile } from 'node:fs/promises';
 import { normalize, basename, resolve } from 'node:path';
 import { setTimeout } from 'node:timers/promises';
 import { pathToFileURL } from 'node:url';
-import { ReadableStream, DecompressionStream, TextDecoderStream } from 'node:stream/web';
+import { ReadableStream, DecompressionStream, CompressionStream, TextDecoderStream } from 'node:stream/web';
 
-import { csvStreamEqualWritable, createCSVMockStream, createTempFile } from './utils.js';
+import { csvStreamEqualWritable, createCSVMockStream, createTempFile, createWritableFileStream } from './utils.js';
 import { parsePathLike, createFileStream, CSVReader, CSVTransformer, CSVWriter } from '../src/core.js';
 
 suite('CSVWriter.arrayToCSVString', { concurrency: true }, () => {
@@ -720,6 +720,32 @@ suite('CSVWriter', { concurrency: true }, () => {
     deepStrictEqual(s instanceof CSVWriter, true);
     await s.abort();
   });
+  test('can create TransformStream (raw strings)', { concurrency: true }, async (t) => {
+    const input = new URL('./data/simple.csv', import.meta.url);
+    const stream = new CSVReader(input).pipeThrough(new CSVWriter());
+    const expected = [
+      'column1,column2,column3\r\n',
+      'abc,def,ghi\r\n',
+      '123,456,789\r\n',
+      'aaa,bbb,ccc\r\n'
+    ];
+    for await (const row of stream) {
+      deepStrictEqual(row, expected.shift());
+    }
+  });
+  test('can create TransformStream (simple.csv.gz)', { concurrency: true }, async (t) => {
+    const temp = await createTempFile();
+    t.after(async () => await unlink(temp));
+    const input = new URL('./data/simple.csv', import.meta.url);
+    const output = new URL('./data/simple.csv.gz', import.meta.url);
+    await new CSVReader(input)
+      .pipeThrough(new CSVWriter())
+      .pipeThrough(new CompressionStream('gzip'))
+      .pipeTo(createWritableFileStream(temp));
+    const expected = await readFile(output);
+    const actual = await readFile(temp);
+    ok(actual.equals(expected));
+  });
 });
 
 suite('CSVWriter status', { concurrency: true }, () => {
@@ -730,6 +756,12 @@ suite('CSVWriter status', { concurrency: true }, () => {
     await createCSVMockStream([['']]).pipeTo(writer);
     deepStrictEqual(writer.status.name, basename(temp));
   });
+  test('records file name of "null" if CSVWriter is a TransformStream', { concurrency: true }, async (t) => {
+    const writer = new CSVWriter();
+    const stream = createCSVMockStream([['']]).pipeThrough(writer);
+    deepStrictEqual(writer.status.name, null);
+    for await (const _ of stream) { }
+  });
   test('records elapsed time', { concurrency: true }, async (t) => {
     const temp = await createTempFile();
     t.after(async () => await unlink(temp));
index 33b18c193fa9a40a6bf647dc17839e37544c41c9..f13bea0caa63b5ab37ed4177b4e2b17c95e4b5aa 100644 (file)
Binary files a/test/data/simple.csv.gz and b/test/data/simple.csv.gz differ
index 9249d6b4e68fcde2604e24f5fc2a98c7c07c3b86..a7b5adbefd5d9a91fdb1c1db74815a7b583de58f 100644 (file)
@@ -4,6 +4,8 @@ import { mkdtemp, open } from 'node:fs/promises';
 import { normalize } from 'node:path';
 import { ReadableStream } from 'node:stream/web';
 /** @import { TestContext } from 'node:test'; */
+/** @import { PathLike } from 'node:fs'; */
+/** @import { FileHandle } from 'node:fs/promises'; */
 
 import { utils } from '../src/errorStrategies.js';
 
@@ -208,4 +210,31 @@ export function createRandomCSV(rows, columns, seed) {
     }
   })();
   return ReadableStream.from(chunks);
-}
\ No newline at end of file
+}
+
+/**
+ * Creates a writable file stream for a local file.
+ * 
+ * @see {@link https://streams.spec.whatwg.org/#example-ws-backpressure}
+ * @param {PathLike} path A `string`, `Buffer`, or `URL` representing a path to a local file.
+ * @returns {WritableStream<Uint8Array<ArrayBuffer>>}
+ */
+export function createWritableFileStream(path) {
+  /** @type {FileHandle} */
+  let handle;
+
+  return new WritableStream({
+    async start() {
+      handle = await open(path, 'w');
+    },
+    async write(chunk) {
+      await handle.write(chunk, 0, chunk.length);
+    },
+    async close() {
+      await handle.close();
+    },
+    async abort() {
+      await handle.close();
+    }
+  });
+}
This page took 0.046693 seconds and 4 git commands to generate.