diff options
Diffstat (limited to 'std/encoding/csv_stringify.ts')
-rw-r--r-- | std/encoding/csv_stringify.ts | 172 |
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; +} |