spice-js 2.7.40 → 2.7.41

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/build/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  exports.__esModule = true;
4
- exports.status = exports.default = exports.addTask = exports.Storage = exports.SpiceModel = exports.SpiceEventDriver = exports.SpiceCache = exports.SocketEventDriver = exports.Serializer = exports.RestHelper = exports.ResourceReloader = exports.ResourceLifecycleTriggered = exports.MapType = exports.MailFile = exports.MailDebug = exports.Mail = exports.LocalStorage = exports.EventDebugger = exports.Event = exports.DebugStorage = exports.DataType = exports.Crypt = exports.AI = void 0;
4
+ exports.status = exports.default = exports.addTask = exports.Storage = exports.SpiceModel = exports.SpiceEventDriver = exports.SpiceCache = exports.SocketEventDriver = exports.Serializer = exports.RestHelper = exports.ResourceReloader = exports.ResourceLifecycleTriggered = exports.MapType = exports.MailFile = exports.MailDebug = exports.Mail = exports.LocalStorage = exports.IndexKeys = exports.EventDebugger = exports.Event = exports.DebugStorage = exports.DataType = exports.Crypt = exports.AI = void 0;
5
5
  var _Event = _interopRequireDefault(require("./events/Event"));
6
6
  exports.Event = _Event.default;
7
7
  var _EventDebugger = _interopRequireDefault(require("./events/providers/EventDebugger"));
@@ -20,6 +20,8 @@ var _Serializer = _interopRequireDefault(require("./utility/Serializer"));
20
20
  exports.Serializer = _Serializer.default;
21
21
  var _RestHelper = _interopRequireDefault(require("./utility/RestHelper"));
22
22
  exports.RestHelper = _RestHelper.default;
23
+ var _IndexKeys = _interopRequireDefault(require("./utility/IndexKeys"));
24
+ exports.IndexKeys = _IndexKeys.default;
23
25
  var _ResourceReloader = _interopRequireDefault(require("./utility/ResourceReloader"));
24
26
  exports.ResourceReloader = _ResourceReloader.default;
25
27
  var _Status = _interopRequireDefault(require("./utility/Status"));
@@ -7,6 +7,7 @@ var _ResourceLifecycleTriggered = _interopRequireDefault(require("../events/even
7
7
  var _Security = require("../utility/Security");
8
8
  var _fix = require("../utility/fix");
9
9
  var _propertyDefaults = require("../utility/propertyDefaults");
10
+ var _IndexKeys = require("../utility/IndexKeys");
10
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
12
  function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) { ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } } return n; }, _extends.apply(null, arguments); }
12
13
  function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); }
@@ -775,7 +776,13 @@ class SpiceModel {
775
776
  return {
776
777
  scan_consistency: scanConsistency,
777
778
  consistent_with: args.consistent_with,
778
- scan_wait: args.scan_wait
779
+ scan_wait: args.scan_wait,
780
+ // With scan_wait: read without waiting when the index is not current in time, instead of
781
+ // failing. For reads where a slightly stale answer is better than an error.
782
+ scan_wait_fallback: args.scan_wait_fallback,
783
+ // Shows up as clientContextID in system:completed_requests, so a slow statement can be
784
+ // traced back to the route or job that ran it.
785
+ client_context_id: args.client_context_id
779
786
  };
780
787
  }
781
788
  requiresFreshQuery(args) {
@@ -1468,15 +1475,17 @@ class SpiceModel {
1468
1475
  // Build JOIN/NEST from the mapped keyspaces
1469
1476
  args._join = _this10.createJoinSection(mappedNestings);
1470
1477
 
1471
- // WHERE
1478
+ // WHERE. The caller's query is parenthesised so a top-level OR cannot swallow the live
1479
+ // condition, which has to stay a top-level conjunct for the partial indexes to apply.
1472
1480
  var query = "";
1473
- var deletedCondition = "(`" + _this10.type + "`.deleted = false OR `" + _this10.type + "`.deleted IS MISSING)";
1481
+ var deletedCondition = (0, _IndexKeys.liveCondition)(_this10.type);
1474
1482
  if (args.is_full_text === "true" || args.is_custom_query === "true") {
1475
1483
  query = args.query || "";
1476
1484
  } else if (args.filters) {
1477
- query = _this10.makeQueryFromFilter(args.filters);
1485
+ var filterQuery = _this10.makeQueryFromFilter(args.filters);
1486
+ query = filterQuery ? "(" + filterQuery + ") AND " + deletedCondition : deletedCondition;
1478
1487
  } else if (args.query) {
1479
- query = args.query + " AND " + deletedCondition;
1488
+ query = "(" + args.query + ") AND " + deletedCondition;
1480
1489
  } else {
1481
1490
  query = deletedCondition;
1482
1491
  }
@@ -1485,7 +1494,8 @@ class SpiceModel {
1485
1494
  // LIMIT/OFFSET/SORT
1486
1495
  args.limit = Number(args.limit) || undefined;
1487
1496
  args.offset = Number(args.offset) || 0;
1488
- args.sort = args.sort ? args.sort.split(",").map(item => item.includes(".") ? item : "`" + _this10.type + "`." + _this10.formatSortComponent(item)).join(",") : "`" + _this10.type + "`.created_at DESC";
1497
+ var joinAliases = (mappedNestings || []).map(nesting => nesting.alias);
1498
+ args.sort = (0, _IndexKeys.orderByClause)(args.sort, _this10.type, joinAliases) || "`" + _this10.type + "`.`created_at` DESC";
1489
1499
  if (args.skip_hooks !== true) {
1490
1500
  yield _this10.run_hook(_this10, "list", "before");
1491
1501
  }
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * One definition of the live (not soft-deleted) predicate and of how a sort becomes ORDER BY
5
+ * terms and index keys. SpiceModel builds queries with it and the application's index planner
6
+ * builds indexes with it, so a query and the index meant to serve it can never drift apart.
7
+ *
8
+ * Couchbase cannot scan an index backwards and a plain ascending key puts MISSING and NULL first,
9
+ * so `ASC NULLS LAST` (what the UI sends) is expressed as `IFMISSINGORNULL(f, {})`: an empty
10
+ * object sorts after every scalar and array, which is exactly "empties last".
11
+ */
12
+ exports.__esModule = true;
13
+ exports.default = exports.LIVE_INDEX_CONDITION = void 0;
14
+ exports.fieldIndexExpression = fieldIndexExpression;
15
+ exports.liveCondition = liveCondition;
16
+ exports.orderByClause = orderByClause;
17
+ exports.orderByTerms = orderByTerms;
18
+ exports.parseSort = parseSort;
19
+ exports.sortIndexKeys = sortIndexKeys;
20
+ exports.usesSentinel = usesSentinel;
21
+ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) { ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } } return n; }, _extends.apply(null, arguments); }
22
+ var SEGMENT = /^[A-Za-z0-9_$-]+$/;
23
+ var ITEM = /^(\S+?)(?:\s+(ASC|DESC))?(?:\s+NULLS\s+(FIRST|LAST))?$/i;
24
+ var SENTINEL = "{}";
25
+ function quoteSegment(segment) {
26
+ return "`" + segment + "`";
27
+ }
28
+
29
+ /** `deleted` = false or MISSING passes; true or NULL does not. No OR, so it can never be split
30
+ * into a UnionScan, and partial indexes can use the exact same text as their condition. */
31
+ function liveCondition(alias) {
32
+ var field = alias ? quoteSegment(alias) + ".`deleted`" : "`deleted`";
33
+ return "IFMISSING(" + field + ", false) = false";
34
+ }
35
+
36
+ /** The partial-index condition every managed list index carries. */
37
+ var LIVE_INDEX_CONDITION = exports.LIVE_INDEX_CONDITION = liveCondition();
38
+ function parsePath(raw) {
39
+ var segments = String(raw).replace(/`/g, "").split(".");
40
+ if (segments.length === 0 || !segments.every(segment => SEGMENT.test(segment))) {
41
+ return null;
42
+ }
43
+ return segments;
44
+ }
45
+ function parseItem(item) {
46
+ if (item && typeof item === "object") {
47
+ var _ref, _item$property, _ref2, _item$order;
48
+ var _path = parsePath((_ref = (_item$property = item.property) != null ? _item$property : item.field) != null ? _ref : "");
49
+ if (!_path) return null;
50
+ var direction = String((_ref2 = (_item$order = item.order) != null ? _item$order : item.direction) != null ? _ref2 : "ASC").toUpperCase() === "DESC" ? "DESC" : "ASC";
51
+ var nulls = item.nulls ? String(item.nulls).toUpperCase() : undefined;
52
+ return {
53
+ path: _path,
54
+ direction,
55
+ nulls: nulls === "FIRST" || nulls === "LAST" ? nulls : undefined
56
+ };
57
+ }
58
+ var text = String(item != null ? item : "").trim().replace(/\s+/g, " ");
59
+ if (!text) return null;
60
+ var match = ITEM.exec(text);
61
+ if (!match) return null;
62
+ var path = parsePath(match[1]);
63
+ if (!path) return null;
64
+ return {
65
+ path,
66
+ direction: (match[2] || "ASC").toUpperCase(),
67
+ nulls: match[3] ? match[3].toUpperCase() : undefined
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Parses `"a DESC NULLS LAST, b"`, `[{ property, order }]` or an array of strings into
73
+ * `[{ path, direction, nulls }]`. Items that are not a plain field path with an optional
74
+ * direction and nulls clause are dropped: the sort is interpolated into the statement and was
75
+ * never injection-checked.
76
+ */
77
+ function parseSort(sort) {
78
+ if (sort === undefined || sort === null || sort === "") return [];
79
+ var items = Array.isArray(sort) ? sort : String(sort).split(",");
80
+ var parsed = [];
81
+ for (var item of items) {
82
+ var result = parseItem(item);
83
+ if (result) {
84
+ parsed.push(result);
85
+ } else if (String(item != null ? item : "").trim() !== "") {
86
+ console.warn("[spice] ignoring unsupported sort item: " + JSON.stringify(item));
87
+ }
88
+ }
89
+ return parsed;
90
+ }
91
+
92
+ /** Whether the item needs the empties-placement expression instead of the plain field. */
93
+ function usesSentinel(item) {
94
+ return item.direction === "ASC" && item.nulls === "LAST" || item.direction === "DESC" && item.nulls === "FIRST";
95
+ }
96
+ function fieldReference(path, alias) {
97
+ var quoted = path.map(quoteSegment).join(".");
98
+ return alias ? quoteSegment(alias) + "." + quoted : quoted;
99
+ }
100
+ function sortExpression(item, alias) {
101
+ var field = fieldReference(item.path, alias);
102
+ return usesSentinel(item) ? "IFMISSINGORNULL(" + field + ", " + SENTINEL + ")" : field;
103
+ }
104
+
105
+ /**
106
+ * ORDER BY terms for parsed items. `alias` qualifies local fields; a path whose first segment is
107
+ * in `joinAliases` already names a joined keyspace and is left as is. A first segment equal to
108
+ * the alias is treated as an explicit qualification of a local field.
109
+ */
110
+ function orderByTerms(items, alias, joinAliases) {
111
+ if (joinAliases === void 0) {
112
+ joinAliases = [];
113
+ }
114
+ var joins = new Set(joinAliases);
115
+ return items.map(item => {
116
+ var path = item.path;
117
+ var qualifier = alias;
118
+ if (path.length > 1 && path[0] === alias) {
119
+ path = path.slice(1);
120
+ } else if (path.length > 1 && joins.has(path[0])) {
121
+ qualifier = undefined;
122
+ }
123
+ return sortExpression(_extends({}, item, {
124
+ path
125
+ }), qualifier) + " " + item.direction;
126
+ });
127
+ }
128
+ function orderByClause(sort, alias, joinAliases) {
129
+ if (joinAliases === void 0) {
130
+ joinAliases = [];
131
+ }
132
+ return orderByTerms(parseSort(sort), alias, joinAliases).join(", ");
133
+ }
134
+
135
+ /**
136
+ * Index keys for parsed items, unqualified, in sort order: `{ expr, desc, field, sentinel }`.
137
+ * `field` is the plain dotted path, which the planner also adds as a trailing key for every
138
+ * sentinel key so a flipped header sort is still covered by the same index.
139
+ */
140
+ function sortIndexKeys(items) {
141
+ return items.map(item => ({
142
+ expr: sortExpression(item),
143
+ desc: item.direction === "DESC",
144
+ field: item.path.join("."),
145
+ sentinel: usesSentinel(item)
146
+ }));
147
+ }
148
+
149
+ /** An unqualified, backtick-quoted reference to a dotted field path, as an index key. */
150
+ function fieldIndexExpression(fieldPath) {
151
+ var path = parsePath(fieldPath);
152
+ return path ? fieldReference(path) : null;
153
+ }
154
+ var _default = exports.default = {
155
+ liveCondition,
156
+ LIVE_INDEX_CONDITION,
157
+ parseSort,
158
+ usesSentinel,
159
+ orderByTerms,
160
+ orderByClause,
161
+ sortIndexKeys,
162
+ fieldIndexExpression
163
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spice-js",
3
- "version": "2.7.40",
3
+ "version": "2.7.41",
4
4
  "description": "spice",
5
5
  "main": "build/index.js",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -27,6 +27,8 @@ export { default as Serializer } from "./utility/Serializer";
27
27
 
28
28
  export { default as RestHelper } from "./utility/RestHelper";
29
29
 
30
+ export { default as IndexKeys } from "./utility/IndexKeys";
31
+
30
32
  export { default as ResourceReloader } from "./utility/ResourceReloader";
31
33
  import Status from "./utility/Status";
32
34
  export { default as Mail } from "./mail/Mail";
@@ -7,6 +7,7 @@ import ResourceLifecycleTriggered from "../events/events/ResourceLifecycleTrigge
7
7
  import { hasSQLInjection } from "../utility/Security";
8
8
  import { fixCollection } from "../utility/fix";
9
9
  import { applyPropertyDefaults } from "../utility/propertyDefaults";
10
+ import { liveCondition, orderByClause } from "../utility/IndexKeys";
10
11
 
11
12
  var SDate = require("sonover-date"),
12
13
  UUID = require("uuid"),
@@ -849,6 +850,12 @@ export default class SpiceModel {
849
850
  scan_consistency: scanConsistency,
850
851
  consistent_with: args.consistent_with,
851
852
  scan_wait: args.scan_wait,
853
+ // With scan_wait: read without waiting when the index is not current in time, instead of
854
+ // failing. For reads where a slightly stale answer is better than an error.
855
+ scan_wait_fallback: args.scan_wait_fallback,
856
+ // Shows up as clientContextID in system:completed_requests, so a slow statement can be
857
+ // traced back to the route or job that ran it.
858
+ client_context_id: args.client_context_id,
852
859
  };
853
860
  }
854
861
 
@@ -1628,15 +1635,17 @@ export default class SpiceModel {
1628
1635
  // Build JOIN/NEST from the mapped keyspaces
1629
1636
  args._join = this.createJoinSection(mappedNestings);
1630
1637
 
1631
- // WHERE
1638
+ // WHERE. The caller's query is parenthesised so a top-level OR cannot swallow the live
1639
+ // condition, which has to stay a top-level conjunct for the partial indexes to apply.
1632
1640
  let query = "";
1633
- const deletedCondition = `(\`${this.type}\`.deleted = false OR \`${this.type}\`.deleted IS MISSING)`;
1641
+ const deletedCondition = liveCondition(this.type);
1634
1642
  if (args.is_full_text === "true" || args.is_custom_query === "true") {
1635
1643
  query = args.query || "";
1636
1644
  } else if (args.filters) {
1637
- query = this.makeQueryFromFilter(args.filters);
1645
+ const filterQuery = this.makeQueryFromFilter(args.filters);
1646
+ query = filterQuery ? `(${filterQuery}) AND ${deletedCondition}` : deletedCondition;
1638
1647
  } else if (args.query) {
1639
- query = `${args.query} AND ${deletedCondition}`;
1648
+ query = `(${args.query}) AND ${deletedCondition}`;
1640
1649
  } else {
1641
1650
  query = deletedCondition;
1642
1651
  }
@@ -1646,17 +1655,10 @@ export default class SpiceModel {
1646
1655
  // LIMIT/OFFSET/SORT
1647
1656
  args.limit = Number(args.limit) || undefined;
1648
1657
  args.offset = Number(args.offset) || 0;
1658
+ const joinAliases = (mappedNestings || []).map((nesting) => nesting.alias);
1649
1659
  args.sort =
1650
- args.sort ?
1651
- args.sort
1652
- .split(",")
1653
- .map((item) =>
1654
- item.includes(".") ? item : (
1655
- `\`${this.type}\`.${this.formatSortComponent(item)}`
1656
- ),
1657
- )
1658
- .join(",")
1659
- : `\`${this.type}\`.created_at DESC`;
1660
+ orderByClause(args.sort, this.type, joinAliases) ||
1661
+ `\`${this.type}\`.\`created_at\` DESC`;
1660
1662
 
1661
1663
  if (args.skip_hooks !== true) {
1662
1664
  await this.run_hook(this, "list", "before");
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * One definition of the live (not soft-deleted) predicate and of how a sort becomes ORDER BY
5
+ * terms and index keys. SpiceModel builds queries with it and the application's index planner
6
+ * builds indexes with it, so a query and the index meant to serve it can never drift apart.
7
+ *
8
+ * Couchbase cannot scan an index backwards and a plain ascending key puts MISSING and NULL first,
9
+ * so `ASC NULLS LAST` (what the UI sends) is expressed as `IFMISSINGORNULL(f, {})`: an empty
10
+ * object sorts after every scalar and array, which is exactly "empties last".
11
+ */
12
+
13
+ const SEGMENT = /^[A-Za-z0-9_$-]+$/;
14
+ const ITEM = /^(\S+?)(?:\s+(ASC|DESC))?(?:\s+NULLS\s+(FIRST|LAST))?$/i;
15
+ const SENTINEL = "{}";
16
+
17
+ function quoteSegment(segment) {
18
+ return `\`${segment}\``;
19
+ }
20
+
21
+ /** `deleted` = false or MISSING passes; true or NULL does not. No OR, so it can never be split
22
+ * into a UnionScan, and partial indexes can use the exact same text as their condition. */
23
+ export function liveCondition(alias) {
24
+ const field = alias ? `${quoteSegment(alias)}.\`deleted\`` : "`deleted`";
25
+ return `IFMISSING(${field}, false) = false`;
26
+ }
27
+
28
+ /** The partial-index condition every managed list index carries. */
29
+ export const LIVE_INDEX_CONDITION = liveCondition();
30
+
31
+ function parsePath(raw) {
32
+ const segments = String(raw).replace(/`/g, "").split(".");
33
+ if (segments.length === 0 || !segments.every((segment) => SEGMENT.test(segment))) {
34
+ return null;
35
+ }
36
+ return segments;
37
+ }
38
+
39
+ function parseItem(item) {
40
+ if (item && typeof item === "object") {
41
+ const path = parsePath(item.property ?? item.field ?? "");
42
+ if (!path) return null;
43
+ const direction = String(item.order ?? item.direction ?? "ASC").toUpperCase() === "DESC" ? "DESC" : "ASC";
44
+ const nulls = item.nulls ? String(item.nulls).toUpperCase() : undefined;
45
+ return { path, direction, nulls: nulls === "FIRST" || nulls === "LAST" ? nulls : undefined };
46
+ }
47
+
48
+ const text = String(item ?? "").trim().replace(/\s+/g, " ");
49
+ if (!text) return null;
50
+ const match = ITEM.exec(text);
51
+ if (!match) return null;
52
+ const path = parsePath(match[1]);
53
+ if (!path) return null;
54
+ return {
55
+ path,
56
+ direction: (match[2] || "ASC").toUpperCase(),
57
+ nulls: match[3] ? match[3].toUpperCase() : undefined,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Parses `"a DESC NULLS LAST, b"`, `[{ property, order }]` or an array of strings into
63
+ * `[{ path, direction, nulls }]`. Items that are not a plain field path with an optional
64
+ * direction and nulls clause are dropped: the sort is interpolated into the statement and was
65
+ * never injection-checked.
66
+ */
67
+ export function parseSort(sort) {
68
+ if (sort === undefined || sort === null || sort === "") return [];
69
+ const items = Array.isArray(sort) ? sort : String(sort).split(",");
70
+ const parsed = [];
71
+ for (const item of items) {
72
+ const result = parseItem(item);
73
+ if (result) {
74
+ parsed.push(result);
75
+ } else if (String(item ?? "").trim() !== "") {
76
+ console.warn(`[spice] ignoring unsupported sort item: ${JSON.stringify(item)}`);
77
+ }
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ /** Whether the item needs the empties-placement expression instead of the plain field. */
83
+ export function usesSentinel(item) {
84
+ return (item.direction === "ASC" && item.nulls === "LAST") || (item.direction === "DESC" && item.nulls === "FIRST");
85
+ }
86
+
87
+ function fieldReference(path, alias) {
88
+ const quoted = path.map(quoteSegment).join(".");
89
+ return alias ? `${quoteSegment(alias)}.${quoted}` : quoted;
90
+ }
91
+
92
+ function sortExpression(item, alias) {
93
+ const field = fieldReference(item.path, alias);
94
+ return usesSentinel(item) ? `IFMISSINGORNULL(${field}, ${SENTINEL})` : field;
95
+ }
96
+
97
+ /**
98
+ * ORDER BY terms for parsed items. `alias` qualifies local fields; a path whose first segment is
99
+ * in `joinAliases` already names a joined keyspace and is left as is. A first segment equal to
100
+ * the alias is treated as an explicit qualification of a local field.
101
+ */
102
+ export function orderByTerms(items, alias, joinAliases = []) {
103
+ const joins = new Set(joinAliases);
104
+ return items.map((item) => {
105
+ let path = item.path;
106
+ let qualifier = alias;
107
+ if (path.length > 1 && path[0] === alias) {
108
+ path = path.slice(1);
109
+ } else if (path.length > 1 && joins.has(path[0])) {
110
+ qualifier = undefined;
111
+ }
112
+ return `${sortExpression({ ...item, path }, qualifier)} ${item.direction}`;
113
+ });
114
+ }
115
+
116
+ export function orderByClause(sort, alias, joinAliases = []) {
117
+ return orderByTerms(parseSort(sort), alias, joinAliases).join(", ");
118
+ }
119
+
120
+ /**
121
+ * Index keys for parsed items, unqualified, in sort order: `{ expr, desc, field, sentinel }`.
122
+ * `field` is the plain dotted path, which the planner also adds as a trailing key for every
123
+ * sentinel key so a flipped header sort is still covered by the same index.
124
+ */
125
+ export function sortIndexKeys(items) {
126
+ return items.map((item) => ({
127
+ expr: sortExpression(item),
128
+ desc: item.direction === "DESC",
129
+ field: item.path.join("."),
130
+ sentinel: usesSentinel(item),
131
+ }));
132
+ }
133
+
134
+ /** An unqualified, backtick-quoted reference to a dotted field path, as an index key. */
135
+ export function fieldIndexExpression(fieldPath) {
136
+ const path = parsePath(fieldPath);
137
+ return path ? fieldReference(path) : null;
138
+ }
139
+
140
+ export default {
141
+ liveCondition,
142
+ LIVE_INDEX_CONDITION,
143
+ parseSort,
144
+ usesSentinel,
145
+ orderByTerms,
146
+ orderByClause,
147
+ sortIndexKeys,
148
+ fieldIndexExpression,
149
+ };
@@ -0,0 +1,58 @@
1
+ const { createTestModel } = require("../helpers/modelFactory");
2
+
3
+ const LIVE = "IFMISSING(`application`.`deleted`, false) = false";
4
+
5
+ function stubSearch(model) {
6
+ model.database.search = jest.fn(async () => ({ data: [], total: null }));
7
+ return model.database.search;
8
+ }
9
+
10
+ async function listWith(args) {
11
+ const model = createTestModel({ type: "application" });
12
+ const search = stubSearch(model);
13
+ await model.list({ skip_hooks: true, skip_serialize: true, ...args });
14
+ const [, , query, , , sort, , , , options] = search.mock.calls[0];
15
+ return { query, sort, options };
16
+ }
17
+
18
+ describe("SpiceModel list query shape", () => {
19
+ test("a bare list filters on the live condition and sorts newest first", async () => {
20
+ const { query, sort } = await listWith({});
21
+ expect(query).toBe(LIVE);
22
+ expect(sort).toBe("`application`.`created_at` DESC");
23
+ });
24
+
25
+ test("a caller query with a top-level OR is parenthesised before the live condition", async () => {
26
+ const { query } = await listWith({ query: "status = 'a' OR status = 'b'" });
27
+ expect(query).toBe(`(status = 'a' OR status = 'b') AND ${LIVE}`);
28
+ });
29
+
30
+ test("the filters path also excludes soft-deleted records", async () => {
31
+ const { query } = await listWith({ filters: { status: ["open"] } });
32
+ expect(query).toBe(`( status LIKE 'open') AND ${LIVE}`);
33
+ });
34
+
35
+ test("custom queries are passed through untouched", async () => {
36
+ const model = createTestModel({ type: "application" });
37
+ model.database.query = jest.fn(async () => ({ data: [] }));
38
+ await model.list({ is_custom_query: "true", ids: ["x"], query: "SELECT 1", skip_hooks: true, skip_serialize: true });
39
+ expect(model.database.query.mock.calls[0][0]).toBe("SELECT 1");
40
+ });
41
+
42
+ test("a multi-column UI sort becomes index-matching ORDER BY terms", async () => {
43
+ const { sort } = await listWith({ sort: "priority DESC NULLS LAST, due_at ASC NULLS LAST" });
44
+ expect(sort).toBe("`application`.`priority` DESC, IFMISSINGORNULL(`application`.`due_at`, {}) ASC");
45
+ });
46
+
47
+ test("an unparseable sort falls back to the default instead of reaching the statement", async () => {
48
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
49
+ const { sort } = await listWith({ sort: "created_at; DELETE" });
50
+ expect(sort).toBe("`application`.`created_at` DESC");
51
+ warn.mockRestore();
52
+ });
53
+
54
+ test("the client context id is forwarded to the driver", async () => {
55
+ const { options } = await listWith({ client_context_id: "scheduler.due" });
56
+ expect(options.client_context_id).toBe("scheduler.due");
57
+ });
58
+ });
@@ -0,0 +1,67 @@
1
+ const {
2
+ liveCondition,
3
+ LIVE_INDEX_CONDITION,
4
+ parseSort,
5
+ orderByClause,
6
+ sortIndexKeys,
7
+ fieldIndexExpression,
8
+ } = require("../../src/utility/IndexKeys");
9
+
10
+ describe("IndexKeys", () => {
11
+ test("live condition has no OR and matches the partial index text", () => {
12
+ expect(liveCondition("app")).toBe("IFMISSING(`app`.`deleted`, false) = false");
13
+ expect(LIVE_INDEX_CONDITION).toBe("IFMISSING(`deleted`, false) = false");
14
+ expect(liveCondition("app")).not.toMatch(/\bOR\b/);
15
+ });
16
+
17
+ test("parses multi-column sorts, trimming items the old split left padded", () => {
18
+ expect(parseSort("priority DESC NULLS LAST, due_at ASC NULLS LAST,name")).toEqual([
19
+ { path: ["priority"], direction: "DESC", nulls: "LAST" },
20
+ { path: ["due_at"], direction: "ASC", nulls: "LAST" },
21
+ { path: ["name"], direction: "ASC", nulls: undefined },
22
+ ]);
23
+ });
24
+
25
+ test("accepts the saved { property, order } shape", () => {
26
+ expect(parseSort([{ property: "status", order: "DESC" }])).toEqual([
27
+ { path: ["status"], direction: "DESC", nulls: undefined },
28
+ ]);
29
+ });
30
+
31
+ test("drops items that are not a plain field path", () => {
32
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
33
+ expect(parseSort("name; DROP INDEX x, LOWER(name) ASC, created_at DESC")).toEqual([
34
+ { path: ["created_at"], direction: "DESC", nulls: undefined },
35
+ ]);
36
+ expect(warn).toHaveBeenCalledTimes(2);
37
+ warn.mockRestore();
38
+ });
39
+
40
+ test.each([
41
+ ["f DESC", "`t`.`f` DESC", { expr: "`f`", desc: true, sentinel: false }],
42
+ ["f DESC NULLS LAST", "`t`.`f` DESC", { expr: "`f`", desc: true, sentinel: false }],
43
+ ["f ASC NULLS LAST", "IFMISSINGORNULL(`t`.`f`, {}) ASC", { expr: "IFMISSINGORNULL(`f`, {})", desc: false, sentinel: true }],
44
+ ["f ASC", "`t`.`f` ASC", { expr: "`f`", desc: false, sentinel: false }],
45
+ ["f ASC NULLS FIRST", "`t`.`f` ASC", { expr: "`f`", desc: false, sentinel: false }],
46
+ ["f DESC NULLS FIRST", "IFMISSINGORNULL(`t`.`f`, {}) DESC", { expr: "IFMISSINGORNULL(`f`, {})", desc: true, sentinel: true }],
47
+ ])("%s orders as %s and indexes as %o", (sort, orderBy, key) => {
48
+ expect(orderByClause(sort, "t")).toBe(orderBy);
49
+ expect(sortIndexKeys(parseSort(sort))[0]).toEqual({ ...key, field: "f" });
50
+ });
51
+
52
+ test("qualifies local paths, strips an explicit alias and leaves joined aliases alone", () => {
53
+ expect(orderByClause("address.city ASC, t.created_at DESC, owner.name DESC", "t", ["owner"])).toBe(
54
+ "`t`.`address`.`city` ASC, `t`.`created_at` DESC, `owner`.`name` DESC",
55
+ );
56
+ });
57
+
58
+ test("an empty sort produces no clause so the caller can apply its default", () => {
59
+ expect(orderByClause("", "t")).toBe("");
60
+ expect(orderByClause(undefined, "t")).toBe("");
61
+ });
62
+
63
+ test("field index expressions are quoted paths and reject anything else", () => {
64
+ expect(fieldIndexExpression("address.city")).toBe("`address`.`city`");
65
+ expect(fieldIndexExpression("lower(name)")).toBeNull();
66
+ });
67
+ });