mirror of
https://github.com/denoland/std.git
synced 2024-11-21 12:40:03 +00:00
5926aeb60b
feat(csv): accept readonly cell/header data (#5734)
468 lines
11 KiB
TypeScript
468 lines
11 KiB
TypeScript
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
|
|
// This module is browser compatible.
|
|
|
|
/** Array index or record key corresponding to a value for a data object. */
|
|
export type PropertyAccessor = number | string;
|
|
|
|
/**
|
|
* Column information.
|
|
*
|
|
* @param header Explicit column header name. If omitted,
|
|
* the (final) property accessor is used for this value.
|
|
*
|
|
* @param prop Property accessor(s) used to access the value on the object
|
|
*/
|
|
export type ColumnDetails = {
|
|
header?: string;
|
|
prop: PropertyAccessor | PropertyAccessor[];
|
|
};
|
|
|
|
/**
|
|
* The most essential aspect of a column is accessing the property holding the
|
|
* data for that column on each object in the data array. If that member is at
|
|
* the top level, `Column` can simply be a property accessor, which is either a
|
|
* `string` (if it's a plain object) or a `number` (if it's an array).
|
|
*
|
|
* ```ts
|
|
* const columns = [
|
|
* "name",
|
|
* ];
|
|
* ```
|
|
*
|
|
* Each property accessor will be used as the header for the column:
|
|
*
|
|
* | name |
|
|
* | :--: |
|
|
* | Deno |
|
|
*
|
|
* - If the required data is not at the top level (it's nested in other
|
|
* objects/arrays), then a simple property accessor won't work, so an array of
|
|
* them will be required.
|
|
*
|
|
* ```ts
|
|
* const columns = [
|
|
* ["repo", "name"],
|
|
* ["repo", "org"],
|
|
* ];
|
|
* ```
|
|
*
|
|
* When using arrays of property accessors, the header names inherit the value
|
|
* of the last accessor in each array:
|
|
*
|
|
* | name | org |
|
|
* | :--: | :------: |
|
|
* | deno | denoland |
|
|
*
|
|
* - If a different column header is desired, then a `ColumnDetails` object type
|
|
* can be used for each column:
|
|
*
|
|
* - **`header?: string`** is the optional value to use for the column header
|
|
* name
|
|
*
|
|
* - **`prop: PropertyAccessor | PropertyAccessor[]`** is the property accessor
|
|
* (`string` or `number`) or array of property accessors used to access the
|
|
* data on each object
|
|
*
|
|
* ```ts
|
|
* const columns = [
|
|
* "name",
|
|
* {
|
|
* prop: ["runsOn", 0],
|
|
* header: "language 1",
|
|
* },
|
|
* {
|
|
* prop: ["runsOn", 1],
|
|
* header: "language 2",
|
|
* },
|
|
* ];
|
|
* ```
|
|
*
|
|
* | name | language 1 | language 2 |
|
|
* | :--: | :--------: | :--------: |
|
|
* | Deno | Rust | TypeScript |
|
|
*/
|
|
export type Column = ColumnDetails | PropertyAccessor | PropertyAccessor[];
|
|
|
|
/** An object (plain or array) */
|
|
export type DataItem = Readonly<Record<string, unknown> | unknown[]>;
|
|
|
|
/** Options for {@linkcode stringify}. */
|
|
export type StringifyOptions = {
|
|
/** Whether to include the row of headers or not.
|
|
*
|
|
* @default {true}
|
|
*/
|
|
headers?: boolean;
|
|
/**
|
|
* Delimiter used to separate values. Examples:
|
|
* - `","` _comma_
|
|
* - `"\t"` _tab_
|
|
* - `"|"` _pipe_
|
|
* - etc.
|
|
*
|
|
* @default {","}
|
|
*/
|
|
separator?: string;
|
|
/**
|
|
* A list of instructions for how to target and transform the data for each
|
|
* column of output. This is also where you can provide an explicit header
|
|
* name for the column.
|
|
*
|
|
* @default {[]}
|
|
*/
|
|
columns?: readonly Column[];
|
|
/**
|
|
* Whether to add a
|
|
* {@link https://en.wikipedia.org/wiki/Byte_order_mark | byte-order mark} to the
|
|
* beginning of the file content. Required by software such as MS Excel to
|
|
* properly display Unicode text.
|
|
*
|
|
* @default {false}
|
|
*/
|
|
bom?: boolean;
|
|
};
|
|
|
|
const QUOTE = '"';
|
|
const LF = "\n";
|
|
const CRLF = "\r\n";
|
|
const BYTE_ORDER_MARK = "\ufeff";
|
|
|
|
function getEscapedString(value: unknown, sep: string): string {
|
|
if (value === undefined || value === null) return "";
|
|
let str = "";
|
|
|
|
if (typeof value === "object") str = JSON.stringify(value);
|
|
else str = String(value);
|
|
|
|
// Is regex.test more performant here? If so, how to dynamically create?
|
|
// https://stackoverflow.com/questions/3561493/
|
|
if (str.includes(sep) || str.includes(LF) || str.includes(QUOTE)) {
|
|
return `${QUOTE}${str.replaceAll(QUOTE, `${QUOTE}${QUOTE}`)}${QUOTE}`;
|
|
}
|
|
|
|
return str;
|
|
}
|
|
|
|
type NormalizedColumn = Omit<ColumnDetails, "header" | "prop"> & {
|
|
header: string;
|
|
prop: readonly PropertyAccessor[];
|
|
};
|
|
|
|
function normalizeColumn(column: Column): NormalizedColumn {
|
|
let header: NormalizedColumn["header"];
|
|
let prop: NormalizedColumn["prop"];
|
|
|
|
if (typeof column === "object") {
|
|
if (Array.isArray(column)) {
|
|
header = String(column[column.length - 1]);
|
|
prop = column;
|
|
} else {
|
|
prop = Array.isArray(column.prop) ? column.prop : [column.prop];
|
|
header = typeof column.header === "string"
|
|
? column.header
|
|
: String(prop[prop.length - 1]);
|
|
}
|
|
} else {
|
|
header = String(column);
|
|
prop = [column];
|
|
}
|
|
|
|
return { header, prop };
|
|
}
|
|
|
|
/**
|
|
* Returns an array of values from an object using the property accessors
|
|
* (and optional transform function) in each column
|
|
*/
|
|
function getValuesFromItem(
|
|
item: DataItem,
|
|
normalizedColumns: readonly NormalizedColumn[],
|
|
): unknown[] {
|
|
const values: unknown[] = [];
|
|
|
|
if (normalizedColumns.length) {
|
|
for (const column of normalizedColumns) {
|
|
let value: unknown = item;
|
|
|
|
for (const prop of column.prop) {
|
|
if (typeof value !== "object" || value === null) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (typeof prop === "number") value = value[prop];
|
|
else {
|
|
throw new TypeError(
|
|
'Property accessor is not of type "number"',
|
|
);
|
|
}
|
|
} // I think this assertion is safe. Confirm?
|
|
else value = (value as Record<string, unknown>)[prop];
|
|
}
|
|
|
|
values.push(value);
|
|
}
|
|
} else {
|
|
if (Array.isArray(item)) {
|
|
values.push(...item);
|
|
} else if (typeof item === "object") {
|
|
throw new TypeError(
|
|
"No property accessor function was provided for object",
|
|
);
|
|
} else {
|
|
values.push(item);
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
/**
|
|
* Converts an array of objects into a CSV string.
|
|
*
|
|
* @example Default options
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* ["Rick", 70],
|
|
* ["Morty", 14],
|
|
* ];
|
|
*
|
|
* assertEquals(stringify(data), `Rick,70\r\nMorty,14\r\n`);
|
|
* ```
|
|
*
|
|
* @example Give an array of objects and specify columns
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* { name: "Rick", age: 70 },
|
|
* { name: "Morty", age: 14 },
|
|
* ];
|
|
*
|
|
* const columns = ["name", "age"];
|
|
*
|
|
* assertEquals(stringify(data, { columns }), `name,age\r\nRick,70\r\nMorty,14\r\n`);
|
|
* ```
|
|
*
|
|
* @example Give an array of objects without specifying columns
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertThrows } from "@std/assert/throws";
|
|
*
|
|
* const data = [
|
|
* { name: "Rick", age: 70 },
|
|
* { name: "Morty", age: 14 },
|
|
* ];
|
|
*
|
|
* assertThrows(
|
|
* () => stringify(data),
|
|
* TypeError,
|
|
* "No property accessor function was provided for object",
|
|
* );
|
|
* ```
|
|
*
|
|
* @example Give an array of objects and specify columns with `headers: false`
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* { name: "Rick", age: 70 },
|
|
* { name: "Morty", age: 14 },
|
|
* ];
|
|
*
|
|
* const columns = ["name", "age"];
|
|
*
|
|
* assertEquals(
|
|
* stringify(data, { columns, headers: false }),
|
|
* `Rick,70\r\nMorty,14\r\n`,
|
|
* );
|
|
* ```
|
|
*
|
|
* @example Give an array of objects and specify columns with renaming
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* { name: "Rick", age: 70 },
|
|
* { name: "Morty", age: 14 },
|
|
* ];
|
|
*
|
|
* const columns = [
|
|
* { prop: "name", header: "user name" },
|
|
* "age",
|
|
* ];
|
|
*
|
|
* assertEquals(
|
|
* stringify(data, { columns }),
|
|
* `user name,age\r\nRick,70\r\nMorty,14\r\n`,
|
|
* );
|
|
* ```
|
|
*
|
|
* @example Give an array of objects with nested property and specify columns
|
|
* ```ts
|
|
* import {
|
|
* Column,
|
|
* stringify,
|
|
* } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* {
|
|
* age: 70,
|
|
* name: {
|
|
* first: "Rick",
|
|
* last: "Sanchez",
|
|
* },
|
|
* },
|
|
* {
|
|
* age: 14,
|
|
* name: {
|
|
* first: "Morty",
|
|
* last: "Smith",
|
|
* },
|
|
* },
|
|
* ];
|
|
*
|
|
* const columns: Column[] = [
|
|
* ["name", "first"],
|
|
* "age",
|
|
* ];
|
|
*
|
|
* assertEquals(
|
|
* stringify(data, { columns }),
|
|
* `first,age\r\nRick,70\r\nMorty,14\r\n`,
|
|
* );
|
|
* ```
|
|
*
|
|
* @example Give an array of objects with nested property and specify columns
|
|
* with renaming
|
|
* ```ts
|
|
* import {
|
|
* Column,
|
|
* stringify,
|
|
* } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* {
|
|
* age: 70,
|
|
* name: {
|
|
* first: "Rick",
|
|
* last: "Sanchez",
|
|
* },
|
|
* },
|
|
* {
|
|
* age: 14,
|
|
* name: {
|
|
* first: "Morty",
|
|
* last: "Smith",
|
|
* },
|
|
* },
|
|
* ];
|
|
*
|
|
* const columns: Column[] = [
|
|
* { prop: ["name", "first"], header: "first name" },
|
|
* "age",
|
|
* ];
|
|
*
|
|
* assertEquals(
|
|
* stringify(data, { columns }),
|
|
* `first name,age\r\nRick,70\r\nMorty,14\r\n`,
|
|
* );
|
|
* ```
|
|
*
|
|
* @example Give an array of string arrays and specify columns with renaming
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* ["Rick", 70],
|
|
* ["Morty", 14],
|
|
* ];
|
|
*
|
|
* const columns = [
|
|
* { prop: 0, header: "name" },
|
|
* { prop: 1, header: "age" },
|
|
* ];
|
|
*
|
|
* assertEquals(
|
|
* stringify(data, { columns }),
|
|
* `name,age\r\nRick,70\r\nMorty,14\r\n`,
|
|
* );
|
|
* ```
|
|
*
|
|
* @example Emit TSV (tab-separated values) with `separator: "\t"`
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [
|
|
* ["Rick", 70],
|
|
* ["Morty", 14],
|
|
* ];
|
|
*
|
|
* assertEquals(stringify(data, { separator: "\t" }), `Rick\t70\r\nMorty\t14\r\n`);
|
|
* ```
|
|
*
|
|
* @example Prepend a byte-order mark with `bom: true`
|
|
* ```ts
|
|
* import { stringify } from "@std/csv/stringify";
|
|
* import { assertEquals } from "@std/assert/equals";
|
|
*
|
|
* const data = [["Rick", 70]];
|
|
*
|
|
* assertEquals(stringify(data, { bom: true }), "\ufeffRick,70\r\n");
|
|
* ```
|
|
*
|
|
* @param data The source data to stringify. It's an array of items which are
|
|
* plain objects or arrays.
|
|
* @param options Options for the stringification.
|
|
* @returns A CSV string.
|
|
*/
|
|
export function stringify(
|
|
data: readonly DataItem[],
|
|
options?: StringifyOptions,
|
|
): string {
|
|
const { headers = true, separator: sep = ",", columns = [], bom = false } =
|
|
options ?? {};
|
|
|
|
if (sep.includes(QUOTE) || sep.includes(CRLF)) {
|
|
const message = [
|
|
"Separator cannot include the following strings:",
|
|
' - U+0022: Quotation mark (")',
|
|
" - U+000D U+000A: Carriage Return + Line Feed (\\r\\n)",
|
|
].join("\n");
|
|
throw new TypeError(message);
|
|
}
|
|
|
|
const normalizedColumns = columns.map(normalizeColumn);
|
|
let output = "";
|
|
|
|
if (bom) {
|
|
output += BYTE_ORDER_MARK;
|
|
}
|
|
|
|
if (headers && normalizedColumns.length > 0) {
|
|
output += normalizedColumns
|
|
.map((column) => getEscapedString(column.header, sep))
|
|
.join(sep);
|
|
output += CRLF;
|
|
}
|
|
|
|
for (const item of data) {
|
|
const values = getValuesFromItem(item, normalizedColumns);
|
|
output += values
|
|
.map((value) => getEscapedString(value, sep))
|
|
.join(sep);
|
|
output += CRLF;
|
|
}
|
|
|
|
return output;
|
|
}
|