summaryrefslogtreecommitdiff
path: root/std/encoding/csv_stringify.ts
diff options
context:
space:
mode:
Diffstat (limited to 'std/encoding/csv_stringify.ts')
-rw-r--r--std/encoding/csv_stringify.ts172
1 files changed, 172 insertions, 0 deletions
diff --git a/std/encoding/csv_stringify.ts b/std/encoding/csv_stringify.ts
new file mode 100644
index 000000000..4c5f8c816
--- /dev/null
+++ b/std/encoding/csv_stringify.ts
@@ -0,0 +1,172 @@
+// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
+// Implements the CSV spec at https://tools.ietf.org/html/rfc4180
+
+/** This module is browser compatible. */
+
+const QUOTE = '"';
+export const NEWLINE = "\r\n";
+
+export class StringifyError extends Error {
+ readonly name = "StringifyError";
+}
+
+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(NEWLINE) || str.includes(QUOTE)) {
+ return `${QUOTE}${str.replaceAll(QUOTE, `${QUOTE}${QUOTE}`)}${QUOTE}`;
+ }
+
+ return str;
+}
+
+type PropertyAccessor = number | string;
+
+/**
+ * @param fn Optional callback for transforming the value
+ *
+ * @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 = {
+ // "unknown" is more type-safe, but inconvenient for user. How to resolve?
+ // deno-lint-ignore no-explicit-any
+ fn?: (value: any) => string | Promise<string>;
+ header?: string;
+ prop: PropertyAccessor | PropertyAccessor[];
+};
+
+export type Column = ColumnDetails | PropertyAccessor | PropertyAccessor[];
+
+type NormalizedColumn = Omit<ColumnDetails, "header" | "prop"> & {
+ header: string;
+ prop: PropertyAccessor[];
+};
+
+function normalizeColumn(column: Column): NormalizedColumn {
+ let fn: NormalizedColumn["fn"],
+ header: NormalizedColumn["header"],
+ prop: NormalizedColumn["prop"];
+
+ if (typeof column === "object") {
+ if (Array.isArray(column)) {
+ header = String(column[column.length - 1]);
+ prop = column;
+ } else {
+ ({ fn } = column);
+ 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 { fn, header, prop };
+}
+
+type ObjectWithStringPropertyKeys = Record<string, unknown>;
+
+/** An object (plain or array) */
+export type DataItem = ObjectWithStringPropertyKeys | unknown[];
+
+/**
+ * Returns an array of values from an object using the property accessors
+ * (and optional transform function) in each column
+ */
+async function getValuesFromItem(
+ item: DataItem,
+ normalizedColumns: NormalizedColumn[],
+): Promise<unknown[]> {
+ const values: unknown[] = [];
+
+ 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 StringifyError('Property accessor is not of type "number"');
+ }
+ } // I think this assertion is safe. Confirm?
+ else value = (value as ObjectWithStringPropertyKeys)[prop];
+ }
+
+ if (typeof column.fn === "function") value = await column.fn(value);
+ values.push(value);
+ }
+
+ return values;
+}
+
+/**
+ * @param headers Whether or not to include the row of headers.
+ * Default: `true`
+ *
+ * @param separator Delimiter used to separate values. Examples:
+ * - `","` _comma_ (Default)
+ * - `"\t"` _tab_
+ * - `"|"` _pipe_
+ * - etc.
+ */
+export type StringifyOptions = {
+ headers?: boolean;
+ separator?: string;
+};
+
+/**
+ * @param data The array of objects to encode
+ * @param columns Array of values specifying which data to include in the output
+ * @param options Output formatting options
+ */
+export async function stringify(
+ data: DataItem[],
+ columns: Column[],
+ options: StringifyOptions = {},
+): Promise<string> {
+ const { headers, separator: sep } = {
+ headers: true,
+ separator: ",",
+ ...options,
+ };
+ if (sep.includes(QUOTE) || sep.includes(NEWLINE)) {
+ 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 StringifyError(message);
+ }
+
+ const normalizedColumns = columns.map(normalizeColumn);
+ let output = "";
+
+ if (headers) {
+ output += normalizedColumns
+ .map((column) => getEscapedString(column.header, sep))
+ .join(sep);
+ output += NEWLINE;
+ }
+
+ for (const item of data) {
+ const values = await getValuesFromItem(item, normalizedColumns);
+ output += values
+ .map((value) => getEscapedString(value, sep))
+ .join(sep);
+ output += NEWLINE;
+ }
+
+ return output;
+}