json-2-csv
Version:
A JSON to CSV and CSV to JSON converter that natively supports sub-documents and auto-generates the CSV heading.
299 lines (258 loc) • 8.53 kB
JavaScript
;
let path = require('doc-path'),
constants = require('./constants.json');
const dateStringRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
module.exports = {
isStringRepresentation,
isDateRepresentation,
computeSchemaDifferences,
deepCopy,
convert,
isEmptyField,
removeEmptyFields,
getNCharacters,
unwind,
isInvalid,
// underscore replacements:
isString,
isNull,
isError,
isDate,
isUndefined,
isObject,
unique,
flatten
};
/**
* Build the options to be passed to the appropriate function
* If a user does not provide custom options, then we use our default
* If options are provided, then we set each valid key that was passed
* @param opts {Object} options object
* @return {Object} options object
*/
function buildOptions(opts) {
opts = {...constants.defaultOptions, ...opts || {}};
// Note: Object.assign does a shallow default, we need to deep copy the delimiter object
opts.delimiter = {...constants.defaultOptions.delimiter, ...opts.delimiter};
// Otherwise, send the options back
return opts;
}
/**
* When promisified, the callback and options argument ordering is swapped, so
* this function is intended to determine which argument is which and return
* them in the correct order
* @param arg1 {Object|Function} options or callback
* @param arg2 {Object|Function} options or callback
*/
function parseArguments(arg1, arg2) {
// If this was promisified (callback and opts are swapped) then fix the argument order.
if (isObject(arg1) && !isFunction(arg1)) {
return {
options: arg1,
callback: arg2
};
}
// Regular ordering where the callback is provided before the options object
return {
options: arg2,
callback: arg1
};
}
/**
* Validates the parameters passed in to json2csv and csv2json
* @param config {Object} of the form: { data: {Any}, callback: {Function}, dataCheckFn: Function, errorMessages: {Object} }
*/
function validateParameters(config) {
// If a callback wasn't provided, throw an error
if (!config.callback) {
throw new Error(constants.errors.callbackRequired);
}
// If we don't receive data, report an error
if (!config.data) {
config.callback(new Error(config.errorMessages.cannotCallOn + config.data + '.'));
return false;
}
// The data provided data does not meet the type check requirement
if (!config.dataCheckFn(config.data)) {
config.callback(new Error(config.errorMessages.dataCheckFailure));
return false;
}
// If we didn't hit any known error conditions, then the data is so far determined to be valid
// Note: json2csv/csv2json may perform additional validity checks on the data
return true;
}
/**
* Abstracted function to perform the conversion of json-->csv or csv-->json
* depending on the converter class that is passed via the params object
* @param params {Object}
*/
function convert(params) {
let {options, callback} = parseArguments(params.callback, params.options);
options = buildOptions(options);
let converter = new params.converter(options),
// Validate the parameters before calling the converter's convert function
valid = validateParameters({
data: params.data,
callback,
errorMessages: converter.validationMessages,
dataCheckFn: converter.validationFn
});
if (valid) converter.convert(params.data, callback);
}
/**
* Utility function to deep copy an object, used by the module tests
* @param obj
* @returns {any}
*/
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* Helper function that determines whether the provided value is a representation
* of a string. Given the RFC4180 requirements, that means that the value is
* wrapped in value wrap delimiters (usually a quotation mark on each side).
* @param fieldValue
* @param options
* @returns {boolean}
*/
function isStringRepresentation(fieldValue, options) {
const firstChar = fieldValue[0],
lastIndex = fieldValue.length - 1,
lastChar = fieldValue[lastIndex];
// If the field starts and ends with a wrap delimiter
return firstChar === options.delimiter.wrap && lastChar === options.delimiter.wrap;
}
/**
* Helper function that determines whether the provided value is a representation
* of a date.
* @param fieldValue
* @returns {boolean}
*/
function isDateRepresentation(fieldValue) {
return dateStringRegex.test(fieldValue);
}
/**
* Helper function that determines the schema differences between two objects.
* @param schemaA
* @param schemaB
* @returns {*}
*/
function computeSchemaDifferences(schemaA, schemaB) {
return arrayDifference(schemaA, schemaB)
.concat(arrayDifference(schemaB, schemaA));
}
/**
* Utility function to check if a field is considered empty so that the emptyFieldValue can be used instead
* @param fieldValue
* @returns {boolean}
*/
function isEmptyField(fieldValue) {
return isUndefined(fieldValue) || isNull(fieldValue) || fieldValue === '';
}
/**
* Helper function that removes empty field values from an array.
* @param fields
* @returns {Array}
*/
function removeEmptyFields(fields) {
return fields.filter((field) => !isEmptyField(field));
}
/**
* Helper function that retrieves the next n characters from the start index in
* the string including the character at the start index. This is used to
* check if are currently at an EOL value, since it could be multiple
* characters in length (eg. '\r\n')
* @param str
* @param start
* @param n
* @returns {string}
*/
function getNCharacters(str, start, n) {
return str.substring(start, start + n);
}
/**
* The following unwind functionality is a heavily modified version of @edwincen's
* unwind extension for lodash. Since lodash is a large package to require in,
* and all of the required functionality was already being imported, either
* natively or with doc-path, I decided to rewrite the majority of the logic
* so that an additional dependency would not be required. The original code
* with the lodash dependency can be found here:
*
* https://github.com/edwincen/unwind/blob/master/index.js
*/
/**
* Core function that unwinds an item at the provided path
* @param accumulator {Array<any>}
* @param item {any}
* @param fieldPath {String}
*/
function unwindItem(accumulator, item, fieldPath) {
const valueToUnwind = path.evaluatePath(item, fieldPath);
let cloned = deepCopy(item);
if (Array.isArray(valueToUnwind) && valueToUnwind.length) {
valueToUnwind.forEach((val) => {
cloned = deepCopy(item);
accumulator.push(path.setPath(cloned, fieldPath, val));
});
} else if (Array.isArray(valueToUnwind) && valueToUnwind.length === 0) {
// Push an empty string so the value is empty since there are no values
path.setPath(cloned, fieldPath, '');
accumulator.push(cloned);
} else {
accumulator.push(cloned);
}
}
/**
* Main unwind function which takes an array and a field to unwind.
* @param array {Array<any>}
* @param field {String}
* @returns {Array<any>}
*/
function unwind(array, field) {
const result = [];
array.forEach((item) => {
unwindItem(result, item, field);
});
return result;
}
/*
* Helper functions which were created to remove underscorejs from this package.
*/
function isString(value) {
return typeof value === 'string';
}
function isObject(value) {
return typeof value === 'object';
}
function isFunction(value) {
return typeof value === 'function';
}
function isNull(value) {
return value === null;
}
function isDate(value) {
return value instanceof Date;
}
function isUndefined(value) {
return typeof value === 'undefined';
}
function isError(value) {
return Object.prototype.toString.call(value) === '[object Error]';
}
function arrayDifference(a, b) {
return a.filter((x) => !b.includes(x));
}
function unique(array) {
return [...new Set(array)];
}
function flatten(array) {
return [].concat(...array);
}
/**
* Used to help avoid incorrect values returned by JSON.parse when converting
* CSV back to JSON, such as '39e1804' which JSON.parse converts to Infinity
*/
function isInvalid(parsedJson) {
return parsedJson === Infinity ||
parsedJson === -Infinity;
}