Skip to content

Commit

Permalink
feat(csv): add formatCSV(), types, tests
Browse files Browse the repository at this point in the history
  • Loading branch information
postspectacular committed Sep 12, 2021
1 parent 4987e1d commit a2ca1b6
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 1 deletion.
3 changes: 3 additions & 0 deletions packages/csv/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@
"./api": {
"import": "./api.js"
},
"./format": {
"import": "./format.js"
},
"./parse": {
"import": "./parse.js"
},
Expand Down
29 changes: 29 additions & 0 deletions packages/csv/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Fn2, Nullable } from "@thi.ng/api";
import type { Stringer } from "@thi.ng/strings";

/**
* Tuple representing a single CSV row/record.
Expand Down Expand Up @@ -104,3 +105,31 @@ export interface CSVOpts extends CommonCSVOpts {
*/
cols: Nullable<ColumnSpec>[] | Record<string, ColumnSpec>;
}

export interface CSVFormatOpts {
/**
* Column names, in order of appearance. If omitted and rows are supplied as
* {@link CSVRecord}, the keys of the first item will be used as column
* names. If `header` is omitted and rows are given as array, NO header row
* will be created.
*/
header: string[];
/**
* Column value formatters. If given as object, and the `header` option MUST be
* given and the column names given in `header` need to correspond with keys
* in the object.
*/
cols: Nullable<Stringer<any>>[] | Record<string, Stringer<any>>;
/**
* Column delimiter
*
* @defaultValue `,`
*/
delim: string;
/**
* Quote char
*
* @defaultValue `"`
*/
quote: string;
}
90 changes: 90 additions & 0 deletions packages/csv/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Nullable } from "@thi.ng/api";
import { isArray } from "@thi.ng/checks/is-array";
import { isIterable } from "@thi.ng/checks/is-iterable";
import type { Stringer } from "@thi.ng/strings";
import { wrap } from "@thi.ng/strings/wrap";
import type { Reducer, Transducer } from "@thi.ng/transducers";
import { compR } from "@thi.ng/transducers/func/compr";
import { iterator } from "@thi.ng/transducers/iterator";
import { isReduced } from "@thi.ng/transducers/reduced";
import { str } from "@thi.ng/transducers/rfn/str";
import { transduce } from "@thi.ng/transducers/transduce";
import type { CSVRecord, CSVRow, CSVFormatOpts } from "./api";

export function formatCSV(
opts?: Partial<CSVFormatOpts>
): Transducer<CSVRow | CSVRecord, string>;
export function formatCSV(
opts: Partial<CSVFormatOpts>,
src: Iterable<CSVRow | CSVRecord>
): IterableIterator<string>;
export function formatCSV(
opts?: Partial<CSVFormatOpts>,
src?: Iterable<CSVRow | CSVRecord>
): any {
return isIterable(src)
? iterator(formatCSV(opts), src)
: (rfn: Reducer<any, string>) => {
let { header, cols, delim, quote } = {
delim: ",",
quote: `"`,
cols: [],
...opts,
};
let colTx: Nullable<Stringer<any>>[];
const reQuote = new RegExp(quote, "g");
const reduce = rfn[2];
let headerDone = false;
return compR(rfn, (acc, row: CSVRow | CSVRecord) => {
if (!headerDone) {
if (!header && !isArray(row)) {
header = Object.keys(row);
}
colTx = isArray(cols)
? cols
: header
? header.map(
(id) =>
(<Record<string, Stringer<any>>>cols)[id]
)
: [];
}
const $row = isArray(row)
? row
: header!.map((k) => (<CSVRecord>row)[k]);
const line = (header || $row)
.map((_, i) => {
const val = $row[i];
const cell =
val != null
? colTx[i]
? colTx[i]!(val)
: String(val)
: "";
return cell.indexOf(quote) !== -1
? wrap(quote)(
cell.replace(reQuote, `${quote}${quote}`)
)
: cell;
})
.join(delim);
if (!headerDone) {
if (header) {
acc = reduce(acc, header.join(delim));
} else {
header = $row;
}
headerDone = true;
!isReduced(acc) && (acc = reduce(acc, line));
return acc;
} else {
return reduce(acc, line);
}
});
};
}

export const formatCSVString = (
opts: Partial<CSVFormatOpts & { rowDelim: string }> = {},
src: Iterable<CSVRow>
) => transduce(formatCSV(opts), str(opts.rowDelim || "\n"), src);
1 change: 1 addition & 0 deletions packages/csv/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./api";
export * from "./format";
export * from "./parse";
export * from "./transforms";
10 changes: 10 additions & 0 deletions packages/csv/src/transforms.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { padLeft } from "@thi.ng/strings/pad-left";
import { maybeParseFloat, maybeParseInt } from "@thi.ng/strings/parse";
import type { CellTransform } from "./api";

Expand All @@ -19,3 +20,12 @@ export const hex =
(defaultVal = 0): CellTransform =>
(x) =>
maybeParseInt(x, defaultVal, 16);

// formatters

export const zeroPad = (digits: number) => padLeft(digits, "0");

export const formatFloat =
(prec = 2) =>
(x: number) =>
x.toFixed(prec);
44 changes: 44 additions & 0 deletions packages/csv/test/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { group } from "@thi.ng/testament";
import * as assert from "assert";
import { formatCSV, formatFloat, zeroPad } from "../src";

group("format array", {
header: () =>
assert.deepStrictEqual(
[...formatCSV({ header: ["a", "b"] }, [[1, 2]])],
["a,b", "1,2"]
),

"no header": () =>
assert.deepStrictEqual([...formatCSV({}, [[1, 2]])], ["1,2"]),

tx: () =>
assert.deepStrictEqual(
[...formatCSV({ cols: [null, formatFloat(2)] }, [[1, 2]])],
["1,2.00"]
),
});

group("format obj", {
header: () =>
assert.deepStrictEqual(
[...formatCSV({ header: ["a", "b"] }, [{ a: 1, b: 2 }])],
["a,b", "1,2"]
),

"no header": () =>
assert.deepStrictEqual(
[...formatCSV({}, [{ a: 1, b: 2 }])],
["a,b", "1,2"]
),

tx: () =>
assert.deepStrictEqual(
[
...formatCSV({ cols: { a: zeroPad(4), b: formatFloat(2) } }, [
{ a: 1, b: 2 },
]),
],
["a,b", "0001,2.00"]
),
});
2 changes: 1 addition & 1 deletion packages/csv/test/index.ts → packages/csv/test/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { group } from "@thi.ng/testament";
import * as assert from "assert";
import { parseCSV, parseCSVFromString } from "../src";

group("csv", {
group("parse", {
header: () => {
assert.deepStrictEqual(
[...parseCSV({ header: ["a", "b", "c"] }, ["1,2,3"])],
Expand Down

0 comments on commit a2ca1b6

Please sign in to comment.