@lovrozagar/oat 0.10.0 → 0.11.0
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/README.md +25 -2006
- package/dist/cli.js +283 -59
- package/dist/config/define-config.d.ts +8 -0
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +86 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/conformance/effects.d.ts +9 -0
- package/dist/conformance/effects.d.ts.map +1 -0
- package/dist/conformance/effects.js +276 -0
- package/dist/conformance/effects.js.map +1 -0
- package/dist/conformance/expected-failures.d.ts +18 -0
- package/dist/conformance/expected-failures.d.ts.map +1 -0
- package/dist/conformance/expected-failures.js +48 -0
- package/dist/conformance/expected-failures.js.map +1 -0
- package/dist/conformance/feature-gate.d.ts +7 -0
- package/dist/conformance/feature-gate.d.ts.map +1 -0
- package/dist/conformance/feature-gate.js +428 -0
- package/dist/conformance/feature-gate.js.map +1 -0
- package/dist/conformance/fuzz.d.ts +108 -0
- package/dist/conformance/fuzz.d.ts.map +1 -0
- package/dist/conformance/fuzz.js +246 -0
- package/dist/conformance/fuzz.js.map +1 -0
- package/dist/conformance/leaks.d.ts +10 -0
- package/dist/conformance/leaks.d.ts.map +1 -0
- package/dist/conformance/leaks.js +23 -0
- package/dist/conformance/leaks.js.map +1 -0
- package/dist/conformance/pool.d.ts +49 -0
- package/dist/conformance/pool.d.ts.map +1 -0
- package/dist/conformance/pool.js +75 -0
- package/dist/conformance/pool.js.map +1 -0
- package/dist/conformance/rate-limit.d.ts +7 -0
- package/dist/conformance/rate-limit.d.ts.map +1 -0
- package/dist/conformance/rate-limit.js +372 -0
- package/dist/conformance/rate-limit.js.map +1 -0
- package/dist/conformance/scope.d.ts +15 -0
- package/dist/conformance/scope.d.ts.map +1 -0
- package/dist/conformance/scope.js +78 -0
- package/dist/conformance/scope.js.map +1 -0
- package/dist/conformance/seed-contract.d.ts +11 -0
- package/dist/conformance/seed-contract.d.ts.map +1 -0
- package/dist/conformance/seed-contract.js +680 -0
- package/dist/conformance/seed-contract.js.map +1 -0
- package/dist/conformance/shapes.d.ts +35 -0
- package/dist/conformance/shapes.d.ts.map +1 -0
- package/dist/conformance/shapes.js +162 -0
- package/dist/conformance/shapes.js.map +1 -0
- package/dist/conformance/specs.d.ts +21 -0
- package/dist/conformance/specs.d.ts.map +1 -0
- package/dist/conformance/specs.js +306 -0
- package/dist/conformance/specs.js.map +1 -0
- package/dist/conformance/suite.d.ts +209 -0
- package/dist/conformance/suite.d.ts.map +1 -0
- package/dist/conformance/suite.js +1538 -0
- package/dist/conformance/suite.js.map +1 -0
- package/dist/conformance/unique.d.ts +7 -0
- package/dist/conformance/unique.d.ts.map +1 -0
- package/dist/conformance/unique.js +586 -0
- package/dist/conformance/unique.js.map +1 -0
- package/dist/conformance/worker.d.ts +3 -0
- package/dist/conformance/worker.d.ts.map +1 -0
- package/dist/conformance/worker.js +27 -0
- package/dist/conformance/worker.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/reference/defects.d.ts +71 -1
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +74 -8
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/dialect.d.ts +6 -4
- package/dist/reference/dialect.d.ts.map +1 -1
- package/dist/reference/dialect.js +1 -0
- package/dist/reference/dialect.js.map +1 -1
- package/dist/reference/http.d.ts +17 -25
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +574 -224
- package/dist/reference/http.js.map +1 -1
- package/dist/reference/listing.d.ts +51 -0
- package/dist/reference/listing.d.ts.map +1 -0
- package/dist/reference/listing.js +256 -0
- package/dist/reference/listing.js.map +1 -0
- package/dist/reference/model.d.ts +35 -1
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +42 -0
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts +132 -43
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +397 -315
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/shapes.d.ts +60 -0
- package/dist/reference/shapes.d.ts.map +1 -0
- package/dist/reference/shapes.js +142 -0
- package/dist/reference/shapes.js.map +1 -0
- package/dist/reference/spec.d.ts +9 -2
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +291 -71
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts +14 -43
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +8 -58
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts +12 -15
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +22 -70
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts +11 -23
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +53 -334
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sql-compile.d.ts +36 -0
- package/dist/reference/stores/sql-compile.d.ts.map +1 -0
- package/dist/reference/stores/sql-compile.js +95 -0
- package/dist/reference/stores/sql-compile.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +12 -27
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +54 -355
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +23 -9
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts +2 -0
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +63 -10
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +10 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +75 -13
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/async.d.ts +16 -3
- package/dist/runtime/async.d.ts.map +1 -1
- package/dist/runtime/async.js +24 -19
- package/dist/runtime/async.js.map +1 -1
- package/dist/runtime/auth.d.ts +2 -9
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +38 -62
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts +15 -3
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +61 -12
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/check-costs.d.ts +15 -0
- package/dist/runtime/check-costs.d.ts.map +1 -0
- package/dist/runtime/check-costs.js +114 -0
- package/dist/runtime/check-costs.js.map +1 -0
- package/dist/runtime/check-ids.d.ts +15 -0
- package/dist/runtime/check-ids.d.ts.map +1 -0
- package/dist/runtime/check-ids.js +140 -0
- package/dist/runtime/check-ids.js.map +1 -0
- package/dist/runtime/checks.d.ts +83 -20
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +3285 -1947
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +112 -0
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +271 -39
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/cookies.d.ts +16 -2
- package/dist/runtime/cookies.d.ts.map +1 -1
- package/dist/runtime/cookies.js +83 -22
- package/dist/runtime/cookies.js.map +1 -1
- package/dist/runtime/exchanges.d.ts +4 -5
- package/dist/runtime/exchanges.d.ts.map +1 -1
- package/dist/runtime/exchanges.js +33 -55
- package/dist/runtime/exchanges.js.map +1 -1
- package/dist/runtime/exit.d.ts +40 -0
- package/dist/runtime/exit.d.ts.map +1 -0
- package/dist/runtime/exit.js +48 -0
- package/dist/runtime/exit.js.map +1 -0
- package/dist/runtime/feature-gate.d.ts +0 -9
- package/dist/runtime/feature-gate.d.ts.map +1 -1
- package/dist/runtime/feature-gate.js +0 -21
- package/dist/runtime/feature-gate.js.map +1 -1
- package/dist/runtime/finding.d.ts +49 -8
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js +51 -8
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/fixture.d.ts +35 -28
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js +92 -731
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/generate.d.ts +63 -0
- package/dist/runtime/generate.d.ts.map +1 -0
- package/dist/runtime/generate.js +635 -0
- package/dist/runtime/generate.js.map +1 -0
- package/dist/runtime/headers.d.ts +6 -0
- package/dist/runtime/headers.d.ts.map +1 -0
- package/dist/runtime/headers.js +16 -0
- package/dist/runtime/headers.js.map +1 -0
- package/dist/runtime/path.d.ts +7 -0
- package/dist/runtime/path.d.ts.map +1 -0
- package/dist/runtime/path.js +44 -0
- package/dist/runtime/path.js.map +1 -0
- package/dist/runtime/payloads.d.ts +2 -0
- package/dist/runtime/payloads.d.ts.map +1 -1
- package/dist/runtime/payloads.js +2 -1
- package/dist/runtime/payloads.js.map +1 -1
- package/dist/runtime/rate-limit.d.ts +6 -4
- package/dist/runtime/rate-limit.d.ts.map +1 -1
- package/dist/runtime/rate-limit.js +22 -33
- package/dist/runtime/rate-limit.js.map +1 -1
- package/dist/runtime/redact.d.ts +24 -0
- package/dist/runtime/redact.d.ts.map +1 -0
- package/dist/runtime/redact.js +98 -0
- package/dist/runtime/redact.js.map +1 -0
- package/dist/runtime/run.d.ts +34 -2
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +346 -138
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runs.d.ts +5 -0
- package/dist/runtime/runs.d.ts.map +1 -1
- package/dist/runtime/runs.js +7 -1
- package/dist/runtime/runs.js.map +1 -1
- package/dist/runtime/teardown.d.ts +17 -4
- package/dist/runtime/teardown.d.ts.map +1 -1
- package/dist/runtime/teardown.js +49 -16
- package/dist/runtime/teardown.js.map +1 -1
- package/dist/runtime/transcript.d.ts +34 -2
- package/dist/runtime/transcript.d.ts.map +1 -1
- package/dist/runtime/transcript.js +119 -24
- package/dist/runtime/transcript.js.map +1 -1
- package/dist/runtime/unique.d.ts +2 -11
- package/dist/runtime/unique.d.ts.map +1 -1
- package/dist/runtime/unique.js +27 -53
- package/dist/runtime/unique.js.map +1 -1
- package/dist/runtime/validate.d.ts +13 -2
- package/dist/runtime/validate.d.ts.map +1 -1
- package/dist/runtime/validate.js +51 -31
- package/dist/runtime/validate.js.map +1 -1
- package/dist/runtime/wait.d.ts +2 -1
- package/dist/runtime/wait.d.ts.map +1 -1
- package/dist/runtime/wait.js +2 -36
- package/dist/runtime/wait.js.map +1 -1
- package/dist/runtime/world.d.ts +24 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +101 -26
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/collection.d.ts +8 -2
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +47 -7
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/extensions.d.ts +5 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +9 -2
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +46 -0
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +175 -11
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts +25 -5
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +160 -59
- package/dist/spec/load.js.map +1 -1
- package/dist/spec/query-capabilities.d.ts +4 -0
- package/dist/spec/query-capabilities.d.ts.map +1 -1
- package/dist/spec/query-capabilities.js +6 -0
- package/dist/spec/query-capabilities.js.map +1 -1
- package/dist/spec/schema.d.ts +20 -0
- package/dist/spec/schema.d.ts.map +1 -0
- package/dist/spec/schema.js +118 -0
- package/dist/spec/schema.js.map +1 -0
- package/dist/spec/tag-schema.d.ts +12 -0
- package/dist/spec/tag-schema.d.ts.map +1 -0
- package/dist/spec/tag-schema.js +143 -0
- package/dist/spec/tag-schema.js.map +1 -0
- package/dist/spec/types.js +17 -1
- package/dist/spec/types.js.map +1 -1
- package/docs/api.md +59 -0
- package/docs/assumptions.md +99 -0
- package/docs/checks.md +183 -0
- package/docs/commands.md +214 -0
- package/docs/configuration.md +650 -0
- package/docs/model.md +184 -0
- package/docs/reference-defects.md +94 -0
- package/docs/reports.md +211 -0
- package/docs/tags.md +400 -0
- package/package.json +3 -1
package/dist/reference/query.js
CHANGED
|
@@ -1,39 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The reference backend's query language, parsed once.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Filter, sort, select and search arrive as text in the canonical grammar (`field.op.value`,
|
|
5
|
+
* `and(...)`, `field.desc`). They are parsed here into an AST that every store consumes: the
|
|
6
|
+
* in-memory store evaluates it, the SQL stores compile it. Defects that change what a predicate
|
|
7
|
+
* *means* are applied here as rewrites of the tree, so a defect is defined exactly once and every
|
|
8
|
+
* engine exhibits the same bug.
|
|
9
|
+
*
|
|
10
|
+
* Values are coerced by the field's declared type, never by what the text looks like. A string
|
|
11
|
+
* field filtered on `123` compares against the string "123"; a numeric field filtered on `abc`
|
|
12
|
+
* matches nothing (or is rejected with a 400 when the backend is strictly typed).
|
|
13
|
+
*
|
|
14
|
+
* Null semantics, decided once and held by every store:
|
|
15
|
+
* - `eq`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `ilike` never match a null value.
|
|
16
|
+
* - `neq` and `nin` do match a null value: null is not equal to, and not a member of, anything.
|
|
17
|
+
* - `is.null` / `is.notnull` test for null explicitly. `eq.null` is not a null test: on a
|
|
18
|
+
* text field it compares against the text "null", as PostgREST does.
|
|
7
19
|
*/
|
|
20
|
+
import { DefectSet } from "./defects.js";
|
|
21
|
+
import { OVERCLAIMED_FIELD, fieldsWhere } from "./model.js";
|
|
8
22
|
import { SqlError } from "./store-api.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"ne",
|
|
16
|
-
"neq",
|
|
17
|
-
"gt",
|
|
18
|
-
"gte",
|
|
19
|
-
"lt",
|
|
20
|
-
"lte",
|
|
21
|
-
"in",
|
|
22
|
-
"nin",
|
|
23
|
-
"like",
|
|
24
|
-
"ilike",
|
|
25
|
-
"is",
|
|
26
|
-
"contains",
|
|
27
|
-
]);
|
|
28
|
-
function countFilterTerms(expression) {
|
|
29
|
-
const group = /^(and|or)\((.*)\)$/s.exec(expression.trim());
|
|
30
|
-
if (group?.[2] !== undefined) {
|
|
31
|
-
return splitTopLevel(group[2]).reduce((sum, part) => sum + countFilterTerms(part), 0);
|
|
32
|
-
}
|
|
33
|
-
return 1;
|
|
34
|
-
}
|
|
35
|
-
/** Splits on commas that sit at paren depth zero. */
|
|
36
|
-
function splitTopLevel(input) {
|
|
23
|
+
export const SEARCH_MODES = ["contains", "prefix"];
|
|
24
|
+
export const DEFAULT_CONVENTIONS = { collation: "binary", nulls: "first", strictTypes: false };
|
|
25
|
+
/* ------------------------------------------------------------------ parsing */
|
|
26
|
+
const OPERATORS = new Set(["eq", "ne", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is", "contains"]);
|
|
27
|
+
/** Splits on commas at paren depth zero — shared by the filter and order grammars. */
|
|
28
|
+
export function splitTopLevel(input) {
|
|
37
29
|
const parts = [];
|
|
38
30
|
let depth = 0;
|
|
39
31
|
let start = 0;
|
|
@@ -51,353 +43,443 @@ function splitTopLevel(input) {
|
|
|
51
43
|
parts.push(input.slice(start));
|
|
52
44
|
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
53
45
|
}
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
function stripParens(value) {
|
|
47
|
+
return value.startsWith("(") && value.endsWith(")") ? value.slice(1, -1) : value;
|
|
48
|
+
}
|
|
49
|
+
function countTerms(expression) {
|
|
50
|
+
const group = /^(and|or)\((.*)\)$/s.exec(expression.trim());
|
|
51
|
+
if (group?.[2] !== undefined)
|
|
52
|
+
return splitTopLevel(group[2]).reduce((sum, part) => sum + countTerms(part), 0);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
function fieldOf(entity, name) {
|
|
56
|
+
return entity.fields.find((field) => field.name === name);
|
|
57
|
+
}
|
|
58
|
+
function filterableFields(entity, defects) {
|
|
59
|
+
const declared = fieldsWhere(entity, "filterable");
|
|
60
|
+
/* Under the overclaim the backend refuses a field the document still declares filterable. The
|
|
61
|
+
* backend is not wrong to refuse — the document is wrong to promise. */
|
|
62
|
+
return defects.has("SPEC_OVERCLAIMS_FILTERABLE") ? declared.filter((f) => f !== OVERCLAIMED_FIELD) : declared;
|
|
63
|
+
}
|
|
64
|
+
function sortableFields(entity, defects) {
|
|
65
|
+
const declared = fieldsWhere(entity, "sortable");
|
|
66
|
+
return defects.has("SPEC_OVERCLAIMS_SORTABLE") ? declared.filter((f) => f !== OVERCLAIMED_FIELD) : declared;
|
|
67
|
+
}
|
|
68
|
+
function isNumeric(field) {
|
|
69
|
+
return field?.type === "integer" || field?.type === "number";
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The value a filter term compares against, read as the field's declared type.
|
|
73
|
+
*
|
|
74
|
+
* `undefined` means the text does not denote a value of that type at all. A lenient backend turns
|
|
75
|
+
* that into a predicate matching nothing; a strict one rejects the request.
|
|
76
|
+
*/
|
|
77
|
+
function coerce(raw, field) {
|
|
78
|
+
if (field.type === "integer" || field.type === "number") {
|
|
79
|
+
if (raw.trim() === "")
|
|
80
|
+
return undefined;
|
|
81
|
+
const n = Number(raw);
|
|
82
|
+
if (!Number.isFinite(n))
|
|
83
|
+
return undefined;
|
|
84
|
+
if (field.type === "integer" && !Number.isInteger(n))
|
|
85
|
+
return undefined;
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
if (field.type === "boolean") {
|
|
89
|
+
if (raw === "true")
|
|
90
|
+
return true;
|
|
91
|
+
if (raw === "false")
|
|
92
|
+
return false;
|
|
93
|
+
return undefined;
|
|
57
94
|
}
|
|
58
|
-
return
|
|
95
|
+
return raw;
|
|
59
96
|
}
|
|
60
|
-
function
|
|
97
|
+
function likePattern(raw, unescaped) {
|
|
98
|
+
const segments = [];
|
|
99
|
+
let text = "";
|
|
100
|
+
const flush = () => {
|
|
101
|
+
if (text !== "")
|
|
102
|
+
segments.push({ kind: "text", text });
|
|
103
|
+
text = "";
|
|
104
|
+
};
|
|
105
|
+
for (const ch of raw) {
|
|
106
|
+
/* `*` is the grammar's wildcard. `%` and `_` are literals — unless the backend interpolates
|
|
107
|
+
* the value into a LIKE pattern without escaping it, which is the LIKE_UNESCAPED defect. */
|
|
108
|
+
if (ch === "*" || (unescaped && ch === "%")) {
|
|
109
|
+
flush();
|
|
110
|
+
segments.push({ kind: "any" });
|
|
111
|
+
}
|
|
112
|
+
else if (unescaped && ch === "_") {
|
|
113
|
+
flush();
|
|
114
|
+
segments.push({ kind: "one" });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
text += ch;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
flush();
|
|
121
|
+
return segments;
|
|
122
|
+
}
|
|
123
|
+
/** Parses a canonical filter expression. Throws `SqlError` for anything a correct backend rejects. */
|
|
124
|
+
export function parseFilter(expression, ctx) {
|
|
125
|
+
const catalog = ctx.entity.filterCatalog;
|
|
126
|
+
if (catalog?.maxFilterConditions !== undefined &&
|
|
127
|
+
countTerms(expression) > catalog.maxFilterConditions &&
|
|
128
|
+
!ctx.defects.has("FILTER_CONDITION_CAP_IGNORED")) {
|
|
129
|
+
throw new SqlError("invalid_filter", `more than ${catalog.maxFilterConditions} filter conditions`);
|
|
130
|
+
}
|
|
131
|
+
return parseNode(expression, ctx);
|
|
132
|
+
}
|
|
133
|
+
function parseNode(expression, ctx) {
|
|
134
|
+
const { defects, entity } = ctx;
|
|
61
135
|
const trimmed = expression.trim();
|
|
62
136
|
const group = /^(and|or)\((.*)\)$/s.exec(trimmed);
|
|
63
|
-
if (group?.[1] && group[2] !== undefined) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
137
|
+
if (group?.[1] !== undefined && group[2] !== undefined) {
|
|
138
|
+
const children = splitTopLevel(group[2]).map((part) => parseNode(part, ctx));
|
|
139
|
+
if (children.length === 0)
|
|
140
|
+
throw new SqlError("invalid_filter", `empty ${group[1]}() group`);
|
|
141
|
+
/* The joiner is swapped, not the parse: the expression is read correctly and evaluated with
|
|
142
|
+
* the other combinator's semantics. */
|
|
143
|
+
const named = group[1];
|
|
144
|
+
let kind = defects.has("FILTER_GROUP_COMBINATOR_SWAPPED") ? (named === "and" ? "or" : "and") : named;
|
|
145
|
+
/* One shared "current joiner": the last nested group to set it decides how this group joins. */
|
|
146
|
+
if (defects.has("FILTER_NESTED_COMBINATOR_LEAKS")) {
|
|
147
|
+
const nested = children.filter((child) => child.kind === "and" || child.kind === "or").at(-1);
|
|
148
|
+
if (nested !== undefined && (nested.kind === "and" || nested.kind === "or"))
|
|
149
|
+
kind = nested.kind;
|
|
68
150
|
}
|
|
69
|
-
|
|
70
|
-
* evaluated with the other combinator's semantics. */
|
|
71
|
-
const effective = defects.has("FILTER_GROUP_COMBINATOR_SWAPPED")
|
|
72
|
-
? combinator === "and"
|
|
73
|
-
? "or"
|
|
74
|
-
: "and"
|
|
75
|
-
: combinator;
|
|
76
|
-
return effective === "and"
|
|
77
|
-
? (row) => children.every((child) => child(row))
|
|
78
|
-
: (row) => children.some((child) => child(row));
|
|
151
|
+
return { children, kind };
|
|
79
152
|
}
|
|
80
153
|
const segments = trimmed.split(".");
|
|
81
|
-
if (segments.length < 3)
|
|
154
|
+
if (segments.length < 3)
|
|
82
155
|
throw new SqlError("invalid_filter", `malformed filter term "${trimmed}"`);
|
|
156
|
+
const [name, rawOp] = segments;
|
|
157
|
+
const raw = segments.slice(2).join(".");
|
|
158
|
+
if (!OPERATORS.has(rawOp)) {
|
|
159
|
+
if (defects.has("FILTER_ILLEGAL_OP_IGNORED"))
|
|
160
|
+
return { kind: "const", value: true };
|
|
161
|
+
throw new SqlError("invalid_filter", `unknown operator "${rawOp}"`);
|
|
83
162
|
}
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
163
|
+
const aliases = entity.filterCatalog?.aliases;
|
|
164
|
+
const alias = aliases !== undefined && Object.hasOwn(aliases, rawOp) ? aliases[rawOp] : undefined;
|
|
165
|
+
/* The alias table maps `ne` to the wrong operator. */
|
|
166
|
+
const mapped = alias !== undefined && defects.has("FILTER_ALIAS_MISMAPPED") ? "eq" : alias;
|
|
167
|
+
const op = mapped ?? (rawOp === "ne" ? "neq" : rawOp);
|
|
168
|
+
const opsByField = entity.filterCatalog?.opsByField;
|
|
169
|
+
const allowed = opsByField !== undefined && Object.hasOwn(opsByField, name) ? opsByField[name] : undefined;
|
|
170
|
+
if (allowed !== undefined && !allowed.includes(rawOp) && !allowed.includes(op)) {
|
|
89
171
|
if (defects.has("FILTER_ILLEGAL_OP_IGNORED"))
|
|
90
|
-
return
|
|
91
|
-
throw new SqlError("invalid_filter", `
|
|
172
|
+
return { kind: "const", value: true };
|
|
173
|
+
throw new SqlError("invalid_filter", `operator "${rawOp}" is not allowed on "${name}"`);
|
|
92
174
|
}
|
|
93
|
-
|
|
175
|
+
/* The document lists `like` on `slug`; under the overclaim the backend no longer supports it. */
|
|
176
|
+
if (defects.has("SPEC_OVERCLAIMS_FILTER_OP") && name === "slug" && op === "like") {
|
|
177
|
+
throw new SqlError("invalid_filter", `operator "like" is not supported on "slug"`);
|
|
178
|
+
}
|
|
179
|
+
if (!filterableFields(entity, defects).includes(name)) {
|
|
94
180
|
/* Correct behaviour is to reject. The defect drops the term instead — the single most
|
|
95
181
|
* common real-world filter bug, and invisible to schema validation. */
|
|
96
182
|
if (defects.has("FILTER_IGNORED"))
|
|
97
|
-
return
|
|
98
|
-
throw new SqlError("invalid_filter", `field "${
|
|
183
|
+
return { kind: "const", value: true };
|
|
184
|
+
throw new SqlError("invalid_filter", `field "${name}" is not filterable`);
|
|
99
185
|
}
|
|
100
|
-
|
|
186
|
+
const field = fieldOf(entity, name);
|
|
187
|
+
return buildTerm(field, op, raw, ctx);
|
|
101
188
|
}
|
|
102
|
-
function
|
|
103
|
-
const
|
|
189
|
+
function buildTerm(field, op, raw, ctx) {
|
|
190
|
+
const { defects, conventions } = ctx;
|
|
191
|
+
const asText = isNumeric(field) && defects.has("NUMERIC_COMPARED_AS_TEXT");
|
|
192
|
+
/* A value that is not of the field's type: lenient backends match nothing, strict ones say so. */
|
|
193
|
+
const typed = (text) => {
|
|
194
|
+
const value = coerce(text, field);
|
|
195
|
+
if (value === undefined && conventions.strictTypes) {
|
|
196
|
+
throw new SqlError("invalid_filter", `"${text}" is not a valid ${field.type} for "${field.name}"`);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
};
|
|
200
|
+
const never = { kind: "const", value: false };
|
|
201
|
+
/* An array is filtered by membership and by nothing else. */
|
|
202
|
+
if (field.type === "array" || op === "contains") {
|
|
203
|
+
if (field.type !== "array" || op !== "contains") {
|
|
204
|
+
throw new SqlError("invalid_filter", `operator "${op}" is not supported on "${field.name}"`);
|
|
205
|
+
}
|
|
206
|
+
return { field: field.name, kind: "contains", substring: defects.has("CONTAINS_MATCHES_SUBSTRING"), value: raw };
|
|
207
|
+
}
|
|
104
208
|
switch (op) {
|
|
105
|
-
case "eq":
|
|
209
|
+
case "eq": {
|
|
106
210
|
if (defects.has("FILTER_EQ_NOT_APPLIED"))
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
211
|
+
return { kind: "const", value: true };
|
|
212
|
+
const value = typed(raw);
|
|
213
|
+
return value === undefined ? never : { asText, field: field.name, kind: "cmp", nullsMatch: false, op, value };
|
|
214
|
+
}
|
|
215
|
+
case "neq": {
|
|
216
|
+
const value = typed(raw);
|
|
217
|
+
/* Three-valued logic leaking through: in SQL `col <> x` is NULL — not true — when col is
|
|
218
|
+
* NULL, so null rows silently vanish unless the query also tests `col IS NULL`. */
|
|
219
|
+
const nullsMatch = !defects.has("NEQ_DROPS_NULLS");
|
|
220
|
+
if (value === undefined) {
|
|
221
|
+
/* Nothing equals a value of the wrong type, so everything — nulls included — differs. */
|
|
222
|
+
return nullsMatch ? { kind: "const", value: true } : { field: field.name, kind: "isnull", negate: true };
|
|
115
223
|
}
|
|
116
|
-
return
|
|
224
|
+
return { asText, field: field.name, kind: "cmp", nullsMatch, op, value };
|
|
225
|
+
}
|
|
117
226
|
case "gt":
|
|
118
|
-
return (row) => compare(row[field], value) > 0;
|
|
119
227
|
case "gte":
|
|
120
|
-
if (defects.has("FILTER_GTE_IS_GT"))
|
|
121
|
-
return (row) => compare(row[field], value) > 0;
|
|
122
|
-
return (row) => compare(row[field], value) >= 0;
|
|
123
228
|
case "lt":
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
229
|
+
case "lte": {
|
|
230
|
+
const value = typed(raw);
|
|
231
|
+
if (value === undefined)
|
|
232
|
+
return never;
|
|
233
|
+
const effective = op === "gte" && defects.has("FILTER_GTE_IS_GT")
|
|
234
|
+
? "gt"
|
|
235
|
+
: op === "lte" && defects.has("FILTER_LTE_IS_LT")
|
|
236
|
+
? "lt"
|
|
237
|
+
: op === "lt" && defects.has("FILTER_LT_IS_LTE")
|
|
238
|
+
? "lte"
|
|
239
|
+
: op;
|
|
240
|
+
return { asText, field: field.name, kind: "cmp", nullsMatch: false, op: effective, value };
|
|
241
|
+
}
|
|
127
242
|
case "in":
|
|
128
243
|
case "nin": {
|
|
129
|
-
const members = stripParens(
|
|
244
|
+
const members = stripParens(raw)
|
|
130
245
|
.split(",")
|
|
131
246
|
.map((s) => s.trim())
|
|
132
|
-
.filter((s) => s.length > 0)
|
|
133
|
-
.map((s) => coerce(s));
|
|
247
|
+
.filter((s) => s.length > 0);
|
|
134
248
|
if (members.length === 0) {
|
|
135
|
-
if (
|
|
136
|
-
throw new SqlError("invalid_filter", "empty in()
|
|
137
|
-
return
|
|
249
|
+
if (ctx.entity.filterCatalog?.emptyIn === "reject")
|
|
250
|
+
throw new SqlError("invalid_filter", "empty in()");
|
|
251
|
+
return { kind: "const", value: op === "nin" };
|
|
138
252
|
}
|
|
139
|
-
|
|
140
|
-
|
|
253
|
+
const max = ctx.entity.filterCatalog?.maxInValues;
|
|
254
|
+
if (max !== undefined && members.length > max && !defects.has("FILTER_IN_CAP_IGNORED")) {
|
|
255
|
+
throw new SqlError("invalid_filter", `in() exceeds maxInValues=${max}`);
|
|
141
256
|
}
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
257
|
+
const values = members.map(typed).filter((value) => value !== undefined);
|
|
258
|
+
const firstOnly = (defects.has("FILTER_IN_FIRST_ONLY") && op === "in") || (defects.has("FILTER_NIN_FIRST_ONLY") && op === "nin");
|
|
259
|
+
const effective = firstOnly ? values.slice(0, 1) : values;
|
|
260
|
+
return { asText, field: field.name, kind: "in", negate: op === "nin", values: effective };
|
|
147
261
|
}
|
|
148
262
|
case "is": {
|
|
149
|
-
if (
|
|
263
|
+
if (raw === "null") {
|
|
150
264
|
if (defects.has("FILTER_IS_NULL_MATCHES_ALL"))
|
|
151
|
-
return
|
|
152
|
-
return
|
|
265
|
+
return { kind: "const", value: true };
|
|
266
|
+
return { field: field.name, kind: "isnull", negate: false };
|
|
153
267
|
}
|
|
154
|
-
if (
|
|
155
|
-
return
|
|
268
|
+
if (raw === "notnull" || raw === "not.null")
|
|
269
|
+
return { field: field.name, kind: "isnull", negate: true };
|
|
270
|
+
if ((raw === "true" || raw === "false") && field.type === "boolean") {
|
|
271
|
+
return { asText: false, field: field.name, kind: "cmp", nullsMatch: false, op: "eq", value: raw === "true" };
|
|
156
272
|
}
|
|
157
|
-
|
|
158
|
-
return (row) => row[field] === true;
|
|
159
|
-
if (rawValue === "false")
|
|
160
|
-
return (row) => row[field] === false;
|
|
161
|
-
throw new SqlError("invalid_filter", `is.${rawValue} is not a recognised predicate`);
|
|
273
|
+
throw new SqlError("invalid_filter", `is.${raw} is not a recognised predicate`);
|
|
162
274
|
}
|
|
163
|
-
case "contains":
|
|
164
|
-
return (row) => Array.isArray(row[field]) && row[field].some((v) => looseEqual(v, value));
|
|
165
275
|
case "like":
|
|
166
276
|
case "ilike": {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return
|
|
277
|
+
if (field.type !== "string")
|
|
278
|
+
throw new SqlError("invalid_filter", `${op} needs a text field`);
|
|
279
|
+
return {
|
|
280
|
+
field: field.name,
|
|
281
|
+
insensitive: op === "ilike" && !defects.has("FILTER_ILIKE_IS_LIKE"),
|
|
282
|
+
kind: "like",
|
|
283
|
+
pattern: likePattern(raw, defects.has("LIKE_UNESCAPED")),
|
|
284
|
+
};
|
|
170
285
|
}
|
|
171
286
|
default:
|
|
172
287
|
throw new SqlError("invalid_filter", `unhandled operator "${op}"`);
|
|
173
288
|
}
|
|
174
289
|
}
|
|
175
290
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
291
|
+
* Parses a canonical order expression (`field.desc.nullslast,other`).
|
|
292
|
+
*
|
|
293
|
+
* `trusted` is for the server's own default order: it is not a request, so the request's
|
|
294
|
+
* validation — and the defects that corrupt it — do not apply to it.
|
|
179
295
|
*/
|
|
180
|
-
function
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
296
|
+
export function parseOrder(expression, ctx, trusted = false) {
|
|
297
|
+
const { entity, conventions } = ctx;
|
|
298
|
+
const defects = trusted ? new DefectSet() : ctx.defects;
|
|
299
|
+
const sortable = trusted ? fieldsWhere(entity, "sortable") : sortableFields(entity, defects);
|
|
300
|
+
const terms = splitTopLevel(expression).flatMap((term) => {
|
|
301
|
+
const [name, ...parsed] = term.split(".");
|
|
302
|
+
if (name === undefined || !sortable.includes(name)) {
|
|
303
|
+
/* Correct is to reject; the defect drops a term naming a field that does not exist. */
|
|
304
|
+
const exists = entity.fields.some((field) => field.name === name);
|
|
305
|
+
if (defects.has("SORT_UNKNOWN_FIELD_IGNORED") && !exists)
|
|
306
|
+
return [];
|
|
307
|
+
throw new SqlError("invalid_order", `field "${name ?? ""}" is not sortable`);
|
|
308
|
+
}
|
|
309
|
+
const modifiers = defects.has("SORT_NULLS_MODIFIER_IGNORED")
|
|
310
|
+
? parsed.filter((modifier) => modifier !== "nullsfirst" && modifier !== "nullslast")
|
|
311
|
+
: parsed;
|
|
312
|
+
for (const modifier of modifiers) {
|
|
313
|
+
if (!["asc", "desc", "nullsfirst", "nullslast"].includes(modifier)) {
|
|
314
|
+
throw new SqlError("invalid_order", `unknown sort modifier "${modifier}"`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const descending = modifiers.includes("desc");
|
|
318
|
+
/* The default placement is stated for ascending order; descending reverses it, so a
|
|
319
|
+
* descending sort is exactly the ascending one read backwards. */
|
|
320
|
+
const defaultFirst = conventions.nulls === "first" ? !descending : descending;
|
|
321
|
+
const numeric = isNumeric(fieldOf(entity, name));
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
asText: numeric && (defects.has("SORT_NUMERIC_AS_TEXT") || defects.has("NUMERIC_COMPARED_AS_TEXT")),
|
|
325
|
+
descending,
|
|
326
|
+
field: name,
|
|
327
|
+
nullsFirst: modifiers.includes("nullsfirst") ? true : modifiers.includes("nullslast") ? false : defaultFirst,
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
});
|
|
331
|
+
return defects.has("SORT_MULTI_KEY_IGNORED") ? terms.slice(0, 1) : terms;
|
|
188
332
|
}
|
|
189
|
-
|
|
190
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Search tokens: every token must occur, case-insensitively, in some searchable field — as a
|
|
335
|
+
* substring under `contains`, at the start of the value under `prefix`. A blank query matches
|
|
336
|
+
* everything.
|
|
337
|
+
*/
|
|
338
|
+
export function parseSearch(q, entity, defects, mode) {
|
|
339
|
+
const split = q
|
|
340
|
+
.split(/\s+/)
|
|
341
|
+
.map((token) => token.trim())
|
|
342
|
+
.filter((token) => token.length > 0);
|
|
343
|
+
const fields = fieldsWhere(entity, "searchable");
|
|
344
|
+
if (split.length === 0)
|
|
345
|
+
return defects.has("SEARCH_EMPTY_MATCHES_NONE") ? { kind: "const", value: false } : null;
|
|
346
|
+
if (fields.length === 0)
|
|
191
347
|
return null;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
348
|
+
const tokens = defects.has("SEARCH_ONLY_FIRST_TOKEN") ? split.slice(0, 1) : split;
|
|
349
|
+
const caseSensitive = defects.has("SEARCH_CASE_SENSITIVE");
|
|
350
|
+
return {
|
|
351
|
+
children: tokens.map((token) => ({ caseSensitive, fields, kind: "search", mode, token })),
|
|
352
|
+
kind: "and",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
export function and(...nodes) {
|
|
356
|
+
const children = nodes.filter((node) => node !== null);
|
|
357
|
+
if (children.length === 0)
|
|
358
|
+
return { kind: "const", value: true };
|
|
359
|
+
if (children.length === 1)
|
|
360
|
+
return children[0];
|
|
361
|
+
return { children, kind: "and" };
|
|
362
|
+
}
|
|
363
|
+
/** Fields a filter expression names, for the tombstone rule (an explicit mention lifts it). */
|
|
364
|
+
export function fieldsIn(node) {
|
|
365
|
+
switch (node.kind) {
|
|
366
|
+
case "and":
|
|
367
|
+
case "or":
|
|
368
|
+
return node.children.flatMap(fieldsIn);
|
|
369
|
+
case "const":
|
|
370
|
+
return [];
|
|
371
|
+
case "search":
|
|
372
|
+
return node.fields;
|
|
373
|
+
default:
|
|
374
|
+
return [node.field];
|
|
375
|
+
}
|
|
199
376
|
}
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
return true;
|
|
203
|
-
if (a === null || a === undefined || b === null || b === undefined)
|
|
204
|
-
return false;
|
|
205
|
-
return String(a) === String(b);
|
|
377
|
+
function isNull(value) {
|
|
378
|
+
return value === null || value === undefined;
|
|
206
379
|
}
|
|
207
|
-
function
|
|
208
|
-
if (a === null || a === undefined)
|
|
209
|
-
return -1;
|
|
210
|
-
if (b === null || b === undefined)
|
|
211
|
-
return 1;
|
|
380
|
+
function compareScalar(a, b, asText) {
|
|
212
381
|
if (!asText && typeof a === "number" && typeof b === "number")
|
|
213
382
|
return a - b;
|
|
383
|
+
if (!asText && typeof a === "boolean" && typeof b === "boolean")
|
|
384
|
+
return Number(a) - Number(b);
|
|
214
385
|
const as = String(a);
|
|
215
386
|
const bs = String(b);
|
|
216
387
|
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
217
388
|
}
|
|
218
|
-
function
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
});
|
|
232
|
-
return defects.has("SORT_MULTI_KEY_IGNORED") ? terms.slice(0, 1) : terms;
|
|
389
|
+
function equal(a, b, asText) {
|
|
390
|
+
if (asText)
|
|
391
|
+
return String(a) === String(b);
|
|
392
|
+
return a === b;
|
|
393
|
+
}
|
|
394
|
+
export function likeToRegExp(pattern, insensitive) {
|
|
395
|
+
const source = pattern
|
|
396
|
+
.map((segment) => segment.kind === "any"
|
|
397
|
+
? "[\\s\\S]*"
|
|
398
|
+
: segment.kind === "one"
|
|
399
|
+
? "[\\s\\S]"
|
|
400
|
+
: segment.text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
401
|
+
.join("");
|
|
402
|
+
return new RegExp(`^${source}$`, insensitive ? "iu" : "u");
|
|
233
403
|
}
|
|
234
|
-
/**
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
404
|
+
/** The in-memory evaluation of a filter — the reference semantics the SQL compilers match. */
|
|
405
|
+
export function evaluate(node, row) {
|
|
406
|
+
switch (node.kind) {
|
|
407
|
+
case "const":
|
|
408
|
+
return node.value;
|
|
409
|
+
case "and":
|
|
410
|
+
return node.children.every((child) => evaluate(child, row));
|
|
411
|
+
case "or":
|
|
412
|
+
return node.children.some((child) => evaluate(child, row));
|
|
413
|
+
case "isnull":
|
|
414
|
+
return isNull(row[node.field]) !== node.negate;
|
|
415
|
+
case "cmp": {
|
|
416
|
+
const value = row[node.field];
|
|
417
|
+
if (isNull(value))
|
|
418
|
+
return node.nullsMatch;
|
|
419
|
+
if (node.op === "eq")
|
|
420
|
+
return equal(value, node.value, node.asText);
|
|
421
|
+
if (node.op === "neq")
|
|
422
|
+
return !equal(value, node.value, node.asText);
|
|
423
|
+
const delta = compareScalar(value, node.value, node.asText);
|
|
424
|
+
if (node.op === "gt")
|
|
425
|
+
return delta > 0;
|
|
426
|
+
if (node.op === "gte")
|
|
427
|
+
return delta >= 0;
|
|
428
|
+
if (node.op === "lt")
|
|
429
|
+
return delta < 0;
|
|
430
|
+
return delta <= 0;
|
|
431
|
+
}
|
|
432
|
+
case "in": {
|
|
433
|
+
const value = row[node.field];
|
|
434
|
+
if (isNull(value))
|
|
435
|
+
return node.negate;
|
|
436
|
+
const hit = node.values.some((member) => equal(value, member, node.asText));
|
|
437
|
+
return hit !== node.negate;
|
|
438
|
+
}
|
|
439
|
+
case "like": {
|
|
440
|
+
const value = row[node.field];
|
|
441
|
+
return typeof value === "string" && likeToRegExp(node.pattern, node.insensitive).test(value);
|
|
442
|
+
}
|
|
443
|
+
case "contains": {
|
|
444
|
+
const value = row[node.field];
|
|
445
|
+
if (!Array.isArray(value))
|
|
446
|
+
return false;
|
|
447
|
+
return node.substring
|
|
448
|
+
? value.some((element) => String(element).includes(node.value))
|
|
449
|
+
: value.some((element) => String(element) === node.value);
|
|
450
|
+
}
|
|
451
|
+
case "search": {
|
|
452
|
+
const fold = (text) => (node.caseSensitive ? text : text.toLowerCase());
|
|
453
|
+
const needle = fold(node.token);
|
|
454
|
+
return node.fields.some((field) => {
|
|
455
|
+
const value = row[field];
|
|
456
|
+
if (isNull(value))
|
|
457
|
+
return false;
|
|
458
|
+
const text = fold(String(value));
|
|
459
|
+
return node.mode === "prefix" ? text.startsWith(needle) : text.includes(needle);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
251
462
|
}
|
|
252
|
-
|
|
253
|
-
|
|
463
|
+
}
|
|
464
|
+
/** Orders rows under the plan. Ties the plan leaves open keep their input order. */
|
|
465
|
+
export function compareRows(order, collation) {
|
|
466
|
+
return (left, right) => {
|
|
467
|
+
for (const term of order) {
|
|
254
468
|
const a = left[term.field];
|
|
255
469
|
const b = right[term.field];
|
|
256
|
-
const aNull = a
|
|
257
|
-
const bNull = b
|
|
470
|
+
const aNull = isNull(a);
|
|
471
|
+
const bNull = isNull(b);
|
|
258
472
|
if (aNull && bNull)
|
|
259
473
|
continue;
|
|
260
|
-
if (aNull || bNull)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
474
|
+
if (aNull || bNull)
|
|
475
|
+
return aNull === term.nullsFirst ? -1 : 1;
|
|
476
|
+
const delta = collation === "insensitive" && typeof a === "string" && typeof b === "string"
|
|
477
|
+
? compareScalar(a.toLowerCase(), b.toLowerCase(), true)
|
|
478
|
+
: compareScalar(a, b, term.asText);
|
|
265
479
|
if (delta !== 0)
|
|
266
480
|
return term.descending ? -delta : delta;
|
|
267
481
|
}
|
|
268
482
|
return 0;
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
/* ------------------------------------------------------------------ execution */
|
|
272
|
-
export function runQuery(source, params, options, defects,
|
|
273
|
-
/** Applied before the sparse fieldset, so a derived value cannot reintroduce a deselected
|
|
274
|
-
* column. Mirrors the SQL stores so every backend projects identically. */
|
|
275
|
-
transform) {
|
|
276
|
-
let rows = [...source];
|
|
277
|
-
if (options.softDeleteField !== undefined && !defects.has("SOFT_DELETE_LEAK")) {
|
|
278
|
-
const field = options.softDeleteField;
|
|
279
|
-
const mentionsTombstones = params.filter?.includes(field) === true;
|
|
280
|
-
if (!mentionsTombstones)
|
|
281
|
-
rows = rows.filter((row) => row[field] === null || row[field] === undefined);
|
|
282
|
-
}
|
|
283
|
-
const unfilteredCount = rows.length;
|
|
284
|
-
if (params.filter !== undefined && params.filter !== "") {
|
|
285
|
-
let predicate;
|
|
286
|
-
try {
|
|
287
|
-
predicate = parseFilter(params.filter, options, defects);
|
|
288
|
-
}
|
|
289
|
-
catch (error) {
|
|
290
|
-
/* Correct behaviour is a 400. The defect lets the parser's own exception escape,
|
|
291
|
-
* which the transport surfaces as a 500 — validation confused with crashing. */
|
|
292
|
-
if (defects.has("ERROR_500_ON_BAD_FILTER")) {
|
|
293
|
-
throw new Error(`unhandled filter parse failure: ${String(error)}`);
|
|
294
|
-
}
|
|
295
|
-
throw error;
|
|
296
|
-
}
|
|
297
|
-
const filtered = rows.filter(predicate);
|
|
298
|
-
/* The "no results must mean the query was wrong" guard — falls back to the unfiltered set
|
|
299
|
-
* and makes an empty result impossible to express. */
|
|
300
|
-
rows = defects.has("EMPTY_RESULT_RETURNS_ALL") && filtered.length === 0 ? rows : filtered;
|
|
301
|
-
}
|
|
302
|
-
if (params.q !== undefined && params.q.trim() === "") {
|
|
303
|
-
/* empty q is a no-op here; reject is the store's job when the catalog says so. */
|
|
304
|
-
}
|
|
305
|
-
else if (params.q !== undefined && params.q !== "" && !defects.has("SEARCH_IGNORED")) {
|
|
306
|
-
const tokens = params.q
|
|
307
|
-
.toLowerCase()
|
|
308
|
-
.split(/\s+/)
|
|
309
|
-
.filter((token) => token.length > 0);
|
|
310
|
-
rows = rows.filter((row) => tokens.every((needle) => options.searchable.some((field) => String(row[field] ?? "")
|
|
311
|
-
.toLowerCase()
|
|
312
|
-
.includes(needle))));
|
|
313
|
-
}
|
|
314
|
-
const terms = params.order === undefined || params.order === "" ? [] : parseOrder(params.order, options, defects);
|
|
315
|
-
if (defects.has("SORT_DESC_DROPS_NULLS")) {
|
|
316
|
-
for (const term of terms) {
|
|
317
|
-
if (!term.descending)
|
|
318
|
-
continue;
|
|
319
|
-
rows = rows.filter((row) => row[term.field] !== null && row[term.field] !== undefined);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
rows = applyOrder(rows, defects.has("ORDER_IGNORED") ? [] : terms, options, defects);
|
|
323
|
-
const requestedLimit = params.limit ?? options.defaultLimit;
|
|
324
|
-
const limit = defects.has("LIMIT_EXCEEDS_MAX") ? requestedLimit : Math.min(requestedLimit, options.maxLimit);
|
|
325
|
-
const total = defects.has("COUNT_ALWAYS_ZERO")
|
|
326
|
-
? 0
|
|
327
|
-
: defects.has("COUNT_IGNORES_FILTER")
|
|
328
|
-
? unfilteredCount
|
|
329
|
-
: rows.length;
|
|
330
|
-
let offset;
|
|
331
|
-
let page;
|
|
332
|
-
if (params.cursor !== undefined && params.cursor !== "") {
|
|
333
|
-
const afterId = decodeCursor(params.cursor);
|
|
334
|
-
const index = rows.findIndex((row) => String(row[options.identity]) === afterId);
|
|
335
|
-
/* Under CURSOR_DRIFT the cursor resolves one row early, so the cursor walk and the
|
|
336
|
-
* offset walk disagree — each looks self-consistent in isolation. */
|
|
337
|
-
offset = index === -1 ? 0 : index + (defects.has("CURSOR_DRIFT") ? 0 : 1);
|
|
338
|
-
page = null;
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
const requested = Math.max(params.page ?? 1, 1);
|
|
342
|
-
offset = (requested - 1) * limit + (defects.has("OFF_BY_ONE_PAGE") && requested > 1 ? 1 : 0);
|
|
343
|
-
page = requested;
|
|
344
|
-
}
|
|
345
|
-
/* LIMIT_IGNORED serves the whole set regardless of the requested page size — the caller's
|
|
346
|
-
* paging loop then appears to complete in one request. */
|
|
347
|
-
const window = defects.has("LIMIT_IGNORED") ? rows.slice(offset) : rows.slice(offset, offset + limit);
|
|
348
|
-
const last = window.at(-1);
|
|
349
|
-
const hasMore = defects.has("HASMORE_ALWAYS_FALSE") ? false : offset + window.length < rows.length;
|
|
350
|
-
const projected = window
|
|
351
|
-
.map((row) => (transform === undefined ? row : transform(row)))
|
|
352
|
-
.map((row) => project(row, params.select, defects, options.excludedSelect ?? [], options.identity, options.selectUnknown));
|
|
353
|
-
return {
|
|
354
|
-
count: total,
|
|
355
|
-
hasMore,
|
|
356
|
-
items: projected,
|
|
357
|
-
limit,
|
|
358
|
-
nextCursor: hasMore && last !== undefined ? encodeCursor(String(last[options.identity])) : null,
|
|
359
|
-
page,
|
|
360
483
|
};
|
|
361
484
|
}
|
|
362
|
-
function project(row, select, defects, excluded = [], identity = "id", selectUnknown) {
|
|
363
|
-
if (select === undefined || select === "" || select === "*")
|
|
364
|
-
return { ...row };
|
|
365
|
-
if (defects.has("SELECT_IGNORED"))
|
|
366
|
-
return { ...row };
|
|
367
|
-
const fields = select
|
|
368
|
-
.split(",")
|
|
369
|
-
.map((s) => s.trim())
|
|
370
|
-
.filter(Boolean);
|
|
371
|
-
const rejected = fields.find((f) => excluded.includes(f));
|
|
372
|
-
if (rejected !== undefined) {
|
|
373
|
-
throw new SqlError("invalid_select", `field "${rejected}" is not selectable`);
|
|
374
|
-
}
|
|
375
|
-
if (selectUnknown === "reject") {
|
|
376
|
-
const bogus = fields.find((field) => !Object.hasOwn(row, field) && !field.includes("("));
|
|
377
|
-
if (bogus !== undefined)
|
|
378
|
-
throw new SqlError("invalid_select", `field "${bogus}" is not selectable`);
|
|
379
|
-
}
|
|
380
|
-
const out = {};
|
|
381
|
-
for (const field of fields) {
|
|
382
|
-
if (defects.has("SELECT_FIELD_MISSING") && field !== identity && Object.hasOwn(row, field))
|
|
383
|
-
continue;
|
|
384
|
-
if (Object.hasOwn(row, field))
|
|
385
|
-
out[field] = row[field];
|
|
386
|
-
}
|
|
387
|
-
return out;
|
|
388
|
-
}
|
|
389
|
-
export function encodeCursor(id) {
|
|
390
|
-
return Buffer.from(JSON.stringify({ id }), "utf8").toString("base64url");
|
|
391
|
-
}
|
|
392
|
-
export function decodeCursor(cursor) {
|
|
393
|
-
try {
|
|
394
|
-
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
395
|
-
if (typeof parsed.id !== "string")
|
|
396
|
-
throw new Error("missing id");
|
|
397
|
-
return parsed.id;
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
throw new SqlError("invalid_cursor", "cursor is not a value produced by this API");
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
485
|
//# sourceMappingURL=query.js.map
|