jtcsv 2.1.5 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/json-to-csv.js CHANGED
@@ -16,6 +16,8 @@ const {
16
16
  safeExecute
17
17
  } = require('./errors');
18
18
 
19
+ // Add schema validator import
20
+ const { createSchemaValidators } = require('./src/utils/schema-validator');
19
21
  /**
20
22
  * Validates input data and options
21
23
  * @private
@@ -62,6 +64,11 @@ function validateInput(data, options) {
62
64
  throw new ConfigurationError('rfc4180Compliant must be a boolean');
63
65
  }
64
66
 
67
+ // Validate schema
68
+ if (options?.schema && typeof options.schema !== 'object') {
69
+ throw new ConfigurationError('schema must be an object');
70
+ }
71
+
65
72
  return true;
66
73
  }
67
74
 
@@ -77,6 +84,7 @@ function validateInput(data, options) {
77
84
  * @param {number} [options.maxRecords] - Maximum number of records to process (optional, no limit by default)
78
85
  * @param {boolean} [options.preventCsvInjection=true] - Prevent CSV injection attacks by escaping formulas
79
86
  * @param {boolean} [options.rfc4180Compliant=true] - Ensure RFC 4180 compliance (proper quoting, line endings)
87
+ * @param {Object} [options.schema] - JSON schema for data validation and formatting
80
88
  * @returns {string} CSV formatted string
81
89
  *
82
90
  * @example
@@ -108,9 +116,16 @@ function jsonToCsv(data, options = {}) {
108
116
  template = {},
109
117
  maxRecords,
110
118
  preventCsvInjection = true,
111
- rfc4180Compliant = true
119
+ rfc4180Compliant = true,
120
+ schema = null
112
121
  } = opts;
113
122
 
123
+ // Initialize schema validators if schema is provided
124
+ let schemaValidators = null;
125
+ if (schema) {
126
+ schemaValidators = createSchemaValidators(schema);
127
+ }
128
+
114
129
  // Handle empty data
115
130
  if (data.length === 0) {
116
131
  return '';
@@ -135,68 +150,68 @@ function jsonToCsv(data, options = {}) {
135
150
  );
136
151
  }
137
152
 
138
- // Get all unique keys from all objects with minimal allocations.
139
- const allKeys = new Set();
140
- const originalKeys = [];
141
- for (let i = 0; i < data.length; i++) {
142
- const item = data[i];
143
- if (!item || typeof item !== 'object') {
144
- continue;
145
- }
146
- for (const key in item) {
147
- if (Object.prototype.hasOwnProperty.call(item, key) && !allKeys.has(key)) {
148
- allKeys.add(key);
149
- originalKeys.push(key);
150
- }
151
- }
152
- }
153
-
154
- const hasRenameMap = Object.keys(renameMap).length > 0;
155
- const hasTemplate = Object.keys(template).length > 0;
156
-
157
- // Apply rename map to create header names.
158
- let headers = originalKeys;
159
- let reverseRenameMap = null;
160
- if (hasRenameMap) {
161
- headers = new Array(originalKeys.length);
162
- reverseRenameMap = {};
163
- for (let i = 0; i < originalKeys.length; i++) {
164
- const key = originalKeys[i];
165
- const header = renameMap[key] || key;
166
- headers[i] = header;
167
- reverseRenameMap[header] = key;
168
- }
169
- }
170
-
171
- // Apply template ordering if provided.
172
- let finalHeaders = headers;
173
- if (hasTemplate) {
174
- const templateKeys = Object.keys(template);
175
- const templateHeaders = hasRenameMap
176
- ? templateKeys.map(key => renameMap[key] || key)
177
- : templateKeys;
178
- const templateHeaderSet = new Set(templateHeaders);
179
- const extraHeaders = [];
180
- for (let i = 0; i < headers.length; i++) {
181
- const header = headers[i];
182
- if (!templateHeaderSet.has(header)) {
183
- extraHeaders.push(header);
184
- }
185
- }
186
- finalHeaders = templateHeaders.concat(extraHeaders);
187
- }
188
-
189
- const finalKeys = new Array(finalHeaders.length);
190
- if (hasRenameMap) {
191
- for (let i = 0; i < finalHeaders.length; i++) {
192
- const header = finalHeaders[i];
193
- finalKeys[i] = reverseRenameMap[header] || header;
194
- }
195
- } else {
196
- for (let i = 0; i < finalHeaders.length; i++) {
197
- finalKeys[i] = finalHeaders[i];
198
- }
199
- }
153
+ // Get all unique keys from all objects with minimal allocations.
154
+ const allKeys = new Set();
155
+ const originalKeys = [];
156
+ for (let i = 0; i < data.length; i++) {
157
+ const item = data[i];
158
+ if (!item || typeof item !== 'object') {
159
+ continue;
160
+ }
161
+ for (const key in item) {
162
+ if (Object.prototype.hasOwnProperty.call(item, key) && !allKeys.has(key)) {
163
+ allKeys.add(key);
164
+ originalKeys.push(key);
165
+ }
166
+ }
167
+ }
168
+
169
+ const hasRenameMap = Object.keys(renameMap).length > 0;
170
+ const hasTemplate = Object.keys(template).length > 0;
171
+
172
+ // Apply rename map to create header names.
173
+ let headers = originalKeys;
174
+ let reverseRenameMap = null;
175
+ if (hasRenameMap) {
176
+ headers = new Array(originalKeys.length);
177
+ reverseRenameMap = {};
178
+ for (let i = 0; i < originalKeys.length; i++) {
179
+ const key = originalKeys[i];
180
+ const header = renameMap[key] || key;
181
+ headers[i] = header;
182
+ reverseRenameMap[header] = key;
183
+ }
184
+ }
185
+
186
+ // Apply template ordering if provided.
187
+ let finalHeaders = headers;
188
+ if (hasTemplate) {
189
+ const templateKeys = Object.keys(template);
190
+ const templateHeaders = hasRenameMap
191
+ ? templateKeys.map(key => renameMap[key] || key)
192
+ : templateKeys;
193
+ const templateHeaderSet = new Set(templateHeaders);
194
+ const extraHeaders = [];
195
+ for (let i = 0; i < headers.length; i++) {
196
+ const header = headers[i];
197
+ if (!templateHeaderSet.has(header)) {
198
+ extraHeaders.push(header);
199
+ }
200
+ }
201
+ finalHeaders = templateHeaders.concat(extraHeaders);
202
+ }
203
+
204
+ const finalKeys = new Array(finalHeaders.length);
205
+ if (hasRenameMap) {
206
+ for (let i = 0; i < finalHeaders.length; i++) {
207
+ const header = finalHeaders[i];
208
+ finalKeys[i] = reverseRenameMap[header] || header;
209
+ }
210
+ } else {
211
+ for (let i = 0; i < finalHeaders.length; i++) {
212
+ finalKeys[i] = finalHeaders[i];
213
+ }
214
+ }
200
215
 
201
216
  /**
202
217
  * Escapes a value for CSV format with CSV injection protection
@@ -205,75 +220,100 @@ function jsonToCsv(data, options = {}) {
205
220
  * @param {*} value - The value to escape
206
221
  * @returns {string} Escaped CSV value
207
222
  */
208
- const quoteRegex = /"/g;
209
- const delimiterCode = delimiter.charCodeAt(0);
210
-
211
- const escapeValue = (value) => {
212
- if (value === null || value === undefined || value === '') {
213
- return '';
214
- }
215
-
216
- let stringValue = value;
217
- if (typeof stringValue !== 'string') {
218
- stringValue = String(stringValue);
219
- }
220
-
221
- // CSV Injection protection - escape formulas if enabled
222
- let escapedValue = stringValue;
223
- if (preventCsvInjection) {
224
- const firstCharCode = stringValue.charCodeAt(0);
225
- if (firstCharCode === 61 || firstCharCode === 43 || firstCharCode === 45 || firstCharCode === 64) {
226
- escapedValue = "'" + stringValue;
227
- }
228
- }
229
-
230
- let needsQuoting = false;
231
- let hasQuote = false;
232
- for (let i = 0; i < escapedValue.length; i++) {
233
- const code = escapedValue.charCodeAt(i);
234
- if (code === 34) {
235
- hasQuote = true;
236
- needsQuoting = true;
237
- } else if (code === delimiterCode || code === 10 || code === 13) {
238
- needsQuoting = true;
239
- }
240
- }
241
-
242
- if (needsQuoting) {
243
- const quotedValue = hasQuote ? escapedValue.replace(quoteRegex, '""') : escapedValue;
244
- return `"${quotedValue}"`;
245
- }
246
-
247
- return escapedValue;
248
- };
223
+ const quoteRegex = /"/g;
224
+ const delimiterCode = delimiter.charCodeAt(0);
225
+
226
+ const escapeValue = (value) => {
227
+ if (value === null || value === undefined || value === '') {
228
+ return '';
229
+ }
230
+
231
+ let stringValue = value;
232
+ if (typeof stringValue !== 'string') {
233
+ stringValue = String(stringValue);
234
+ }
235
+
236
+ // CSV Injection protection - escape formulas if enabled
237
+ let escapedValue = stringValue;
238
+ if (preventCsvInjection) {
239
+ const firstCharCode = stringValue.charCodeAt(0);
240
+ if (firstCharCode === 61 || firstCharCode === 43 || firstCharCode === 45 || firstCharCode === 64) {
241
+ escapedValue = "'" + stringValue;
242
+ }
243
+ }
244
+
245
+ let needsQuoting = false;
246
+ let hasQuote = false;
247
+ for (let i = 0; i < escapedValue.length; i++) {
248
+ const code = escapedValue.charCodeAt(i);
249
+ if (code === 34) {
250
+ hasQuote = true;
251
+ needsQuoting = true;
252
+ } else if (code === delimiterCode || code === 10 || code === 13) {
253
+ needsQuoting = true;
254
+ }
255
+ }
256
+
257
+ if (needsQuoting) {
258
+ const quotedValue = hasQuote ? escapedValue.replace(quoteRegex, '""') : escapedValue;
259
+ return `"${quotedValue}"`;
260
+ }
261
+
262
+ return escapedValue;
263
+ };
264
+
265
+ // Build CSV rows.
266
+ const rows = [];
267
+ const columnCount = finalHeaders.length;
268
+
269
+ // Add headers row if requested.
270
+ if (includeHeaders && columnCount > 0) {
271
+ rows.push(finalHeaders.join(delimiter));
272
+ }
273
+
274
+ // Add data rows.
275
+ const rowValues = new Array(columnCount);
276
+ for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
277
+ const item = data[rowIndex];
278
+ if (!item || typeof item !== 'object') {
279
+ continue;
280
+ }
281
+
282
+ // Apply schema validation and formatting if schema is provided
283
+ let processedItem = item;
284
+ if (schemaValidators) {
285
+ processedItem = { ...item };
286
+ for (const [key, validator] of Object.entries(schemaValidators)) {
287
+ if (key in processedItem) {
288
+ const value = processedItem[key];
289
+ // Validate value
290
+ if (!validator.validate(value)) {
291
+ throw new ValidationError(
292
+ `Row ${rowIndex + 1}: Value for field "${key}" does not match schema`
293
+ );
294
+ }
295
+ // Format value if formatter exists
296
+ if (validator.format) {
297
+ processedItem[key] = validator.format(value);
298
+ }
299
+ } else if (validator.required) {
300
+ throw new ValidationError(
301
+ `Row ${rowIndex + 1}: Required field "${key}" is missing`
302
+ );
303
+ }
304
+ }
305
+ }
306
+
307
+ for (let i = 0; i < columnCount; i++) {
308
+ rowValues[i] = escapeValue(processedItem[finalKeys[i]]);
309
+ }
310
+
311
+ rows.push(rowValues.join(delimiter));
312
+ }
249
313
 
250
- // Build CSV rows.
251
- const rows = [];
252
- const columnCount = finalHeaders.length;
253
-
254
- // Add headers row if requested.
255
- if (includeHeaders && columnCount > 0) {
256
- rows.push(finalHeaders.join(delimiter));
257
- }
258
-
259
- // Add data rows.
260
- const rowValues = new Array(columnCount);
261
- for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
262
- const item = data[rowIndex];
263
- if (!item || typeof item !== 'object') {
264
- continue;
265
- }
266
-
267
- for (let i = 0; i < columnCount; i++) {
268
- rowValues[i] = escapeValue(item[finalKeys[i]]);
269
- }
270
-
271
- rows.push(rowValues.join(delimiter));
272
- }
273
-
274
- // RFC 4180: Each record is located on a separate line, delimited by a line break (CRLF).
275
- const lineEnding = rfc4180Compliant ? '\r\n' : '\n';
276
- return rows.join(lineEnding);
314
+ // RFC 4180: Each record is located on a separate line, delimited by a line break (CRLF).
315
+ const lineEnding = rfc4180Compliant ? '\r\n' : '\n';
316
+ return rows.join(lineEnding);
277
317
  }, 'PARSE_FAILED', { function: 'jsonToCsv' });
278
318
  }
279
319
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jtcsv",
3
- "version": "2.1.5",
3
+ "version": "2.2.2",
4
4
  "description": "Complete JSON<->CSV and CSV<->JSON converter for Node.js and Browser with streaming, security, Web Workers, TypeScript support, and optional ecosystem (zero-deps core)",
5
5
  "main": "index.js",
6
6
  "browser": "dist/jtcsv.umd.js",
@@ -86,7 +86,12 @@
86
86
  "test:plugins": "jest __tests__/plugin-system.test.js",
87
87
  "test:fastpath": "jest __tests__/fast-path-engine.test.js",
88
88
  "test:ndjson": "jest __tests__/ndjson-parser.test.js",
89
- "test:performance": "jest __tests__/*.test.js --testNamePattern=\"\u041f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c|\u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c\"",
89
+ "test:performance": "jest __tests__/*.test.js --testNamePattern=\"Производительность|производительность\"",
90
+ "test:benchmark": "jest __tests__/benchmark-suite.test.js --testTimeout=60000",
91
+ "test:load": "jest __tests__/load-tests.test.js --testTimeout=300000",
92
+ "test:load:large": "LOAD_TEST_SIZE=large jest __tests__/load-tests.test.js --testTimeout=300000",
93
+ "test:security": "jest __tests__/security-fuzzing.test.js --testTimeout=60000",
94
+ "test:memory": "node --expose-gc node_modules/jest/bin/jest __tests__/memory-profiling.test.js --testTimeout=120000",
90
95
  "test:express": "cd plugins/express-middleware && npm test",
91
96
  "test:fastify": "cd plugins/fastify-plugin && npm test",
92
97
  "test:nextjs": "cd plugins/nextjs-api && npm test",
@@ -113,7 +118,10 @@
113
118
  "profile:perf": "node scripts/profile-performance.js",
114
119
  "example:plugins": "node examples/plugin-excel-exporter.js",
115
120
  "example:express": "cd plugins/express-middleware && node example.js",
116
- "plugins:build": "npm run build && cd plugins/express-middleware && npm run build && cd ../fastify-plugin && npm run build && cd ../nextjs-api && npm run build"
121
+ "plugins:build": "npm run build && cd plugins/express-middleware && npm run build && cd ../fastify-plugin && npm run build && cd ../nextjs-api && npm run build",
122
+ "docs": "typedoc",
123
+ "docs:watch": "typedoc --watch",
124
+ "docs:serve": "typedoc && npx serve docs/api"
117
125
  },
118
126
  "keywords": [
119
127
  "json",
@@ -175,7 +183,7 @@
175
183
  },
176
184
  "homepage": "https://github.com/Linol-Hamelton/jtcsv#readme",
177
185
  "engines": {
178
- "node": ">=12.0.0"
186
+ "node": ">=18.0.0"
179
187
  },
180
188
  "files": [
181
189
  "index.js",
@@ -192,6 +200,9 @@
192
200
  "examples/",
193
201
  "plugins/"
194
202
  ],
203
+ "dependencies": {
204
+ "glob": "10.5.0"
205
+ },
195
206
  "devDependencies": {
196
207
  "@babel/core": "^7.23.0",
197
208
  "@babel/preset-env": "^7.22.0",
@@ -199,14 +210,15 @@
199
210
  "@rollup/plugin-commonjs": "^25.0.0",
200
211
  "@rollup/plugin-node-resolve": "^15.0.0",
201
212
  "@rollup/plugin-terser": "^0.4.0",
202
- "@size-limit/preset-small-lib": "12.0.0",
203
- "blessed": "^0.1.81",
204
- "blessed-contrib": "4.11.0",
205
- "eslint": "8.57.1",
213
+ "@size-limit/preset-small-lib": "12.0.0",
214
+ "blessed": "^0.1.81",
215
+ "blessed-contrib": "4.11.0",
216
+ "eslint": "8.57.1",
206
217
  "jest": "^29.0.0",
207
218
  "jest-environment-jsdom": "30.2.0",
208
219
  "rollup": "^4.0.0",
209
- "size-limit": "12.0.0"
220
+ "size-limit": "12.0.0",
221
+ "typedoc": "^0.25.0"
210
222
  },
211
223
  "type": "commonjs",
212
224
  "size-limit": [
package/src/errors.js ADDED
@@ -0,0 +1,26 @@
1
+ class ValidationError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'ValidationError';
5
+ }
6
+ }
7
+
8
+ class SecurityError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'SecurityError';
12
+ }
13
+ }
14
+
15
+ class ConfigurationError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'ConfigurationError';
19
+ }
20
+ }
21
+
22
+ module.exports = {
23
+ ValidationError,
24
+ SecurityError,
25
+ ConfigurationError
26
+ };