spice-js 2.7.24 → 2.7.27
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.
|
@@ -696,6 +696,9 @@ class SpiceModel {
|
|
|
696
696
|
try {
|
|
697
697
|
if (_.isString(data)) {
|
|
698
698
|
var result = yield _this7.database.get(data);
|
|
699
|
+
if (!result) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
699
702
|
if (result.type) if (result.type != _this7.type) {
|
|
700
703
|
return false;
|
|
701
704
|
}
|
|
@@ -703,6 +706,9 @@ class SpiceModel {
|
|
|
703
706
|
return false;
|
|
704
707
|
}
|
|
705
708
|
} else {
|
|
709
|
+
if (!data) {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
706
712
|
if (data.type) if (data.type != _this7.type) {
|
|
707
713
|
return false;
|
|
708
714
|
}
|
|
@@ -140,6 +140,31 @@ class RestHelper {
|
|
|
140
140
|
numeric: true,
|
|
141
141
|
sensitivity: "base"
|
|
142
142
|
}));
|
|
143
|
+
|
|
144
|
+
// Filter flattened fields to only include requested columns
|
|
145
|
+
var rawUrl = ctx.originalUrl || ctx.request.url || "";
|
|
146
|
+
var urlColumnsMatch = rawUrl.match(/[?&]columns=([^&]*)/);
|
|
147
|
+
var requestedColumns = urlColumnsMatch ? decodeURIComponent(urlColumnsMatch[1]) : null;
|
|
148
|
+
if (requestedColumns) {
|
|
149
|
+
var specs = requestedColumns.split(",").map(c => c.trim()).filter(c => c !== "");
|
|
150
|
+
var filtered = [];
|
|
151
|
+
for (var spec of specs) {
|
|
152
|
+
if (!spec.includes(".")) {
|
|
153
|
+
// Simple field: exact match
|
|
154
|
+
if (fields.includes(spec)) filtered.push(spec);
|
|
155
|
+
} else {
|
|
156
|
+
// Nested field: build regex that allows numeric array indices between segments
|
|
157
|
+
// and optionally matches all descendant fields.
|
|
158
|
+
var segments = spec.split(".");
|
|
159
|
+
var pattern = segments.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("(\\.\\d+)?\\.");
|
|
160
|
+
var re = new RegExp("^" + pattern + "(\\..+)?$");
|
|
161
|
+
for (var f of fields) {
|
|
162
|
+
if (re.test(f)) filtered.push(f);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
fields = filtered.length > 0 ? filtered : fields;
|
|
167
|
+
}
|
|
143
168
|
var csv = parse(flatRows, {
|
|
144
169
|
fields,
|
|
145
170
|
defaultValue: "",
|
|
@@ -154,7 +179,7 @@ class RestHelper {
|
|
|
154
179
|
ctx.status = 200;
|
|
155
180
|
var csvStream = fs.createReadStream(filePath);
|
|
156
181
|
// Delete file after stream is finished
|
|
157
|
-
csvStream.on(
|
|
182
|
+
csvStream.on("close", () => {
|
|
158
183
|
fs.promises.unlink(filePath).catch(() => {});
|
|
159
184
|
});
|
|
160
185
|
ctx.body = csvStream;
|
|
@@ -174,7 +199,7 @@ class RestHelper {
|
|
|
174
199
|
ctx.status = 200;
|
|
175
200
|
var jsonStream = fs.createReadStream(filePath);
|
|
176
201
|
// Delete file after stream is finished
|
|
177
|
-
jsonStream.on(
|
|
202
|
+
jsonStream.on("close", () => {
|
|
178
203
|
fs.promises.unlink(filePath).catch(() => {});
|
|
179
204
|
});
|
|
180
205
|
ctx.body = jsonStream;
|
package/package.json
CHANGED
package/src/models/SpiceModel.js
CHANGED
|
@@ -762,6 +762,9 @@ export default class SpiceModel {
|
|
|
762
762
|
try {
|
|
763
763
|
if (_.isString(data)) {
|
|
764
764
|
let result = await this.database.get(data);
|
|
765
|
+
if (!result) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
765
768
|
if (result.type)
|
|
766
769
|
if (result.type != this.type) {
|
|
767
770
|
return false;
|
|
@@ -771,6 +774,9 @@ export default class SpiceModel {
|
|
|
771
774
|
return false;
|
|
772
775
|
}
|
|
773
776
|
} else {
|
|
777
|
+
if (!data) {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
774
780
|
if (data.type)
|
|
775
781
|
if (data.type != this.type) {
|
|
776
782
|
return false;
|
|
@@ -24,7 +24,7 @@ export default class RestHelper {
|
|
|
24
24
|
RestHelper.SUCCESS,
|
|
25
25
|
ctx.data,
|
|
26
26
|
ctx.errors,
|
|
27
|
-
ctx.token
|
|
27
|
+
ctx.token,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
ctx.status = 200;
|
|
@@ -44,7 +44,7 @@ export default class RestHelper {
|
|
|
44
44
|
const makeDirectory = (dir) => {
|
|
45
45
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
46
46
|
};
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
const deepTrimStrings = (val) => {
|
|
49
49
|
if (Array.isArray(val)) return val.map(deepTrimStrings);
|
|
50
50
|
if (val && typeof val === "object") {
|
|
@@ -54,9 +54,10 @@ export default class RestHelper {
|
|
|
54
54
|
}
|
|
55
55
|
return typeof val === "string" ? val.trim() : val;
|
|
56
56
|
};
|
|
57
|
-
|
|
57
|
+
|
|
58
58
|
const stripTopLevel = (obj, { removeId }) => {
|
|
59
|
-
if (Array.isArray(obj))
|
|
59
|
+
if (Array.isArray(obj))
|
|
60
|
+
return obj.map((i) => stripTopLevel(i, { removeId }));
|
|
60
61
|
if (obj && typeof obj === "object") {
|
|
61
62
|
const copy = { ...obj };
|
|
62
63
|
if (removeId) delete copy.id;
|
|
@@ -65,7 +66,7 @@ export default class RestHelper {
|
|
|
65
66
|
}
|
|
66
67
|
return obj;
|
|
67
68
|
};
|
|
68
|
-
|
|
69
|
+
|
|
69
70
|
const normalizeEmptyArraysForCsv = (val) => {
|
|
70
71
|
if (Array.isArray(val)) {
|
|
71
72
|
if (val.length === 0) return undefined;
|
|
@@ -81,7 +82,7 @@ export default class RestHelper {
|
|
|
81
82
|
}
|
|
82
83
|
return val;
|
|
83
84
|
};
|
|
84
|
-
|
|
85
|
+
|
|
85
86
|
const safeJSONStringify = (value, space = 2) => {
|
|
86
87
|
const seen = new WeakSet();
|
|
87
88
|
const replacer = (_k, v) => {
|
|
@@ -95,31 +96,72 @@ export default class RestHelper {
|
|
|
95
96
|
};
|
|
96
97
|
return JSON.stringify(value, replacer, space);
|
|
97
98
|
};
|
|
98
|
-
|
|
99
|
+
|
|
99
100
|
try {
|
|
100
101
|
const download_type = (ctx.request.query.format || "csv").toLowerCase();
|
|
101
102
|
const include_id = ctx.request.query.include_id;
|
|
102
|
-
|
|
103
|
+
|
|
103
104
|
const original = _.cloneDeep(ctx.data);
|
|
104
|
-
const trimmed
|
|
105
|
-
const cleaned
|
|
106
|
-
|
|
105
|
+
const trimmed = deepTrimStrings(original);
|
|
106
|
+
const cleaned = stripTopLevel(trimmed, {
|
|
107
|
+
removeId: !include_id || include_id === "false",
|
|
108
|
+
});
|
|
109
|
+
|
|
107
110
|
let filename, filePath;
|
|
108
|
-
|
|
111
|
+
|
|
109
112
|
if (download_type === "csv") {
|
|
110
113
|
const { flatten } = await import("flat");
|
|
111
114
|
const rows = Array.isArray(cleaned) ? cleaned : [cleaned];
|
|
112
|
-
|
|
115
|
+
|
|
113
116
|
const csvReady = rows.map(normalizeEmptyArraysForCsv);
|
|
114
|
-
const flatRows = csvReady.map((row) =>
|
|
115
|
-
|
|
117
|
+
const flatRows = csvReady.map((row) =>
|
|
118
|
+
flatten(row, { safe: false, delimiter: "." }),
|
|
119
|
+
);
|
|
120
|
+
|
|
116
121
|
const fieldSet = new Set();
|
|
117
|
-
for (const r of flatRows)
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
for (const r of flatRows)
|
|
123
|
+
Object.keys(r).forEach((k) => fieldSet.add(k));
|
|
124
|
+
let fields = Array.from(fieldSet).sort((a, b) =>
|
|
125
|
+
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
|
|
120
126
|
);
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
|
|
128
|
+
// Filter flattened fields to only include requested columns
|
|
129
|
+
const rawUrl = ctx.originalUrl || ctx.request.url || "";
|
|
130
|
+
const urlColumnsMatch = rawUrl.match(/[?&]columns=([^&]*)/);
|
|
131
|
+
const requestedColumns = urlColumnsMatch
|
|
132
|
+
? decodeURIComponent(urlColumnsMatch[1])
|
|
133
|
+
: null;
|
|
134
|
+
if (requestedColumns) {
|
|
135
|
+
const specs = requestedColumns
|
|
136
|
+
.split(",")
|
|
137
|
+
.map((c) => c.trim())
|
|
138
|
+
.filter((c) => c !== "");
|
|
139
|
+
const filtered = [];
|
|
140
|
+
for (const spec of specs) {
|
|
141
|
+
if (!spec.includes(".")) {
|
|
142
|
+
// Simple field: exact match
|
|
143
|
+
if (fields.includes(spec)) filtered.push(spec);
|
|
144
|
+
} else {
|
|
145
|
+
// Nested field: build regex that allows numeric array indices between segments
|
|
146
|
+
// and optionally matches all descendant fields.
|
|
147
|
+
const segments = spec.split(".");
|
|
148
|
+
const pattern = segments
|
|
149
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
150
|
+
.join("(\\.\\d+)?\\.");
|
|
151
|
+
const re = new RegExp("^" + pattern + "(\\..+)?$");
|
|
152
|
+
for (const f of fields) {
|
|
153
|
+
if (re.test(f)) filtered.push(f);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
fields = filtered.length > 0 ? filtered : fields;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const csv = parse(flatRows, {
|
|
161
|
+
fields,
|
|
162
|
+
defaultValue: "",
|
|
163
|
+
excelStrings: true,
|
|
164
|
+
});
|
|
123
165
|
|
|
124
166
|
makeDirectory(`./storage/exports/csv`);
|
|
125
167
|
filename = `${RestHelper.makeid(9)}.csv`;
|
|
@@ -131,7 +173,7 @@ export default class RestHelper {
|
|
|
131
173
|
ctx.status = 200;
|
|
132
174
|
const csvStream = fs.createReadStream(filePath);
|
|
133
175
|
// Delete file after stream is finished
|
|
134
|
-
csvStream.on(
|
|
176
|
+
csvStream.on("close", () => {
|
|
135
177
|
fs.promises.unlink(filePath).catch(() => {});
|
|
136
178
|
});
|
|
137
179
|
ctx.body = csvStream;
|
|
@@ -139,7 +181,7 @@ export default class RestHelper {
|
|
|
139
181
|
}
|
|
140
182
|
|
|
141
183
|
const jsonText = safeJSONStringify(cleaned, 2);
|
|
142
|
-
|
|
184
|
+
|
|
143
185
|
makeDirectory(`./storage/exports/json`);
|
|
144
186
|
filename = `${RestHelper.makeid(9)}.json`;
|
|
145
187
|
filePath = path.resolve(`./storage/exports/json/${filename}`);
|
|
@@ -149,11 +191,10 @@ export default class RestHelper {
|
|
|
149
191
|
ctx.status = 200;
|
|
150
192
|
const jsonStream = fs.createReadStream(filePath);
|
|
151
193
|
// Delete file after stream is finished
|
|
152
|
-
jsonStream.on(
|
|
194
|
+
jsonStream.on("close", () => {
|
|
153
195
|
fs.promises.unlink(filePath).catch(() => {});
|
|
154
196
|
});
|
|
155
197
|
ctx.body = jsonStream;
|
|
156
|
-
|
|
157
198
|
} catch (e) {
|
|
158
199
|
console.error(e.stack);
|
|
159
200
|
ctx.status = 400;
|
|
@@ -179,7 +220,7 @@ export default class RestHelper {
|
|
|
179
220
|
obj = {
|
|
180
221
|
data: [],
|
|
181
222
|
total: null,
|
|
182
|
-
}
|
|
223
|
+
},
|
|
183
224
|
) {
|
|
184
225
|
var data = {
|
|
185
226
|
status: status,
|
|
@@ -267,4 +308,3 @@ export default class RestHelper {
|
|
|
267
308
|
return false;
|
|
268
309
|
}
|
|
269
310
|
}
|
|
270
|
-
|
|
@@ -32,6 +32,18 @@ class MockDatabase {
|
|
|
32
32
|
return this.mockData.get(id);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Mock update operation
|
|
37
|
+
* @param {string} id - Document ID to update
|
|
38
|
+
* @param {Object} data - Updated document data
|
|
39
|
+
* @returns {Promise<Object>} Updated document
|
|
40
|
+
*/
|
|
41
|
+
async update(id, data, ttl) {
|
|
42
|
+
const updated = { ...data, id };
|
|
43
|
+
this.mockData.set(id, updated);
|
|
44
|
+
return updated;
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* Seed mock database with test data
|
|
37
49
|
* @param {string} id - Document ID
|
|
@@ -578,6 +578,12 @@ describe('SpiceModel - Critical Fixes for Empty/Null Values', () => {
|
|
|
578
578
|
model = createTestModel({ type: 'user' });
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
+
test('should fail update with a controlled not-found error when database returns undefined', async () => {
|
|
582
|
+
await expect(
|
|
583
|
+
model.update({ id: 'missing-user' })
|
|
584
|
+
).rejects.toThrow('user does not exist. in update');
|
|
585
|
+
});
|
|
586
|
+
|
|
581
587
|
test('should handle undefined columns parameter', async () => {
|
|
582
588
|
seedDatabase(model, sampleDbResults.users);
|
|
583
589
|
|