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 +3 -1
- package/build/models/SpiceModel.js +16 -6
- package/build/utility/IndexKeys.js +163 -0
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/models/SpiceModel.js +16 -14
- package/src/utility/IndexKeys.js +149 -0
- package/tests/models/SpiceModel.list-query.test.js +58 -0
- package/tests/utility/IndexKeys.test.js +67 -0
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
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";
|
package/src/models/SpiceModel.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
1645
|
+
const filterQuery = this.makeQueryFromFilter(args.filters);
|
|
1646
|
+
query = filterQuery ? `(${filterQuery}) AND ${deletedCondition}` : deletedCondition;
|
|
1638
1647
|
} else if (args.query) {
|
|
1639
|
-
query =
|
|
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
|
-
|
|
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
|
+
});
|