Skip to content

Commit e1ed4ec

Browse files
committed
Merge pull request #15 from mrodrig/fix-schema-ordering
Fixing issue #12, #13
2 parents b465714 + c520665 commit e1ed4ec

12 files changed

+318
-194
lines changed

README.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ _Note_: This requires `mocha`, `should`, `async`, and `underscore`.
137137
## Features
138138

139139
- Header Generation (per document keys)
140-
- Verifies all documents have same schema
140+
- Verifies all documents have same schema (schema field order does not matter as of 1.1.0)
141141
- Supports sub-documents natively
142142
- Supports arrays as document values for both json2csv and csv2json
143143
- Custom ordering of columns (see F.A.Q. for more information)
@@ -151,9 +151,4 @@ __Yes.__ Currently, changing the order of the keys in the JSON document will als
151151

152152
## Milestones
153153
- Created: Apr 23, 2014
154-
- 1K Downloads/Month: January 15, 2015
155-
156-
## TODO
157-
- Use PARSE_CSV_NUMBERS option to actually convert numbers. Not currently implemented.
158-
- Respect nested arrays when in json2csv - Currently flattens them
159-
- If quotes in CSV header, strip them? Add as an option?
154+
- 1K Downloads/Month: January 15, 2015

bower.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "json-2-csv",
3-
"version": "1.0.7",
3+
"version": "1.1.0",
44
"homepage": "https://github.com/mrodrig/json-2-csv",
55
"moduleType": [
66
"node"
@@ -15,6 +15,6 @@
1515
],
1616
"dependencies": {
1717
"underscore": "1.6.0",
18-
"async": "0.2.10"
18+
"bluebird": "2.9.24"
1919
}
2020
}

lib/converter.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ var json2Csv = require('./json-2-csv'), // Require our json-2-csv code
44
csv2Json = require('./csv-2-json'), // Require our csv-2-json code
55
_ = require('underscore'); // Require underscore
66

7-
// Default options; By using a function this is essentially a 'static' variable
7+
/**
8+
* Default options
9+
*/
810
var defaultOptions = {
911
DELIMITER : {
1012
FIELD : ',',
@@ -15,16 +17,19 @@ var defaultOptions = {
1517
PARSE_CSV_NUMBERS : false
1618
};
1719

18-
// Build the options to be passed to the appropriate function
19-
// If a user does not provide custom options, then we use our default
20-
// If options are provided, then we set each valid key that was passed
20+
/**
21+
* Build the options to be passed to the appropriate function
22+
* If a user does not provide custom options, then we use our default
23+
* If options are provided, then we set each valid key that was passed
24+
*/
2125
var buildOptions = function (opts, cb) {
22-
opts = opts ? opts : {}; // If undefined, set to an empty doc
23-
var out = _.defaults(opts, defaultOptions);
26+
opts = _.defaults(opts || {}, defaultOptions);
27+
// Note: _.defaults does a shallow default, we need to deep copy the DELIMITER object
28+
opts.DELIMITER = _.defaults(opts.DELIMITER || {}, defaultOptions.DELIMITER);
2429
// If the delimiter fields are the same, report an error to the caller
25-
if (out.DELIMITER.FIELD === out.DELIMITER.ARRAY) { return cb(new Error('The field and array delimiters must differ.')); }
30+
if (opts.DELIMITER.FIELD === opts.DELIMITER.ARRAY) { return cb(new Error('The field and array delimiters must differ.')); }
2631
// Otherwise, send the options back
27-
else { return cb(null, out); }
32+
else { return cb(null, opts); }
2833
};
2934

3035
// Export the following functions that will be client accessible

lib/csv-2-json.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict';
22

3-
var _ = require('underscore'),
4-
async = require('async');
3+
var _ = require('underscore');
54

65
var options = {}; // Initialize the options - this will be populated when the csv2json function is called.
76

@@ -34,7 +33,7 @@ var addNestedKey = function (key, value, doc) {
3433

3534
// Helper function to check if the given key already exists in the given document
3635
var keyExists = function (key, doc) {
37-
return (typeof doc[key] !== 'undefined'); // If the key doesn't exist, then the type is 'undefined'
36+
return (!_.isUndefined(doc[key])); // If the key doesn't exist, then the type is 'undefined'
3837
};
3938

4039
var isArrayRepresentation = function (value) {
@@ -96,7 +95,7 @@ module.exports = {
9695
if (!opts) { callback(new Error('Options were not passed and are required.')); return null; } // Shouldn't happen, but just in case
9796
else { options = opts; } // Options were passed, set the global options value
9897
if (!data) { callback(new Error('Cannot call csv2json on ' + data + '.')); return null; } // If we don't receive data, report an error
99-
if (typeof data !== 'string') { // The data is not a string
98+
if (!_.isString(data)) { // The data is not a string
10099
callback(new Error("CSV is not a string.")); // Report an error back to the caller
101100
}
102101
var lines = data.split(options.EOL); // Split the CSV into lines using the specified EOL option

lib/json-2-csv.js

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,95 @@
11
'use strict';
22

33
var _ = require('underscore'),
4-
async = require('async');
4+
Promise = require('bluebird');
55

6-
var options = {}; // Initialize the options - this will be populated when the csv2json function is called.
6+
var options = {}; // Initialize the options - this will be populated when the json2csv function is called.
7+
8+
// Retrieve the headings for all documents and return it. This checks that all documents have the same schema.
9+
var generateHeading = function(data) {
10+
return new Promise(function (resolve, reject) {
11+
var keys = _.map(_.keys(data), function (key, indx) { // for each key
12+
if (_.isObject(data[key])) {
13+
// if the data at the key is a document, then we retrieve the subHeading starting with an empty string heading and the doc
14+
return generateSubHeading('', data[key]);
15+
}
16+
return key;
17+
});
18+
19+
// Check for a consistent schema that does not require the same order:
20+
// if we only have one document - then there is no possiblility of multiple schemas
21+
if (keys && keys.length <= 1) {
22+
return resolve(_.flatten(keys) || []);
23+
}
24+
// else - multiple documents - ensure only one schema (regardless of field ordering)
25+
var firstDocSchema = _.flatten(keys[0]);
26+
_.each(keys, function (keyList) {
27+
// If there is a difference between the schemas, throw the inconsistent schema error
28+
var diff = _.difference(firstDocSchema, _.flatten(keyList));
29+
if (!_.isEqual(diff, [])) {
30+
return reject(new Error('Not all documents have the same schema.'));
31+
}
32+
});
33+
return resolve(_.flatten(keys[0]));
34+
});
35+
};
736

837
// Takes the parent heading and this doc's data and creates the subdocument headings (string)
9-
var retrieveSubHeading = function (heading, data) {
10-
var subKeys = _.keys(data), // retrieve the keys from the current document
11-
newKey; // temporary variable to aid in determining the heading - used to generate the 'nested' headings
12-
_.each(subKeys, function (subKey, indx) {
38+
var generateSubHeading = function(heading, data) {
39+
var subKeys, // retrieve the keys from the current document
40+
newKey = ''; // temporary variable to aid in determining the heading - used to generate the 'nested' headings
41+
42+
subKeys = _.map(_.keys(data), function (subKey) {
1343
// If the given heading is empty, then we set the heading to be the subKey, otherwise set it as a nested heading w/ a dot
1444
newKey = heading === '' ? subKey : heading + '.' + subKey;
15-
if (typeof data[subKey] === 'object' && data[subKey] !== null && typeof data[subKey].length === 'undefined' && _.keys(data[subKey]).length > 0) { // If we have another nested document
16-
subKeys[indx] = retrieveSubHeading(newKey, data[subKey]); // Recur on the subdocument to retrieve the full key name
45+
if (_.isObject(data[subKey]) && !_.isNull(data[subKey]) && _.isUndefined(data[subKey].length) && _.keys(data[subKey]).length > 0) { // If we have another nested document
46+
return generateSubHeading(newKey, data[subKey]); // Recur on the sub-document to retrieve the full key name
1747
} else {
18-
subKeys[indx] = (options.DELIMITER.WRAP || '') + (newKey || '') + (options.DELIMITER.WRAP || ''); // Set the key name since we don't have a sub document
48+
return newKey; // Set the key name since we don't have a sub document
1949
}
2050
});
21-
return subKeys.join(options.DELIMITER.FIELD); // Return the headings joined by our field delimiter
22-
};
2351

24-
// Retrieve the headings for all documents and return it. This checks that all documents have the same schema.
25-
var retrieveHeading = function (data) {
26-
return function (cb) { // Returns a function that takes a callback - the function is passed to async.parallel
27-
var keys = _.keys(data); // Retrieve the current data keys
28-
_.each(keys, function (key, indx) { // for each key
29-
if (typeof data[key] === 'object') {
30-
// if the data at the key is a document, then we retrieve the subHeading starting with an empty string heading and the doc
31-
keys[indx] = retrieveSubHeading('', data[key]);
32-
}
33-
});
34-
// Retrieve the unique array of headings (keys)
35-
keys = _.uniq(keys);
36-
// If we have more than 1 unique list, then not all docs have the same schema - report an error
37-
if (keys.length > 1) { throw new Error('Not all documents have the same schema.', keys); }
38-
return cb(null, _.flatten(keys).join(options.DELIMITER.FIELD)); // Return headings back
39-
};
52+
return subKeys; // Return the headings joined by our field delimiter
4053
};
4154

4255
// Convert the given data with the given keys
4356
var convertData = function (data, keys) {
4457
var output = [], // Array of CSV representing converted docs
4558
value; // Temporary variable to store the current data
46-
_.each(keys, function (key, indx) { // For each key
47-
value = data[key]; // Set the current data that we are looking at
48-
if (keys.indexOf(key) > -1) { // If the keys contain the current key, then process the data
49-
if (_.isArray(value)) { // We have an array of values
50-
output.push((options.DELIMITER.WRAP || '') + '[' + value.join(options.DELIMITER.ARRAY) + ']' + (options.DELIMITER.WRAP || ''));
51-
} else if (_.isDate(value)) { // If we have a date
52-
output.push(value.toString());
53-
} else if (_.isObject(value)) { // If we have an object
54-
output.push(convertData(value, _.keys(value))); // Push the recursively generated CSV
55-
} else {
56-
value = value == null ? '' : value.toString();
57-
output.push((options.DELIMITER.WRAP || '') + value + (options.DELIMITER.WRAP || '')); // Otherwise push the current value
58-
}
59+
60+
_.each(keys, function (key) { // For each key
61+
var indexOfPeriod = _.indexOf(key, '.');
62+
if (indexOfPeriod > -1) {
63+
var pathPrefix = key.slice(0, indexOfPeriod),
64+
pathRemainder = key.slice(indexOfPeriod+1);
65+
output.push(convertData(data[pathPrefix], [pathRemainder]));
66+
} else if (keys.indexOf(key) > -1) { // If the keys contain the current key, then process the data
67+
value = data[key]; // Set the current data that we are looking at
68+
convertField(value, output);
5969
}
6070
});
61-
return output.join(options.DELIMITER.FIELD); // Return the data joined by our field delimiter
71+
return output; // Return the data joined by our field delimiter
72+
};
73+
74+
var convertField = function (value, output) {
75+
if (_.isArray(value)) { // We have an array of values
76+
output.push(options.DELIMITER.WRAP + '[' + value.join(options.DELIMITER.ARRAY) + ']' + options.DELIMITER.WRAP);
77+
} else if (_.isDate(value)) { // If we have a date
78+
output.push(value.toString());
79+
} else if (_.isObject(value)) { // If we have an object
80+
output.push(convertData(value, _.keys(value))); // Push the recursively generated CSV
81+
} else {
82+
value = value === null ? '' : value.toString();
83+
output.push(options.DELIMITER.WRAP + value + options.DELIMITER.WRAP); // Otherwise push the current value
84+
}
6285
};
6386

6487
// Generate the CSV representing the given data.
65-
var generateCsv = function (data) {
66-
return function (cb) { // Returns a function that takes a callback - the function is passed to async.parallel
67-
// Reduce each JSON document in data to a CSV string and append it to the CSV accumulator
68-
return cb(null, _.reduce(data, function (csv, doc) { return csv += convertData(doc, _.keys(doc)) + options.EOL; }, ''));
69-
};
88+
var generateCsv = function (data, headingKeys) {
89+
// Reduce each JSON document in data to a CSV string and append it to the CSV accumulator
90+
return Promise.resolve([headingKeys, _.reduce(data, function (csv, doc) {
91+
return csv += _.flatten(convertData(doc, headingKeys)).join(options.DELIMITER.FIELD) + options.EOL;
92+
}, '')]);
7093
};
7194

7295
module.exports = {
@@ -75,23 +98,33 @@ module.exports = {
7598
// Takes options as a document, data as a JSON document array, and a callback that will be used to report the results
7699
json2csv: function (opts, data, callback) {
77100
if (!callback) { throw new Error('A callback is required!'); } // If a callback wasn't provided, throw an error
78-
if (!opts) { callback(new Error('Options were not passed and are required.')); return null; } // Shouldn't happen, but just in case
101+
102+
if (!opts) { return callback(new Error('Options were not passed and are required.')); } // Shouldn't happen, but just in case
79103
else { options = opts; } // Options were passed, set the global options value
80-
if (!data) { callback(new Error('Cannot call json2csv on ' + data + '.')); return null; } // If we don't receive data, report an error
81-
if (typeof data !== 'object') { // If the data was not a single document or an array of documents
82-
return cb(new Error('Data provided was not an array of documents.')); // Report the error back to the caller
83-
} else if (typeof data === 'object' && !data.length) { // Single document, not an array
104+
105+
if (!data) { return callback(new Error('Cannot call json2csv on ' + data + '.')); } // If we don't receive data, report an error
106+
107+
if (!_.isObject(data)) { // If the data was not a single document or an array of documents
108+
return callback(new Error('Data provided was not an array of documents.')); // Report the error back to the caller
109+
} else if (_.isObject(data) && !data.length) { // Single document, not an array
84110
data = [data]; // Convert to an array of the given document
85111
}
86-
// Retrieve the heading and the CSV asynchronously in parallel
87-
async.parallel([retrieveHeading(data), generateCsv(data)], function (err, res) {
88-
if (!err) {
89-
// Data received with no errors, join the two responses with an end of line delimiter to setup heading and CSV body
90-
return callback(null, res.join(options.EOL));
91-
} else {
92-
return callback(err, null); // Report received error back to caller
93-
}
94-
});
112+
113+
// Retrieve the heading and then generate the CSV with the keys that are identified
114+
generateHeading(data)
115+
.then(_.partial(generateCsv, data))
116+
.spread(function (csvHeading, csvData) {
117+
if (options.DELIMITER.WRAP) {
118+
csvHeading = _.map(csvHeading, function(headingKey) {
119+
return options.DELIMITER.WRAP + headingKey + options.DELIMITER.WRAP;
120+
});
121+
}
122+
csvHeading = csvHeading.join(options.DELIMITER.FIELD);
123+
return callback(null, [csvHeading, csvData].join(options.EOL));
124+
})
125+
.catch(function (err) {
126+
return callback(err);
127+
});
95128
}
96129

97130
};

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"author": "mrodrig",
33
"name": "json-2-csv",
44
"description": "A JSON to CSV and CSV to JSON converter that natively supports sub-documents and auto-generates the CSV heading.",
5-
"version": "1.0.8",
5+
"version": "1.1.0",
66
"repository": {
77
"type": "git",
88
"url": "http://github.com/mrodrig/json-2-csv.git"
@@ -24,7 +24,7 @@
2424
],
2525
"dependencies": {
2626
"underscore": "1.6.0",
27-
"async": "0.2.10"
27+
"bluebird": "~2.9.24"
2828
},
2929
"devDependencies": {
3030
"mocha": "~1.14.0",
@@ -35,4 +35,4 @@
3535
"node": "*"
3636
},
3737
"license": "MIT"
38-
}
38+
}

test/JSON/differentSchemas.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
{ "carModel" : "Audi", "price" : "10000", "color" : "blue", "mileage" : "7200" },
3+
{ "carModel" : "BMW", "price" : "15000", "color" : "red" },
4+
{ "carModel" : "Mercedes", "price" : "20000", "color" : "yellow" },
5+
{ "carModel" : "Porsche", "price" : "30000", "color" : "green" }
6+
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
{ "carModel" : "Audi", "price" : "10000", "color" : "blue" },
3+
{ "carModel" : "BMW", "color" : "red", "price" : "15000" },
4+
{ "price" : "20000", "color" : "yellow", "carModel" : "Mercedes" },
5+
{ "carModel" : "Porsche", "price" : "30000", "color" : "green" }
6+
]

0 commit comments

Comments
 (0)