spice-js 2.7.18 → 2.7.19

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.
@@ -1,2186 +0,0 @@
1
- "use strict";
2
-
3
- import { MapType, DataType } from "..";
4
- let co = require("co");
5
- var regeneratorRuntime = require("regenerator-runtime");
6
- import ResourceLifecycleTriggered from "../events/events/ResourceLifecycleTriggered";
7
- import { hasSQLInjection } from "../utility/Security";
8
- import { fixCollection } from "../utility/fix";
9
-
10
- var SDate = require("sonover-date"),
11
- UUID = require("uuid"),
12
- _ = require("lodash"),
13
- _database = Symbol(),
14
- _ctx = Symbol(),
15
- _props = Symbol(),
16
- _args = Symbol(),
17
- _hooks = Symbol(),
18
- _columns = Symbol(),
19
- _disable_lifecycle_events = Symbol(),
20
- _external_modifier_loaded = Symbol(),
21
- _skip_cache = Symbol(),
22
- _serializers = Symbol(),
23
- _level = Symbol(),
24
- _mapping_dept = Symbol(),
25
- _mapping_dept_exempt = Symbol(),
26
- _current_path = Symbol();
27
-
28
- //const _type = Symbol("type");
29
-
30
- // Helper to resolve spice global (for testing compatibility)
31
- // In production, spice is injected globally. In tests, we need to access it via global.
32
- function getSpice() {
33
- if (typeof spice !== 'undefined') {
34
- return spice;
35
- }
36
- if (typeof global !== 'undefined' && global.spice) {
37
- return global.spice;
38
- }
39
- if (typeof globalThis !== 'undefined' && globalThis.spice) {
40
- return globalThis.spice;
41
- }
42
- throw new Error('spice global not found');
43
- }
44
-
45
- let that;
46
- if (!Promise.allSettled) {
47
- Promise.allSettled = (promises) =>
48
- Promise.all(
49
- promises.map((promise, i) =>
50
- promise
51
- .then((value) => ({
52
- status: "fulfilled",
53
- value,
54
- }))
55
- .catch((reason) => ({
56
- status: "rejected",
57
- reason,
58
- })),
59
- ),
60
- );
61
- }
62
- export default class SpiceModel {
63
- constructor(args = {}) {
64
- try {
65
- var dbtype =
66
- spice.config.database.connections[args.connection].type || "couchbase";
67
- let Database = require(`spice-${dbtype}`);
68
- this[_mapping_dept] =
69
- args?.args?.mapping_dept || process.env.MAPPING_DEPT || 3;
70
- this[_mapping_dept_exempt] = args?.args?.mapping_dept_exempt || [];
71
- this.type = "";
72
- this.collection = args.connection;
73
- this[_args] = args.args;
74
- this[_external_modifier_loaded] = false;
75
- this[_disable_lifecycle_events] =
76
- args.args?.disable_lifecycle_events || false;
77
- this[_ctx] = args?.args?.ctx;
78
- this[_skip_cache] = args?.args?.skip_cache || false;
79
- this[_level] = args?.args?._level || 0;
80
- this[_current_path] = args?.args?._current_path || "";
81
- this[_columns] = args?.args?._columns || "";
82
- this[_hooks] = {
83
- create: {
84
- before: [],
85
- after: [],
86
- },
87
- get: {
88
- before: [],
89
- after: [],
90
- },
91
- list: {
92
- before: [],
93
- after: [],
94
- },
95
- update: {
96
- before: [],
97
- after: [],
98
- },
99
- delete: {
100
- before: [],
101
- after: [],
102
- },
103
- };
104
- this[_serializers] = {
105
- read: {
106
- modifiers: [],
107
- cleaners: ["deleted", "type"],
108
- },
109
- write: {
110
- modifiers: [],
111
- cleaners: [],
112
- },
113
- };
114
-
115
- this.deleted = false;
116
- this[_database] = new Database(args.connection || "default", {
117
- collection: args.collection,
118
- scope: args.scope,
119
- });
120
-
121
- function removeDynamicProps(props) {
122
- let returnVal = {};
123
- for (let i in props) {
124
- if (
125
- !_.has(props[i], "source") ||
126
- _.get(props[i], "source") == "static"
127
- ) {
128
- returnVal[i] = props[i];
129
- }
130
- }
131
- return returnVal;
132
- }
133
-
134
- function applySchemaOverrides(props, resource) {
135
- spice.schema_extenders = spice.schema_extenders || [];
136
-
137
- function applyExtenderModifier(prop, resource, extender) {
138
- if (extender.modifier) {
139
- return extender.modifier(prop, resource);
140
- }
141
- return prop;
142
- }
143
-
144
- function hasExtender(resource, prop, extender) {
145
- if (extender.selector(prop, resource)) {
146
- return true;
147
- }
148
- return false;
149
- }
150
-
151
- function resourceHasExtender(resource, extender) {
152
- return (
153
- extender.resource.includes(resource) ||
154
- extender.resource.includes("*")
155
- );
156
- }
157
-
158
- for (let file_name in spice.schema_extenders) {
159
- let extender = spice.schema_extenders[file_name];
160
- if (resourceHasExtender(resource, extender)) {
161
- for (let j in props) {
162
- if (hasExtender(resource, props[j], extender)) {
163
- props[j] = applyExtenderModifier(props[j], resource, extender);
164
- }
165
- }
166
- if (extender?.creator) {
167
- props = _.merge(props, extender.creator(resource));
168
- }
169
- }
170
- }
171
- return props;
172
- }
173
- args.props = applySchemaOverrides(
174
- removeDynamicProps(args.props),
175
- args.collection,
176
- );
177
- this[_props] = args.props;
178
-
179
- // Get dynamic schema for zero-downtime field updates
180
- // Merge with static props to ensure new fields are also copied to instance
181
- let effectiveProps = args.props;
182
- if (typeof spice !== "undefined" && spice.schemas && args.collection) {
183
- const dynamicSchema =
184
- spice.schemas[args.collection] ||
185
- spice.schemas[args.collection.toLowerCase()];
186
- if (dynamicSchema) {
187
- effectiveProps = { ...args.props, ...dynamicSchema };
188
- }
189
- }
190
-
191
- for (let i in effectiveProps) {
192
- if (args.args != undefined) {
193
- if (args.args[i] != undefined) {
194
- const propDef = effectiveProps[i] || {};
195
- switch (propDef.type) {
196
- case String:
197
- case "string": {
198
- this[i] = args.args[i];
199
- break;
200
- }
201
- case Number:
202
- case "number": {
203
- this[i] =
204
- _.isNumber(args.args[i]) ?
205
- args.args[i]
206
- : Number(args.args[i]);
207
- break;
208
- }
209
- case Boolean:
210
- case "boolean": {
211
- this[i] =
212
- _.isBoolean(args.args[i]) ?
213
- args.args[i]
214
- : args.args[i] == "true" ||
215
- args.args[i] == 1 ||
216
- args.args[i] == "True";
217
- break;
218
- }
219
- case Date:
220
- case "date": {
221
- this[i] = args.args[i];
222
- break;
223
- }
224
- case Array:
225
- case "array": {
226
- this[i] =
227
- _.isArray(args.args[i]) ?
228
- args.args[i]
229
- : JSON.parse(args.args[i]);
230
- break;
231
- }
232
- case Object:
233
- case "object": {
234
- this[i] =
235
- _.isObject(args.args[i]) ?
236
- args.args[i]
237
- : JSON.parse(args.args[i]);
238
- break;
239
- }
240
- default: {
241
- this[i] = args.args[i];
242
- }
243
- }
244
- }
245
- }
246
- }
247
- that = this;
248
- this.createModifiersFromProps();
249
- } catch (e) {
250
- console.warn(e.stack);
251
- }
252
- // }
253
- }
254
-
255
- get database() {
256
- return this[_database];
257
- }
258
- set database(value) {
259
- this[_database] = value;
260
- }
261
-
262
- get props() {
263
- // Dynamically look up schema from spice.schemas for zero-downtime updates
264
- // Fall back to stored props for backward compatibility
265
- if (this.type && typeof spice !== "undefined" && spice.schemas) {
266
- const dynamicSchema =
267
- spice.schemas[this.type] || spice.schemas[this.type.toLowerCase()];
268
- if (dynamicSchema) {
269
- return dynamicSchema;
270
- }
271
- }
272
- return this[_props];
273
- }
274
-
275
- set props(value) {
276
- this[_props] = value;
277
- }
278
-
279
- is_date(item) {
280
- return item.length > 27 && item.indexOf("|") > -1;
281
- }
282
-
283
- async propsToBeRemoved(data) {
284
- if (!_.isArray(data)) {
285
- data = Array.of(data);
286
- }
287
-
288
- if (this[_ctx]) {
289
- if (this[_ctx]?.state?.process_fields) {
290
- let returned = await new this[_ctx].state.process_fields().process(
291
- this[_ctx],
292
- data,
293
- this.type,
294
- );
295
- return returned;
296
- } else {
297
- return data;
298
- }
299
- }
300
- return [];
301
- }
302
-
303
- createValidationString({ property_name, props, operation, complete }) {
304
- let operationExempt = {
305
- PUT: ["required", "unique"],
306
- };
307
- let returnVal = [];
308
- for (let i in props) {
309
- if (!_.includes(operationExempt[operation] || [], i) || complete) {
310
- if (i == "required" && props[i] == true) {
311
- returnVal.push("required");
312
- }
313
- if (i == "email" && props[i] == true) {
314
- returnVal.push("email");
315
- }
316
- if (i == "json" && props[i] == true) {
317
- returnVal.push("json");
318
- }
319
- if (i == "ip" && props[i] == true) {
320
- returnVal.push("ip");
321
- }
322
- if (i == "url" && props[i] == true) {
323
- returnVal.push("url");
324
- }
325
- if (i == "date" && props[i] == true) {
326
- returnVal.push("date");
327
- }
328
- if (i == "requires") {
329
- returnVal.push(`shouldExist:${props[i]}`);
330
- }
331
- if (i == "unique" && props[i] == true) {
332
- returnVal.push(`unique:${this.constructor.name},${property_name}`);
333
- }
334
- if (i == "type") {
335
- switch (props[i]) {
336
- case String:
337
- case DataType.STRING: {
338
- returnVal.push(`string`);
339
- break;
340
- }
341
- case Number:
342
- case DataType.NUMBER: {
343
- //returnVal.push(`numeric`);
344
- break;
345
- }
346
- case Boolean:
347
- case DataType.BOOLEAN: {
348
- returnVal.push(`in:true,false,True,False,TRUE,FALSE,1,0`);
349
- break;
350
- }
351
- case Date:
352
- case DataType.DATE: {
353
- returnVal.push(`date`);
354
- break;
355
- }
356
- case Object:
357
- case DataType.OBJECT: {
358
- //returnVal.push(`json`);
359
- break;
360
- }
361
- case Array:
362
- case DataType.ARRAY: {
363
- //returnVal.push(`json`);
364
- break;
365
- }
366
- default: {
367
- returnVal.push(`string`);
368
- break;
369
- }
370
- }
371
- }
372
- }
373
- }
374
- return _.join(returnVal, "|");
375
- }
376
-
377
- createFilterString(property, props) {
378
- let returnVal = [];
379
- for (let i in props) {
380
- if (i == "lowercase" && props[i] == true) {
381
- returnVal.push("lowercase");
382
- }
383
- if (i == "trim" && props[i] == true) {
384
- returnVal.push("trim");
385
- }
386
- if (i == "uppercase" && props[i] == true) {
387
- returnVal.push("uppercase");
388
- }
389
- }
390
- return _.join(returnVal, "|");
391
- }
392
-
393
- getValidationRules(
394
- method = "POST",
395
- { complete = false, pick = [], omit = [] } = {},
396
- ) {
397
- let rules = {};
398
- let filters = {};
399
- let working_properties = this.props;
400
- if (pick.length > 0) {
401
- working_properties = _.pick(this.props, pick);
402
- }
403
-
404
- if (omit.length > 0) {
405
- working_properties = _.omit(this.props, omit);
406
- }
407
-
408
- for (let i in working_properties) {
409
- let created_rules = this.createValidationString({
410
- property_name: i,
411
- props: this.props[i],
412
- operation: method,
413
- complete,
414
- });
415
- if (created_rules.length > 0) {
416
- rules[i] = created_rules;
417
- }
418
-
419
- let created_filter = this.createFilterString(i, this.props[i]);
420
- if (created_filter.length > 0) {
421
- filters[i] = created_filter;
422
- }
423
- }
424
- return { rules, messages: {}, filters: { before: filters } };
425
- }
426
-
427
- makeQueryFromFilter(filter) {
428
- let return_string = "";
429
- for (let key in filter) {
430
- let item_string = "";
431
- if (filter[key].length > 1) {
432
- item_string = "(";
433
- }
434
-
435
- let item_count = 0;
436
-
437
- for (let item of filter[key]) {
438
- if (item_count > 0) {
439
- item_string = item_string + " OR ";
440
- }
441
-
442
- if (this.is_date(item)) {
443
- let to_index = item.indexOf("|");
444
- let first = item.substring(0, to_index);
445
- let second = item.substring(to_index + 1, item.length);
446
- item_string =
447
- item_string +
448
- " " +
449
- key +
450
- " BETWEEN '" +
451
- first +
452
- "' AND '" +
453
- second +
454
- "'";
455
- } else {
456
- item_string = item_string + " " + key + " LIKE '" + item + "'";
457
- }
458
-
459
- item_count++;
460
- }
461
-
462
- if (filter[key].length > 1) {
463
- item_string = item_string + ")";
464
- }
465
- if (return_string != "") {
466
- return_string = return_string + " AND ";
467
- }
468
- return_string = return_string + item_string;
469
- }
470
- return return_string;
471
- }
472
-
473
- shouldUseCache(resource_type) {
474
- // If '_skip_cache' property of this object is true, then we shouldn't cache.
475
- if (this[_skip_cache] == true) {
476
- return false;
477
- }
478
-
479
- // If the system configuration for spice has a cache status set to "disable",
480
- // then we shouldn't cache, so return false.
481
- if (spice.config.cache?.status == "disabled") {
482
- return false;
483
- }
484
-
485
- // If 'spice.cache[resource_type]' is not undefined,
486
- // it implies that this resource type is already in the cache or is cacheable.
487
- return spice.cache[resource_type] != undefined;
488
- }
489
-
490
- getCacheConfig(resource_type) {
491
- return spice.cache[resource_type] || {};
492
- }
493
-
494
- getCacheProviderObject(resource_type) {
495
- return spice.cache_providers[
496
- this.getCacheConfig(resource_type).driver ||
497
- this.getCacheConfig(resource_type).provider ||
498
- spice.config.cache.default_driver
499
- ];
500
- }
501
-
502
- async exists(item_type, key) {
503
- let obj = this.getCacheProviderObject(item_type);
504
- if (obj) return await obj.exists(key);
505
- return false;
506
- }
507
-
508
- async shouldForceRefresh(response) {
509
- let obj = this.getCacheProviderObject(this.type);
510
- let monitor_record = await obj.get(`monitor::${this.type}`);
511
- if (monitor_record == undefined) {
512
- return false;
513
- }
514
- if (monitor_record.time > response?.time) {
515
- return true;
516
- }
517
- return false;
518
- }
519
-
520
- setMonitor() {
521
- let current_time = new Date().getTime();
522
- let obj = this.getCacheProviderObject(this.type);
523
- let key = `monitor::${this.type}`;
524
- let value = { time: current_time };
525
- obj.set(key, value, { ttl: 0 });
526
- }
527
-
528
- async get(args) {
529
- // Profiling: use track() for proper async context forking
530
- const p = this[_ctx]?.profiler;
531
-
532
- const doGet = async () => {
533
- if (args.mapping_dept) this[_mapping_dept] = args.mapping_dept;
534
- if (args.mapping_dept_exempt)
535
- this[_mapping_dept_exempt] = args.mapping_dept_exempt;
536
-
537
- if (!args) {
538
- args = {};
539
- }
540
- if (_.isString(args.id)) {
541
- if (args.skip_hooks !== true) {
542
- await this.run_hook(args, "get", "before");
543
- }
544
- // ⚡ Include columns in cache key if specified
545
- let key = `get::${this.type}::${args.id}${args.columns ? `::${args.columns}` : ""}`;
546
- let results = {};
547
-
548
- // Extract nestings from columns if specified
549
- const nestings = this.extractNestings(args?.columns || "", this.type);
550
-
551
- // Build join metadata and prepare columns
552
- this.buildJoinMetadata(nestings, args);
553
- let columns = args.columns;
554
- let columnArray = [];
555
- if (columns) {
556
- columns.split(",").forEach((col) => {
557
- const parts = col.trim().split("."); // remove the first segment
558
- // Also match if parts[0] is source_property or '`' + source_property + '`'
559
- const firstPart = parts[0];
560
- // Remove backticks if present
561
- const cleanFirstPart =
562
- (
563
- firstPart &&
564
- firstPart.startsWith("`") &&
565
- firstPart.endsWith("`")
566
- ) ?
567
- firstPart.slice(1, -1)
568
- : firstPart;
569
-
570
- //if (parts.length > 0 && (firstPart === source_property || cleanFirstPart === source_property)) {
571
- // Use cleanFirstPart for the map key to handle both cases consistently
572
- // Remove backticks from each column segment, if present, before joining
573
- columnArray.push(
574
- parts
575
- .slice(1)
576
- .map((segment) =>
577
- segment && segment.startsWith("`") && segment.endsWith("`") ?
578
- segment.slice(1, -1)
579
- : segment,
580
- )
581
- .join("."),
582
- );
583
- // }
584
- });
585
- }
586
-
587
- if (this.shouldUseCache(this.type)) {
588
- // Retrieve the cached results
589
- const cached_results = await this.getCacheProviderObject(
590
- this.type,
591
- ).get(key);
592
- // Check if the cache is empty
593
- const isCacheEmpty = cached_results?.value === undefined;
594
- // Check if the cached result should be refreshed
595
- const shouldRefresh = await this.shouldForceRefresh(cached_results);
596
-
597
- if (isCacheEmpty || shouldRefresh) {
598
- // Retrieve from the database and update cache
599
- if (p) {
600
- results = await p.track(`${this.type}.get.database`, async () => {
601
- return await this.database.get(args.id, {
602
- columns: columnArray,
603
- });
604
- });
605
- } else {
606
- results = await this.database.get(args.id, {
607
- columns: columnArray,
608
- });
609
- }
610
- await this.getCacheProviderObject(this.type).set(
611
- key,
612
- { value: results, time: new Date().getTime() },
613
- this.getCacheConfig(this.type),
614
- );
615
- } else {
616
- // Use the cached value
617
- results = cached_results.value;
618
- }
619
- } else {
620
- // Directly fetch from the database if caching is disabled
621
- if (p) {
622
- results = await p.track(`${this.type}.get.database`, async () => {
623
- return await this.database.get(args.id, { columns: columnArray });
624
- });
625
- } else {
626
- results = await this.database.get(args.id, {
627
- columns: columnArray,
628
- });
629
- }
630
- }
631
-
632
- if (results.type !== undefined && results.type !== this.type) {
633
- throw new Error(`${this.type} does not exist type`);
634
- }
635
- if (results._type !== undefined && results._type !== this.type) {
636
- throw new Error(`${this.type} does not exist _type`);
637
- }
638
- if (results.deleted === undefined || results.deleted === false) {
639
- if (
640
- args.skip_read_serialize !== true &&
641
- args.skip_serialize !== true
642
- ) {
643
- // ⚡ Pass columns to do_serialize so it can skip irrelevant modifiers
644
- results = await this.do_serialize(
645
- results,
646
- "read",
647
- {},
648
- args,
649
- await this.propsToBeRemoved(results),
650
- );
651
- }
652
-
653
- // ⚡ OPTIMIZED: Filter results by columns if specified
654
- if (args.columns) {
655
- results = this.filterResultsByColumns([results], args.columns)[0];
656
- }
657
-
658
- if (args.skip_hooks !== true) {
659
- await this.run_hook(results, "get", "after");
660
- }
661
- return results;
662
- } else {
663
- throw new Error(`${this.type} does not exist`);
664
- }
665
- }
666
- };
667
-
668
- try {
669
- if (p) {
670
- return await p.track(`${this.type}.get`, doGet, { id: args?.id });
671
- }
672
- return await doGet();
673
- } catch (e) {
674
- console.warn(e.message, e);
675
- throw e;
676
- }
677
- }
678
-
679
- async query(query, scope) {
680
- try {
681
- let results = await this.database.query(query, scope);
682
- return results;
683
- } catch (error) {
684
- throw error;
685
- }
686
- }
687
-
688
- async getMulti(args) {
689
- // ⚡ Profiling: use track() for proper async context forking
690
- const p = this[_ctx]?.profiler;
691
-
692
- const doGetMulti = async () => {
693
- if (!args) {
694
- args = {};
695
- }
696
- if (args.skip_hooks != true) {
697
- await this.run_hook(this, "list", "before");
698
- }
699
- // PATCH: Prevent empty/null IDs from causing query explosions
700
- _.remove(args.ids, (o) => o == undefined || o === "" || o === null);
701
-
702
- // PATCH: Early exit if no valid IDs after filtering
703
- if (!args.ids || args.ids.length === 0) {
704
- return []; // No IDs to fetch
705
- }
706
-
707
- let key = `multi-get::${this.type}::${_.join(args.ids, "|")}:${args.columns}`;
708
- let results = [];
709
- if (args.ids.length > 0) {
710
- if (this.shouldUseCache(this.type)) {
711
- let cached_results = await this.getCacheProviderObject(this.type).get(
712
- key,
713
- );
714
- results = cached_results?.value;
715
- if (
716
- cached_results?.value === undefined ||
717
- (await this.shouldForceRefresh(cached_results))
718
- ) {
719
- results = await this.database.multi_get(args.ids, {
720
- keep_type: true,
721
- columns:
722
- args.columns && args.columns.length > 0 && args.columns != "" ?
723
- [...this.extractBaseColumns(args.columns), "type"]
724
- : [],
725
- });
726
- this.getCacheProviderObject(this.type).set(
727
- key,
728
- { value: results, time: new Date().getTime() },
729
- this.getCacheConfig(this.type),
730
- );
731
- }
732
- } else {
733
- results = await this.database.multi_get(args.ids, {
734
- keep_type: true,
735
- columns:
736
- args.columns && args.columns.length > 0 && args.columns != "" ?
737
- [...this.extractBaseColumns(args.columns), "type"]
738
- : [],
739
- });
740
- }
741
- }
742
- _.remove(results, (o) => o.type != this.type);
743
- if (args.skip_read_serialize != true && args.skip_serialize != true) {
744
- results = await this.do_serialize(
745
- results,
746
- "read",
747
- {},
748
- args,
749
- await this.propsToBeRemoved(results),
750
- );
751
- }
752
-
753
- if (args.skip_hooks != true) {
754
- await this.run_hook(results, "list", "after", args.context);
755
- }
756
-
757
- return results;
758
- };
759
-
760
- try {
761
- if (p) {
762
- return await p.track(`${this.type}.getMulti`, doGetMulti, {
763
- ids_count: args?.ids?.length || 0,
764
- });
765
- }
766
- return await doGetMulti();
767
- } catch (e) {
768
- console.warn(e.stack);
769
- throw e;
770
- }
771
- }
772
-
773
- async exist(data) {
774
- try {
775
- if (_.isString(data)) {
776
- let result = await this.database.get(data);
777
- if (result.type)
778
- if (result.type != this.type) {
779
- return false;
780
- }
781
- if (result._type)
782
- if (result._type != this.type) {
783
- return false;
784
- }
785
- } else {
786
- if (data.type)
787
- if (data.type != this.type) {
788
- return false;
789
- }
790
-
791
- if (data._type)
792
- if (data._type != this.type) {
793
- return false;
794
- }
795
- }
796
- return true;
797
- } catch (e) {
798
- throw e;
799
- }
800
- }
801
-
802
- async update(args) {
803
- // Profiling: use track() for proper async context forking
804
- const p = this[_ctx]?.profiler;
805
-
806
- const doUpdate = async () => {
807
- this.updated_at = new SDate().now();
808
- let results = await this.database.get(args.id);
809
- let item_exist = await this.exist(results);
810
- if (!item_exist) {
811
- throw new Error(`${this.type} does not exist. in update`);
812
- }
813
- delete results["id"];
814
- _.defaults(this, results);
815
- let cover_obj = {
816
- old: results,
817
- new: this,
818
- id: args.id,
819
- };
820
-
821
- let form;
822
- if (args.skip_write_serialize != true && args.skip_serialize != true) {
823
- cover_obj.new = await this.do_serialize(
824
- this,
825
- "write",
826
- cover_obj.new,
827
- args,
828
- );
829
- }
830
-
831
- if (args.skip_hooks != true) {
832
- await this.run_hook(cover_obj, "update", "before", results);
833
- }
834
- let db_data = cover_obj.new || this;
835
-
836
- if (p) {
837
- await p.track(`${this.type}.update.database`, async () => {
838
- await this.database.update(args.id, db_data, args._ttl);
839
- });
840
- } else {
841
- await this.database.update(args.id, db_data, args._ttl);
842
- }
843
- this.setMonitor();
844
-
845
- if (args.skip_read_serialize != true && args.skip_serialize != true) {
846
- cover_obj.new = await this.do_serialize(
847
- cover_obj.new,
848
- "read",
849
- {},
850
- args,
851
- await this.propsToBeRemoved(cover_obj.new),
852
- );
853
- }
854
-
855
- if (args.skip_hooks != true) {
856
- await this.run_hook(
857
- {
858
- ...this,
859
- id: args.id,
860
- },
861
- "update",
862
- "after",
863
- results,
864
- );
865
- }
866
- this.id = args.id;
867
- return { ...cover_obj.new, id: args.id };
868
- };
869
-
870
- try {
871
- if (p) {
872
- return await p.track(`${this.type}.update`, doUpdate, { id: args?.id });
873
- }
874
- return await doUpdate();
875
- } catch (e) {
876
- console.warn("Error on update", e, e.stack);
877
- throw e;
878
- }
879
- }
880
-
881
- async create(args = {}) {
882
- // Profiling: use track() for proper async context forking
883
- const p = this[_ctx]?.profiler;
884
-
885
- const doCreate = async () => {
886
- let form;
887
- this.created_at = new SDate().now();
888
- args = _.defaults(args, { id_prefix: this.type });
889
- if (args.body) {
890
- form = _.defaults({}, args.body);
891
- form.created_at = this.created_at;
892
- form.updated_at = this.created_at;
893
- delete form["bucket"];
894
- }
895
- let workingForm = form || this;
896
- this.updated_at = new SDate().now();
897
-
898
- let id = `${args.id_prefix}-${UUID.v4()}`;
899
- if (args && args.id) {
900
- id = args.id;
901
- }
902
-
903
- if (args.skip_write_serialize != true && args.skip_serialize != true) {
904
- workingForm = await this.do_serialize(workingForm, "write", {}, args);
905
- }
906
-
907
- if (args.skip_hooks != true) {
908
- await this.run_hook(workingForm, "create", "before");
909
- }
910
-
911
- let results;
912
- if (p) {
913
- results = await p.track(`${this.type}.create.database`, async () => {
914
- return await this.database.insert(id, workingForm, args._ttl);
915
- });
916
- } else {
917
- results = await this.database.insert(id, workingForm, args._ttl);
918
- }
919
- this.setMonitor();
920
-
921
- if (args.skip_read_serialize != true && args.skip_serialize != true) {
922
- results = await this.do_serialize(
923
- results,
924
- "read",
925
- {},
926
- args,
927
- await this.propsToBeRemoved(results),
928
- );
929
- }
930
- if (args.skip_hooks != true) {
931
- await this.run_hook(
932
- {
933
- ...results,
934
- id,
935
- },
936
- "create",
937
- "after",
938
- );
939
- }
940
- return { ...results, id };
941
- };
942
-
943
- try {
944
- if (p) {
945
- return await p.track(`${this.type}.create`, doCreate);
946
- }
947
- return await doCreate();
948
- } catch (e) {
949
- console.warn(e.stack);
950
- throw e;
951
- }
952
- }
953
-
954
- async touch(args) {
955
- let item_exist = await this.exist(args.id);
956
-
957
- if (!item_exist) {
958
- throw new Error(`${this.type} does not exist.`);
959
- }
960
- try {
961
- let touch_response = {};
962
- touch_response = await this.database.touch(args.id, args._ttl);
963
- return touch_response;
964
- } catch (e) {
965
- console.warn(e.stack);
966
- throw e;
967
- }
968
- }
969
-
970
- async delete(args) {
971
- // Profiling: use track() for proper async context forking
972
- const p = this[_ctx]?.profiler;
973
-
974
- const doDelete = async () => {
975
- let item_exist = await this.exist(args.id);
976
-
977
- if (!item_exist) {
978
- throw new Error(`${this.type} does not exist.`);
979
- }
980
- let results = await this.database.get(args.id);
981
- if (args.skip_hooks != true) {
982
- await this.run_hook(args, "delete", "before");
983
- }
984
- let delete_response = {};
985
-
986
- const dbOperation = async () => {
987
- if (args.hard) {
988
- delete_response = await this.database.delete(args.id);
989
- this.setMonitor();
990
- } else {
991
- delete results["id"];
992
- results.deleted = true;
993
- delete_response = await this.database.update(args.id, results);
994
- }
995
- };
996
-
997
- if (p) {
998
- await p.track(`${this.type}.delete.database`, dbOperation);
999
- } else {
1000
- await dbOperation();
1001
- }
1002
-
1003
- if (args.skip_hooks != true) {
1004
- await this.run_hook(results, "delete", "after", results);
1005
- }
1006
- return {};
1007
- };
1008
-
1009
- try {
1010
- if (p) {
1011
- return await p.track(`${this.type}.delete`, doDelete, { id: args?.id });
1012
- }
1013
- return await doDelete();
1014
- } catch (e) {
1015
- console.warn(e.stack);
1016
- throw e;
1017
- }
1018
- }
1019
-
1020
- toString() {
1021
- JSON.stringify(this);
1022
- }
1023
-
1024
- buildArrayProjection(alias, field) {
1025
- const safeAlias = String(alias).replace(/`/g, "");
1026
- const safeField = String(field).replace(/`/g, "");
1027
- return `ARRAY rc.${safeField} FOR rc IN IFMISSINGORNULL(${safeAlias}, []) END AS \`${safeAlias}_${safeField}\``;
1028
- }
1029
-
1030
- prepColumns(columns, protectedAliases = [], arrayAliases = []) {
1031
- if (!columns || columns === "") return columns;
1032
-
1033
- const q = (s = "") => {
1034
- const t = (s || "").trim();
1035
- if (t.startsWith("`") && t.endsWith("`")) return t;
1036
- return "`" + t.replace(/`/g, "``") + "`";
1037
- };
1038
-
1039
- const protectedSet = new Set(protectedAliases);
1040
- const arraySet = new Set(arrayAliases);
1041
-
1042
- const tokens = columns.split(",");
1043
-
1044
- const out = tokens.map((raw) => {
1045
- let col = (raw || "").trim();
1046
- if (col === "" || col === "meta().id") return undefined;
1047
-
1048
- if (/^\s*ARRAY\s+/i.test(col) || /\w+\s*\(/.test(col)) return col;
1049
-
1050
- const m = col.match(
1051
- /^\s*`?(\w+)`?\.`?(\w+)`?(?:\s+AS\s+`?([\w]+)`?)?\s*$/i,
1052
- );
1053
- if (m) {
1054
- const alias = m[1];
1055
- const field = m[2];
1056
- const explicitAs = m[3];
1057
-
1058
- // If alias matches this.type, don't prepend again
1059
- if (alias === this.type) {
1060
- const qualified = `${q(alias)}.${q(field)}`;
1061
- return explicitAs ? `${qualified} AS ${q(explicitAs)}` : qualified;
1062
- }
1063
-
1064
- // Check if alias has a map - if so, simplify to root column only (raw ID)
1065
- const prop = this.props[alias];
1066
- if (prop?.map) {
1067
- // Return base table qualified root column, ignoring the .field part
1068
- return `${q(this.type)}.${q(alias)}`;
1069
- }
1070
-
1071
- if (arraySet.has(alias)) {
1072
- let proj = this.buildArrayProjection(alias, field);
1073
- if (explicitAs && explicitAs !== `${alias}_${field}`) {
1074
- proj = proj.replace(/AS\s+`[^`]+`$/i, `AS ${q(explicitAs)}`);
1075
- }
1076
- return proj;
1077
- }
1078
-
1079
- if (protectedSet.has(alias)) {
1080
- const aliased = `${q(alias)}.${field.startsWith("`") ? field : q(field)}`;
1081
- return explicitAs ? `${aliased} AS ${q(explicitAs)}` : aliased;
1082
- }
1083
-
1084
- const qualified = `${q(this.type)}.${q(alias)}.${q(field)}`;
1085
- return explicitAs ? `${qualified} AS ${q(explicitAs)}` : qualified;
1086
- }
1087
-
1088
- const looksProtected = [...protectedSet].some(
1089
- (a) =>
1090
- col === a ||
1091
- col === q(a) ||
1092
- col.startsWith(`${a}.`) ||
1093
- col.startsWith(`${q(a)}.`),
1094
- );
1095
-
1096
- if (!looksProtected) {
1097
- if (!col.includes(".")) {
1098
- const fieldQuoted = col.startsWith("`") ? col : q(col);
1099
- return `${q(this.type)}.${fieldQuoted}`;
1100
- }
1101
-
1102
- const parts = col.split(".");
1103
- const safeParts = parts.map((p) => {
1104
- const t = p.trim();
1105
- if (t === "" || /\w+\s*\(/.test(t)) return t;
1106
- return t.startsWith("`") ? t : q(t);
1107
- });
1108
- return `${q(this.type)}.${safeParts.join(".")}`;
1109
- }
1110
-
1111
- // For protected aliases (joined resources), return the base table's column (IDs)
1112
- // instead of the joined resource itself. The JOIN is only for WHERE filtering,
1113
- // and the serializer will map the IDs later.
1114
- const unquotedCol = col.replace(/`/g, "");
1115
- if (protectedSet.has(unquotedCol)) {
1116
- return `${q(this.type)}.${q(unquotedCol)}`;
1117
- }
1118
-
1119
- if (!col.includes(".") && !col.startsWith("`")) {
1120
- return q(col);
1121
- }
1122
- return col;
1123
- });
1124
-
1125
- // Deduplicate columns (e.g., when multiple mapped dot-notations share the same root)
1126
- return _.join(_.uniq(_.compact(out)), ",");
1127
- }
1128
-
1129
- filterResultsByColumns(data, columns) {
1130
- if (columns && columns !== "") {
1131
- // Remove backticks and replace meta().id with id
1132
- const cleanedColumns = columns
1133
- .replace(/`/g, "")
1134
- .replace(/meta\(\)\.id/g, "id");
1135
-
1136
- // Process each column by splitting on comma and trimming
1137
- const columnList = cleanedColumns
1138
- .split(",")
1139
- .map((col) => col.trim())
1140
- .filter((col) => col !== "") // PATCH: Remove empty column names
1141
- .map((col) => {
1142
- // Check for alias with AS (case-insensitive)
1143
- const aliasMatch = col.match(/\s+AS\s+([\w]+)/i);
1144
- if (aliasMatch) {
1145
- return aliasMatch[1].trim();
1146
- }
1147
- // Otherwise, if a dot is present, take the part after the last dot
1148
- if (col.includes(".")) {
1149
- return col.split(".").pop().trim();
1150
- }
1151
- return col;
1152
- });
1153
-
1154
- // Ensure that essential keys are always picked
1155
- const requiredKeys = ["_permissions", "_permissions_", "id"];
1156
- const finalKeys = [...new Set([...columnList, ...requiredKeys])];
1157
-
1158
- return data.map((entry) => _.pick(entry, finalKeys));
1159
- }
1160
- return data;
1161
- }
1162
-
1163
- extractNestings(string, localType) {
1164
- let returnVal = [];
1165
-
1166
- // Extract loop variables from ANY/EVERY expressions
1167
- // Loop variables (e.g., "vote" in "EVERY vote IN committee_votes") should be excluded
1168
- // But the collection being iterated (e.g., "committee_votes") should be INCLUDED
1169
- // if it's a joinable mapped property - buildJoinMetadata will filter non-joinables
1170
- let anyEveryRegex = /(ANY|EVERY)\s+(\w+)\s+IN\s+`?(\w+)`?/gi;
1171
- let anyEveryMatch;
1172
- const loopVariables = new Set();
1173
- const anyEveryCollections = new Set();
1174
-
1175
- while ((anyEveryMatch = anyEveryRegex.exec(string)) !== null) {
1176
- loopVariables.add(anyEveryMatch[2]); // e.g., "vote" - exclude from nestings
1177
- anyEveryCollections.add(anyEveryMatch[3]); // e.g., "committee_votes" - include in nestings
1178
- }
1179
-
1180
- // Now extract dot notation patterns, but skip loop variables
1181
- let regex = /(`?\w+`?)\.(`?\w+`?)/g;
1182
- let match;
1183
-
1184
- while ((match = regex.exec(string)) !== null) {
1185
- let first = match[1].replace(/`/g, "");
1186
- // Skip if it's the local type or a loop variable from ANY/EVERY
1187
- if (first !== localType && !loopVariables.has(first)) {
1188
- returnVal.push(first);
1189
- }
1190
- }
1191
-
1192
- // Add collections from ANY/EVERY expressions as potential nestings
1193
- // buildJoinMetadata will filter out non-joinable ones (embedded arrays vs mapped collections)
1194
- for (const collection of anyEveryCollections) {
1195
- if (collection !== localType) {
1196
- returnVal.push(collection);
1197
- }
1198
- }
1199
-
1200
- return [...new Set(returnVal)];
1201
- }
1202
-
1203
- createJoinSection(mappedNestings) {
1204
- if (!mappedNestings || mappedNestings.length === 0) return "";
1205
-
1206
- return mappedNestings
1207
- .map(({ alias, reference, is_array }) => {
1208
- const keyspace = fixCollection(reference);
1209
- if (is_array === true) {
1210
- return `LEFT NEST \`${keyspace}\` AS \`${alias}\` ON KEYS \`${this.type}\`.\`${alias}\``;
1211
- } else {
1212
- return `LEFT JOIN \`${keyspace}\` AS \`${alias}\` ON KEYS \`${this.type}\`.\`${alias}\``;
1213
- }
1214
- })
1215
- .join(" ");
1216
- }
1217
-
1218
- formatSortComponent(sortComponent) {
1219
- const parts = sortComponent.split(" ");
1220
- parts[0] = `\`${parts[0]}\``;
1221
- return parts.join(" ");
1222
- }
1223
-
1224
- /**
1225
- * Builds join metadata from nestings for SQL joins and prepares columns.
1226
- * @param {string[]} nestings - Array of alias names extracted from query/columns/sort
1227
- * @param {Object} args - Arguments object containing columns (will be mutated with prepared columns)
1228
- * @returns {Object} - { mappedNestings, protectedAliases, arrayAliases }
1229
- */
1230
- buildJoinMetadata(nestings, args) {
1231
- // Normalize any double backticks to single backticks early (handles URL encoding issues)
1232
- if (args.columns && typeof args.columns === "string") {
1233
- args.columns = args.columns.replace(/``/g, "`");
1234
- }
1235
-
1236
- // Decide which aliases we can join: only when map.type===MODEL AND reference is a STRING keyspace.
1237
- const mappedNestings = _.compact(
1238
- _.uniq(nestings).map((alias) => {
1239
- const prop = this.props[alias];
1240
- if (!prop?.map || prop.map.type !== MapType.MODEL) return null;
1241
-
1242
- const ref = prop.map.reference;
1243
- if (typeof ref !== "string") {
1244
- // reference is a class/function/array-of-classes → no SQL join; serializer will handle it
1245
- return null;
1246
- }
1247
-
1248
- const is_array =
1249
- prop.type === "array" ||
1250
- prop.type === Array ||
1251
- prop.type === DataType.ARRAY;
1252
-
1253
- return {
1254
- alias,
1255
- reference: ref.toLowerCase(), // keyspace to join
1256
- is_array,
1257
- type: prop.type,
1258
- value_field: prop.map.value_field,
1259
- destination: prop.map.destination || alias,
1260
- };
1261
- }),
1262
- );
1263
-
1264
- const protectedAliases = mappedNestings.map((m) => m.alias);
1265
- const arrayAliases = mappedNestings
1266
- .filter((m) => m.is_array)
1267
- .map((m) => m.alias);
1268
-
1269
- // Columns: first prepare (prefix base table, rewrite array alias.field → ARRAY proj),
1270
- // then normalize names and add default aliases
1271
- //console.log("Columns in BuildJoinMetadata", args.columns);
1272
-
1273
- this[_columns] = args.columns ?? "";
1274
-
1275
- args.columns = this.prepColumns(
1276
- args.columns,
1277
- protectedAliases,
1278
- arrayAliases,
1279
- );
1280
-
1281
- args.columns = this.fixColumnName(args.columns, protectedAliases);
1282
- //console.log("Columns in BuildJoinMetadata after fixColumnName", args.columns);
1283
- return { mappedNestings, protectedAliases, arrayAliases };
1284
- }
1285
-
1286
- /* removeSpaceAndSpecialCharacters(str) {
1287
- return str.replace(/[^a-zA-Z0-9]/g, "");
1288
- } */
1289
-
1290
- fixColumnName(columns, protectedAliases = []) {
1291
- if (!columns || typeof columns !== "string") return columns;
1292
- const protectedSet = new Set(protectedAliases);
1293
-
1294
- const tokens = columns.split(",").map((s) => s.trim());
1295
- const aliasTracker = {};
1296
-
1297
- const out = tokens.map((col) => {
1298
- if (!col) return undefined;
1299
-
1300
- // Do not rewrite ARRAY projections
1301
- if (/^\s*ARRAY\s+/i.test(col)) return col;
1302
-
1303
- // If token is literally this.type.alias → compress to `alias`
1304
- for (const a of protectedSet) {
1305
- const re = new RegExp(
1306
- "^`?" +
1307
- _.escapeRegExp(this.type) +
1308
- "`?\\.`?" +
1309
- _.escapeRegExp(a) +
1310
- "`?$",
1311
- );
1312
- if (re.test(col)) {
1313
- return `\`${a}\``;
1314
- }
1315
- }
1316
-
1317
- // Extract explicit AS
1318
- const aliasRegex = /\s+AS\s+`?([\w]+)`?$/i;
1319
- const aliasMatch = col.match(aliasRegex);
1320
- const explicitAlias = aliasMatch ? aliasMatch[1] : null;
1321
- const colWithoutAlias =
1322
- explicitAlias ? col.replace(aliasRegex, "").trim() : col;
1323
-
1324
- // `table`.`col` or table.col
1325
- const columnRegex = /^`?([\w]+)`?\.`?([\w]+)`?$/;
1326
- const match = colWithoutAlias.match(columnRegex);
1327
-
1328
- let tableName = this.type;
1329
- let columnName = colWithoutAlias.replace(/`/g, "");
1330
- if (match) {
1331
- tableName = match[1];
1332
- columnName = match[2];
1333
- }
1334
-
1335
- // For joined table columns, add default alias <table_col> if none
1336
- if (tableName && tableName !== this.type) {
1337
- let newAlias = explicitAlias || `${tableName}`;
1338
- if (!explicitAlias) {
1339
- if (aliasTracker.hasOwnProperty(newAlias)) {
1340
- aliasTracker[newAlias]++;
1341
- newAlias = `${newAlias}`;
1342
- } else {
1343
- aliasTracker[newAlias] = 0;
1344
- }
1345
- return `${colWithoutAlias} AS \`${newAlias}\``;
1346
- }
1347
- }
1348
-
1349
- // If column is already qualified with base table (e.g., `user`.`field`), return as-is
1350
- if (tableName === this.type && match) {
1351
- return col;
1352
- }
1353
-
1354
- // If column is already backtick-quoted, return as-is
1355
- if (col.startsWith("`") && col.endsWith("`")) {
1356
- return col;
1357
- }
1358
- return `\`${col}\``;
1359
- });
1360
-
1361
- return _.join(_.compact(out), ", ");
1362
- }
1363
-
1364
- async list(args = {}) {
1365
- // Profiling: use track() for proper async context forking
1366
- const p = this[_ctx]?.profiler;
1367
-
1368
- const doList = async () => {
1369
- if (args.mapping_dept) this[_mapping_dept] = args.mapping_dept;
1370
- if (args.mapping_dept_exempt)
1371
- this[_mapping_dept_exempt] = args.mapping_dept_exempt;
1372
-
1373
- // Find alias tokens from query/columns/sort
1374
- const nestings = [
1375
- ...this.extractNestings(args?.query || "", this.type),
1376
- //...this.extractNestings(args?.columns || "", this.type),
1377
- ...this.extractNestings(args?.sort || "", this.type),
1378
- ];
1379
-
1380
- // Build join metadata and prepare columns
1381
- const { mappedNestings } = this.buildJoinMetadata(nestings, args);
1382
-
1383
- // Build JOIN/NEST from the mapped keyspaces
1384
- args._join = this.createJoinSection(mappedNestings);
1385
-
1386
- // WHERE
1387
- let query = "";
1388
- const deletedCondition = `(\`${this.type}\`.deleted = false OR \`${this.type}\`.deleted IS MISSING)`;
1389
- if (args.is_full_text === "true" || args.is_custom_query === "true") {
1390
- query = args.query || "";
1391
- } else if (args.filters) {
1392
- query = this.makeQueryFromFilter(args.filters);
1393
- } else if (args.query) {
1394
- query = `${args.query} AND ${deletedCondition}`;
1395
- } else {
1396
- query = deletedCondition;
1397
- }
1398
-
1399
- if (hasSQLInjection(query)) return [];
1400
-
1401
- // LIMIT/OFFSET/SORT
1402
- args.limit = Number(args.limit) || undefined;
1403
- args.offset = Number(args.offset) || 0;
1404
- args.sort =
1405
- args.sort ?
1406
- args.sort
1407
- .split(",")
1408
- .map((item) =>
1409
- item.includes(".") ? item : (
1410
- `\`${this.type}\`.${this.formatSortComponent(item)}`
1411
- ),
1412
- )
1413
- .join(",")
1414
- : `\`${this.type}\`.created_at DESC`;
1415
-
1416
- if (args.skip_hooks !== true) {
1417
- await this.run_hook(this, "list", "before");
1418
- }
1419
-
1420
- const cacheKey = `list::${this.type}::${args._join}::${query}::${args.limit}::${args.offset}::${args.sort}::${args.do_count}::${args.statement_consistent}::${args.columns}::${args.is_full_text}::${args.is_custom_query}`;
1421
- let results;
1422
-
1423
- if (this.shouldUseCache(this.type)) {
1424
- const cached = await this.getCacheProviderObject(this.type).get(
1425
- cacheKey,
1426
- );
1427
- results = cached?.value;
1428
- const isEmpty = cached?.value === undefined;
1429
- const refresh = await this.shouldForceRefresh(cached);
1430
- if (isEmpty || refresh) {
1431
- results = await this.fetchResults(args, query);
1432
- this.getCacheProviderObject(this.type).set(
1433
- cacheKey,
1434
- { value: results, time: new Date().getTime() },
1435
- this.getCacheConfig(this.type),
1436
- );
1437
- }
1438
- } else {
1439
- results = await this.fetchResults(args, query);
1440
- }
1441
-
1442
- // Serializer still handles class-based refs and value_field
1443
- if (args.skip_read_serialize !== true && args.skip_serialize !== true) {
1444
- results.data = await this.do_serialize(
1445
- results.data,
1446
- "read",
1447
- {},
1448
- args,
1449
- await this.propsToBeRemoved(results.data),
1450
- );
1451
- }
1452
-
1453
- if (args.skip_hooks !== true) {
1454
- await this.run_hook(results.data, "list", "after");
1455
- }
1456
-
1457
- results.data = this.filterResultsByColumns(results.data, args.columns);
1458
- //console.log("results.data", results.data);
1459
- return results;
1460
- };
1461
-
1462
- try {
1463
- if (p) {
1464
- return await p.track(`${this.type}.list`, doList, {
1465
- limit: args?.limit,
1466
- offset: args?.offset,
1467
- });
1468
- }
1469
- return await doList();
1470
- } catch (e) {
1471
- console.warn(e.stack);
1472
- throw e;
1473
- }
1474
- }
1475
-
1476
- async fetchResults(args, query) {
1477
- // Profiling: use track() for proper async context forking
1478
- const p = this[_ctx]?.profiler;
1479
-
1480
- const doFetch = async () => {
1481
- if (args.is_custom_query === "true" && args.ids.length > 0) {
1482
- return await this.database.query(query);
1483
- } else if (args.is_full_text === "true") {
1484
- return await this.database.full_text_search(
1485
- this.type,
1486
- query || "",
1487
- args.limit,
1488
- args.offset,
1489
- args._join,
1490
- );
1491
- } else {
1492
- let result = await this.database.search(
1493
- this.type,
1494
- args.columns || "",
1495
- query || "",
1496
- args.limit,
1497
- args.offset,
1498
- args.sort,
1499
- args.do_count,
1500
- args.statement_consistent,
1501
- args._join,
1502
- );
1503
- return result;
1504
- }
1505
- };
1506
-
1507
- if (p) {
1508
- return await p.track(`${this.type}.fetchResults`, doFetch);
1509
- }
1510
- return await doFetch();
1511
- }
1512
-
1513
- addHook({ operation, when, execute }) {
1514
- this[_hooks][operation][when].push(execute);
1515
- }
1516
-
1517
- async run_hook(data, op, when, old_data) {
1518
- try {
1519
- if (this[_disable_lifecycle_events] == false) {
1520
- let resourceLifecycleTriggered = new ResourceLifecycleTriggered({
1521
- data: {
1522
- data,
1523
- operation: op,
1524
- when,
1525
- old_data,
1526
- resource: this.type,
1527
- ctx: this[_ctx],
1528
- },
1529
- });
1530
- resourceLifecycleTriggered.dispatch();
1531
- }
1532
- if (this[_hooks] && this[_hooks][op] && this[_hooks][op][when]) {
1533
- for (let i of this[_hooks][op][when]) {
1534
- data = await i(data, old_data);
1535
- }
1536
- }
1537
- return data;
1538
- } catch (e) {
1539
- throw e;
1540
- }
1541
- }
1542
-
1543
- shouldSerializerRun(args, type) {
1544
- if (args) {
1545
- if (args.skip_serialize == true) {
1546
- return false;
1547
- }
1548
- if (type == "read") {
1549
- if (args.skip_read_serialize == true) {
1550
- return false;
1551
- }
1552
- } else if (type == "write") {
1553
- if (args.skip_write_serialize == true) {
1554
- return false;
1555
- }
1556
- }
1557
- }
1558
- return true;
1559
- }
1560
-
1561
- // Check if a field is exempt from mapping depth limits
1562
- // Supports deep references like "group.permissions"
1563
- isFieldExempt(source_property) {
1564
- const currentPath = this[_current_path];
1565
- const fullPath =
1566
- currentPath ? `${currentPath}.${source_property}` : source_property;
1567
-
1568
- return this[_mapping_dept_exempt].some((exemptPattern) => {
1569
- // Exact match: "group" or "group.permissions"
1570
- if (exemptPattern === source_property || exemptPattern === fullPath) {
1571
- return true;
1572
- }
1573
- // Pattern starts with current full path (e.g., "group.permissions" starts with "group")
1574
- if (exemptPattern.startsWith(fullPath + ".")) {
1575
- return true;
1576
- }
1577
- // Full path starts with pattern (e.g., we're at "group.permissions" and "group" is exempt)
1578
- if (fullPath.startsWith(exemptPattern + ".")) {
1579
- return true;
1580
- }
1581
- return false;
1582
- });
1583
- }
1584
-
1585
- async mapToObject(
1586
- data,
1587
- Class,
1588
- source_property,
1589
- store_property,
1590
- property,
1591
- args,
1592
- type,
1593
- mapping_dept,
1594
- level,
1595
- columns,
1596
- ) {
1597
- // ⚡ Get profiler for proper async context forking
1598
- const p = this[_ctx]?.profiler;
1599
- // create a array of all columns in args.columns skipping the first_segment of the Column string and pull out all the columns of where the new first segment matches source_property
1600
- // Create a set of columns where the first segment matches source_property
1601
- let columnArray = [];
1602
- if (columns) {
1603
- columns.split(",").forEach((col) => {
1604
- const parts = col.trim().split("."); // remove the first segment
1605
- // Also match if parts[0] is source_property or '`' + source_property + '`'
1606
- const firstPart = parts[0];
1607
- // Remove backticks if present
1608
- const cleanFirstPart =
1609
- firstPart && firstPart.startsWith("`") && firstPart.endsWith("`") ?
1610
- firstPart.slice(1, -1)
1611
- : firstPart;
1612
-
1613
- if (
1614
- parts.length > 0 &&
1615
- (firstPart === source_property || cleanFirstPart === source_property)
1616
- ) {
1617
- // Use cleanFirstPart for the map key to handle both cases consistently
1618
- // Remove backticks from each column segment, if present, before joining
1619
- columnArray.push(
1620
- parts
1621
- .slice(1)
1622
- .map((segment) =>
1623
- segment && segment.startsWith("`") && segment.endsWith("`") ?
1624
- segment.slice(1, -1)
1625
- : segment,
1626
- )
1627
- .join("."),
1628
- );
1629
- }
1630
- });
1631
- }
1632
- let original_is_array = _.isArray(data);
1633
- if (!original_is_array) {
1634
- data = Array.of(data);
1635
- }
1636
- const isExempt = this.isFieldExempt(source_property);
1637
- if (isExempt || this[_level] + 1 < this[_mapping_dept]) {
1638
- let classes = _.compact(_.isArray(Class) ? Class : [Class]);
1639
-
1640
- let ids = [];
1641
- _.each(data, (result) => {
1642
- let value = result[source_property];
1643
-
1644
- // Check if value is in the empty string key (column aliasing issue)
1645
- if (
1646
- (value === undefined || (_.isObject(value) && _.isEmpty(value))) &&
1647
- result[""] &&
1648
- result[""][source_property]
1649
- ) {
1650
- value = result[""][source_property];
1651
- }
1652
-
1653
- if (_.isString(value) && value !== "") {
1654
- // PATCH: Use strict inequality
1655
- // Value is a raw ID string
1656
- ids = _.union(ids, [value]);
1657
- } else if (
1658
- _.isObject(value) &&
1659
- _.isString(value.id) &&
1660
- value.id !== ""
1661
- ) {
1662
- // PATCH: Use strict inequality
1663
- // Value is already a joined object with an id field
1664
- ids = _.union(ids, [value.id]);
1665
- }
1666
- });
1667
-
1668
- // PATCH: Remove empty strings, nulls, and deduplicate IDs
1669
- ids = _.compact(_.uniq(ids));
1670
-
1671
- // PATCH: Early exit if no valid IDs
1672
- if (ids.length === 0) {
1673
- return original_is_array ? data : data[0];
1674
- }
1675
-
1676
- // Build the path for child models
1677
- const childPath =
1678
- this[_current_path] ?
1679
- `${this[_current_path]}.${source_property}`
1680
- : source_property;
1681
-
1682
- // ⚡ Wrap in profiler track() to ensure proper async context for child operations
1683
- const fetchRelated = async () => {
1684
- return await Promise.allSettled(
1685
- _.map(classes, (obj) => {
1686
- let objInstance = new obj({
1687
- ...this[_args],
1688
- skip_cache: this[_skip_cache],
1689
- _level: this[_level] + 1,
1690
- mapping_dept: this[_mapping_dept],
1691
- mapping_dept_exempt: this[_mapping_dept_exempt],
1692
- _columns: columnArray.join(","),
1693
- _current_path: childPath,
1694
- });
1695
-
1696
- return objInstance.getMulti({
1697
- skip_hooks: true,
1698
- ids: ids,
1699
- columns: columnArray,
1700
- });
1701
- }),
1702
- );
1703
- };
1704
-
1705
- var returned_all;
1706
- if (p && ids.length > 0) {
1707
- returned_all = await p.track(
1708
- `${this.type}.map.${source_property}`,
1709
- fetchRelated,
1710
- { ids_count: ids.length },
1711
- );
1712
- } else {
1713
- returned_all = await fetchRelated();
1714
- }
1715
-
1716
- let ug = _.flatten(
1717
- _.compact(
1718
- _.map(returned_all, (returned_obj) => {
1719
- if (returned_obj.status == "fulfilled") {
1720
- return returned_obj.value;
1721
- }
1722
- }),
1723
- ),
1724
- );
1725
-
1726
- /* if(source_property == "group") {
1727
- console.log("Returned All", source_property, store_property, ug);
1728
- } */
1729
-
1730
- data = _.map(data, (result) => {
1731
- let sourceValue = result[source_property];
1732
-
1733
- // Check if value is in the empty string key (column aliasing issue)
1734
- if (
1735
- (sourceValue === undefined ||
1736
- (_.isObject(sourceValue) && _.isEmpty(sourceValue))) &&
1737
- result[""] &&
1738
- result[""][source_property]
1739
- ) {
1740
- sourceValue = result[""][source_property];
1741
- }
1742
-
1743
- // Get the ID to match against - either a string ID or the id property of an object
1744
- const sourceId =
1745
- _.isString(sourceValue) ? sourceValue
1746
- : _.isObject(sourceValue) ? sourceValue.id
1747
- : null;
1748
- // PATCH: Use strict equality to prevent type coercion issues
1749
- let result_found =
1750
- _.find(ug, (g) => {
1751
- return g.id === sourceId && sourceId !== null && sourceId !== "";
1752
- }) || {};
1753
- result[store_property] = result_found;
1754
- return result;
1755
- });
1756
- }
1757
- return original_is_array ? data : data[0];
1758
- }
1759
-
1760
- async mapToObjectArray(
1761
- data,
1762
- Class,
1763
- source_property,
1764
- store_property,
1765
- property,
1766
- args,
1767
- type,
1768
- mapping_dept,
1769
- level,
1770
- columns,
1771
- ) {
1772
- // ⚡ Get profiler for proper async context forking
1773
- const p = this[_ctx]?.profiler;
1774
-
1775
- let columnArray = [];
1776
- if (columns) {
1777
- columns.split(",").forEach((col) => {
1778
- const parts = col.trim().split("."); // remove the first segment
1779
- // Also match if parts[0] is source_property or '`' + source_property + '`'
1780
- const firstPart = parts[0];
1781
- // Remove backticks if present
1782
- const cleanFirstPart =
1783
- firstPart && firstPart.startsWith("`") && firstPart.endsWith("`") ?
1784
- firstPart.slice(1, -1)
1785
- : firstPart;
1786
-
1787
- if (
1788
- parts.length > 0 &&
1789
- (firstPart === source_property || cleanFirstPart === source_property)
1790
- ) {
1791
- // Use cleanFirstPart for the map key to handle both cases consistently
1792
- // Remove backticks from each column segment, if present, before joining
1793
- columnArray.push(
1794
- parts
1795
- .slice(1)
1796
- .map((segment) =>
1797
- segment && segment.startsWith("`") && segment.endsWith("`") ?
1798
- segment.slice(1, -1)
1799
- : segment,
1800
- )
1801
- .join("."),
1802
- );
1803
- }
1804
- });
1805
- }
1806
-
1807
- let original_is_array = _.isArray(data);
1808
- if (!original_is_array) {
1809
- data = Array.of(data);
1810
- }
1811
- const isExempt = this.isFieldExempt(source_property);
1812
- if (isExempt || this[_level] + 1 < this[_mapping_dept]) {
1813
- let ids = [];
1814
- _.each(data, (result) => {
1815
- let value = [];
1816
-
1817
- if (_.isArray(result[source_property])) {
1818
- value = result[source_property];
1819
- }
1820
-
1821
- if (_.isString(result[source_property])) {
1822
- value = [result[source_property]];
1823
- }
1824
-
1825
- // Extract IDs - handle both string IDs and objects with id property
1826
- let items = _.compact(
1827
- _.map(value, (obj) => {
1828
- if (_.isString(obj) && obj !== "") {
1829
- // PATCH: Use strict inequality
1830
- return obj;
1831
- } else if (_.isObject(obj) && _.isString(obj.id) && obj.id !== "") {
1832
- // PATCH: Use strict inequality
1833
- return obj.id;
1834
- }
1835
- return null;
1836
- }),
1837
- );
1838
- ids = _.union(ids, items);
1839
- });
1840
-
1841
- // PATCH: Deduplicate IDs to prevent redundant queries
1842
- ids = _.uniq(ids);
1843
-
1844
- // PATCH: Early exit if no valid items
1845
- if (ids.length === 0) {
1846
- return original_is_array ? data : data[0];
1847
- }
1848
-
1849
- // Build the path for child models
1850
- const childPath =
1851
- this[_current_path] ?
1852
- `${this[_current_path]}.${source_property}`
1853
- : source_property;
1854
-
1855
- let classes = _.compact(_.isArray(Class) ? Class : [Class]);
1856
-
1857
- // ⚡ Wrap in profiler track() to ensure proper async context for child operations
1858
- const fetchRelated = async () => {
1859
- return await Promise.allSettled(
1860
- _.map(classes, (obj) => {
1861
- return new obj({
1862
- ...this[_args],
1863
- skip_cache: this[_skip_cache],
1864
- _level: this[_level] + 1,
1865
- mapping_dept: this[_mapping_dept],
1866
- mapping_dept_exempt: this[_mapping_dept_exempt],
1867
- _columns: columnArray.join(","),
1868
- _current_path: childPath,
1869
- }).getMulti({
1870
- skip_hooks: true,
1871
- ids: ids,
1872
- columns: columnArray,
1873
- });
1874
- }),
1875
- );
1876
- };
1877
-
1878
- var returned_all;
1879
- if (p && ids.length > 0) {
1880
- returned_all = await p.track(
1881
- `${this.type}.mapArray.${source_property}`,
1882
- fetchRelated,
1883
- { ids_count: ids.length },
1884
- );
1885
- } else {
1886
- returned_all = await fetchRelated();
1887
- }
1888
-
1889
- var returned_objects = _.flatten(
1890
- _.compact(
1891
- _.map(returned_all, (returned_obj) => {
1892
- if (returned_obj.status == "fulfilled") return returned_obj.value;
1893
- }),
1894
- ),
1895
- );
1896
-
1897
- _.each(data, (result) => {
1898
- if (_.isString(result[store_property])) {
1899
- result[store_property] = [result[store_property]];
1900
- }
1901
-
1902
- if (!_.has(result, source_property)) {
1903
- result[source_property] = [];
1904
- return;
1905
- }
1906
-
1907
- result[store_property] = _.map(result[source_property], (item) => {
1908
- // Get the ID to match - either a string ID or the id property of an object
1909
- const itemId =
1910
- _.isString(item) ? item
1911
- : _.isObject(item) ? item.id
1912
- : null;
1913
- return _.find(returned_objects, (p) => p.id === itemId);
1914
- });
1915
- result[store_property] = _.reject(
1916
- result[store_property],
1917
- (obj) => obj === null || obj === undefined,
1918
- );
1919
- });
1920
- }
1921
- return original_is_array ? data : data[0];
1922
- }
1923
-
1924
- addModifier({ when, execute }) {
1925
- if (this[_serializers][when]) {
1926
- this[_serializers][when]["modifiers"].push(execute);
1927
- }
1928
- }
1929
-
1930
- createModifiersFromProps() {
1931
- this.createMofifier(this.props);
1932
- this.addExternalModifiers();
1933
- }
1934
-
1935
- addExternalModifiers(scope = "*") {
1936
- let that = this;
1937
- _.each([...spice.getModifiers(scope)], function (modifier) {
1938
- that.addModifier(modifier);
1939
- });
1940
- }
1941
-
1942
- /**
1943
- * Extracts the first segment of each column path for database queries.
1944
- * e.g., ["name", "permissions.permission"] → ["name", "permissions"]
1945
- * @param {string[]} columns - Array of column paths
1946
- * @returns {string[]} - Array of base column names (deduplicated)
1947
- */
1948
- extractBaseColumns(columns) {
1949
- if (!columns || !Array.isArray(columns)) return columns;
1950
- return [
1951
- ...new Set(
1952
- columns.map((col) => {
1953
- const firstDot = col.indexOf(".");
1954
- return firstDot > -1 ? col.substring(0, firstDot) : col;
1955
- }),
1956
- ),
1957
- ];
1958
- }
1959
-
1960
- /**
1961
- * Checks if a field is present in the columns string.
1962
- * Used to skip modifier execution when the field isn't requested.
1963
- * @param {string} fieldName - The field name to check for
1964
- * @param {string} columns - Comma-separated column string
1965
- * @returns {boolean} - True if field is in columns or columns is empty (fetch all)
1966
- */
1967
- isFieldInColumns(fieldName, columns) {
1968
- // No columns filter = include all
1969
- if (!columns || columns === "") return true;
1970
-
1971
- const tokens = columns.split(",");
1972
- for (const col of tokens) {
1973
- const trimmed = col.trim();
1974
- // Check if this column starts with the field name (exact match or nested like "fieldName.subfield")
1975
- const firstPart = trimmed.split(".")[0];
1976
- // Handle backtick-wrapped column names
1977
- const cleanFirstPart =
1978
- firstPart.startsWith("`") && firstPart.endsWith("`") ?
1979
- firstPart.slice(1, -1)
1980
- : firstPart;
1981
-
1982
- if (cleanFirstPart === fieldName) {
1983
- return true;
1984
- }
1985
- }
1986
- return false;
1987
- }
1988
-
1989
- createMofifier(properties) {
1990
- for (let i in properties) {
1991
- if (properties[i].map) {
1992
- switch (properties[i].map.type) {
1993
- case MapType.MODEL: {
1994
- switch (properties[i].type) {
1995
- case String:
1996
- case "string": {
1997
- this.addModifier({
1998
- when: properties[i].map.when || "read",
1999
- execute: async (
2000
- data,
2001
- old_data,
2002
- ctx,
2003
- type,
2004
- args,
2005
- mapping_dept,
2006
- level,
2007
- columns,
2008
- ) => {
2009
- // Skip if columns are specified but this field isn't included
2010
- if (!this.isFieldInColumns(i, columns)) {
2011
- return data;
2012
- }
2013
- return await this.mapToObject(
2014
- data,
2015
- _.isString(properties[i].map.reference) ?
2016
- spice.models[properties[i].map.reference]
2017
- : properties[i].map.reference,
2018
- i,
2019
- properties[i].map.destination || i,
2020
- properties[i],
2021
- args,
2022
- type,
2023
- mapping_dept,
2024
- level,
2025
- columns,
2026
- );
2027
- },
2028
- });
2029
- break;
2030
- }
2031
- case Array:
2032
- case "array": {
2033
- this.addModifier({
2034
- when: properties[i].map.when || "read",
2035
- execute: async (
2036
- data,
2037
- old_data,
2038
- ctx,
2039
- type,
2040
- args,
2041
- mapping_dept,
2042
- level,
2043
- columns,
2044
- ) => {
2045
- // Skip if columns are specified but this field isn't included
2046
- if (!this.isFieldInColumns(i, columns)) {
2047
- return data;
2048
- }
2049
- return await this.mapToObjectArray(
2050
- data,
2051
- _.isString(properties[i].map.reference) ?
2052
- spice.models[properties[i].map.reference]
2053
- : properties[i].map.reference,
2054
- i,
2055
- properties[i].map.destination || i,
2056
- properties[i],
2057
- args,
2058
- type,
2059
- mapping_dept,
2060
- level,
2061
- columns,
2062
- );
2063
- },
2064
- });
2065
- break;
2066
- }
2067
- }
2068
- break;
2069
- }
2070
- case MapType.LOOKUP: {
2071
- break;
2072
- }
2073
- }
2074
- }
2075
- }
2076
- }
2077
-
2078
- async do_serialize(data, type, old_data, args, path_to_be_removed = []) {
2079
- // Profiling: use track() for proper async context forking
2080
- const p = this[_ctx]?.profiler;
2081
-
2082
- const doSerialize = async () => {
2083
- // Early exit if serialization should not run.
2084
- if (!this.shouldSerializerRun(args, type)) {
2085
- return data;
2086
- }
2087
-
2088
- // Add external modifiers only once.
2089
- if (this.type && !this[_external_modifier_loaded]) {
2090
- this.addExternalModifiers(this.type);
2091
- this[_external_modifier_loaded] = true;
2092
- }
2093
-
2094
- // Cache the modifiers lookup for the specified type.
2095
- const modifiers = this[_serializers]?.[type]?.modifiers || [];
2096
- for (let i = 0; i < modifiers.length; i++) {
2097
- const modifier = modifiers[i];
2098
- try {
2099
- const result = await modifier(
2100
- data,
2101
- old_data,
2102
- this[_ctx],
2103
- this.type,
2104
- args,
2105
- this[_mapping_dept],
2106
- this[_level],
2107
- this[_columns],
2108
- );
2109
- // Guard against modifiers that return undefined
2110
- if (result !== undefined) {
2111
- data = result;
2112
- } else {
2113
- console.warn(
2114
- `Modifier #${i} for type=${this.type} returned undefined, keeping previous data`,
2115
- );
2116
- }
2117
- } catch (error) {
2118
- console.error(
2119
- `Modifier error in do_serialize (type=${this.type}, modifier #${i}):`,
2120
- error instanceof Error ?
2121
- error.stack
2122
- : `Non-Error thrown: ${JSON.stringify(error)}`,
2123
- );
2124
- }
2125
- }
2126
-
2127
- // Ensure data is always an array for consistent processing.
2128
- const originalIsArray = Array.isArray(data);
2129
- if (!originalIsArray) {
2130
- data = [data];
2131
- }
2132
-
2133
- // Compute the defaults from properties using reduce.
2134
- const defaults = Object.keys(this.props).reduce((acc, key) => {
2135
- const def = this.props[key]?.defaults?.[type];
2136
- if (def !== undefined) {
2137
- acc[key] =
2138
- _.isFunction(def) ?
2139
- def({ old_data: data, new_data: old_data })
2140
- : def;
2141
- }
2142
- return acc;
2143
- }, {});
2144
-
2145
- // Merge defaults into each object.
2146
- data = data.map((item) => _.defaults(item, defaults));
2147
-
2148
- // If type is "read", clean the data by omitting certain props.
2149
- if (type === "read") {
2150
- // Collect hidden properties from schema.
2151
- const hiddenProps = Object.keys(this.props).filter(
2152
- (key) => this.props[key]?.hide,
2153
- );
2154
- // Combine default props to remove.
2155
- const propsToClean = [
2156
- "deleted",
2157
- "type",
2158
- "collection",
2159
- ...path_to_be_removed,
2160
- ...hiddenProps,
2161
- ];
2162
- data = data.map((item) => _.omit(item, propsToClean));
2163
- }
2164
-
2165
- // Return in the original format (array or single object).
2166
- return originalIsArray ? data : data[0];
2167
- };
2168
-
2169
- try {
2170
- if (p) {
2171
- return await p.track(`${this.type}.do_serialize`, doSerialize, {
2172
- type,
2173
- });
2174
- }
2175
- return await doSerialize();
2176
- } catch (error) {
2177
- console.error(
2178
- "Error in do_serialize:",
2179
- error instanceof Error ?
2180
- error.stack
2181
- : `Non-Error thrown: ${JSON.stringify(error)}`,
2182
- );
2183
- throw error;
2184
- }
2185
- }
2186
- }