@rebasepro/common 0.23.0 → 0.24.1-canary.g56af4a4
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/dist/collections/CollectionRegistry.d.ts +72 -3
- package/dist/collections/CollectionRegistry.es.js +404 -0
- package/dist/collections/CollectionRegistry.es.js.map +1 -0
- package/dist/collections/auth-secrets.es.js +47 -0
- package/dist/collections/auth-secrets.es.js.map +1 -0
- package/dist/collections/default-collections.es.js +120 -0
- package/dist/collections/default-collections.es.js.map +1 -0
- package/dist/collections/engine-support.es.js +35 -0
- package/dist/collections/engine-support.es.js.map +1 -0
- package/dist/collections/field-access.d.ts +13 -0
- package/dist/collections/field-access.es.js +142 -0
- package/dist/collections/field-access.es.js.map +1 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/collections/soft-delete.es.js +34 -0
- package/dist/collections/soft-delete.es.js.map +1 -0
- package/dist/collections/unknown-field.d.ts +65 -0
- package/dist/collections/unknown-field.es.js +135 -0
- package/dist/collections/unknown-field.es.js.map +1 -0
- package/dist/data/buildRebaseData.d.ts +27 -2
- package/dist/data/buildRebaseData.es.js +1026 -0
- package/dist/data/buildRebaseData.es.js.map +1 -0
- package/dist/data/buildRoutedRebaseData.d.ts +6 -1
- package/dist/data/buildRoutedRebaseData.es.js +47 -0
- package/dist/data/buildRoutedRebaseData.es.js.map +1 -0
- package/dist/data/collection-accessor.d.ts +54 -0
- package/dist/data/collection-accessor.es.js +101 -0
- package/dist/data/collection-accessor.es.js.map +1 -0
- package/dist/data/cursor.d.ts +1 -1
- package/dist/data/cursor.es.js +166 -0
- package/dist/data/cursor.es.js.map +1 -0
- package/dist/data/filter-conditions.es.js +24 -0
- package/dist/data/filter-conditions.es.js.map +1 -0
- package/dist/data/filter-dialect.d.ts +3 -3
- package/dist/data/filter-dialect.es.js +786 -0
- package/dist/data/filter-dialect.es.js.map +1 -0
- package/dist/data/include-spec.d.ts +2 -0
- package/dist/data/include-spec.es.js +273 -0
- package/dist/data/include-spec.es.js.map +1 -0
- package/dist/data/paginate.d.ts +8 -1
- package/dist/data/paginate.es.js +181 -0
- package/dist/data/paginate.es.js.map +1 -0
- package/dist/data/query_builder.d.ts +52 -4
- package/dist/data/query_builder.es.js +151 -0
- package/dist/data/query_builder.es.js.map +1 -0
- package/dist/data/resolveDataSource.es.js +87 -0
- package/dist/data/resolveDataSource.es.js.map +1 -0
- package/dist/data/row-etag.d.ts +46 -0
- package/dist/data/row-etag.es.js +64 -0
- package/dist/data/row-etag.es.js.map +1 -0
- package/dist/data/sort-dialect.es.js +236 -0
- package/dist/data/sort-dialect.es.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +56 -7235
- package/dist/table-classification.es.js +107 -0
- package/dist/table-classification.es.js.map +1 -0
- package/dist/util/auth-default-policies.es.js +184 -0
- package/dist/util/auth-default-policies.es.js.map +1 -0
- package/dist/util/builders.es.js +15 -0
- package/dist/util/builders.es.js.map +1 -0
- package/dist/util/callback-errors.es.js +164 -0
- package/dist/util/callback-errors.es.js.map +1 -0
- package/dist/util/callbacks.es.js +72 -0
- package/dist/util/callbacks.es.js.map +1 -0
- package/dist/util/collections.es.js +79 -0
- package/dist/util/collections.es.js.map +1 -0
- package/dist/util/common.es.js +7 -0
- package/dist/util/common.es.js.map +1 -0
- package/dist/util/conditions.es.js +100 -0
- package/dist/util/conditions.es.js.map +1 -0
- package/dist/util/copy.d.ts +33 -0
- package/dist/util/copy.es.js +74 -0
- package/dist/util/copy.es.js.map +1 -0
- package/dist/util/download-url-cache.d.ts +122 -0
- package/dist/util/download-url-cache.es.js +187 -0
- package/dist/util/download-url-cache.es.js.map +1 -0
- package/dist/util/email.es.js +36 -0
- package/dist/util/email.es.js.map +1 -0
- package/dist/util/entities.d.ts +27 -2
- package/dist/util/entities.es.js +328 -0
- package/dist/util/entities.es.js.map +1 -0
- package/dist/util/enums.d.ts +10 -1
- package/dist/util/enums.es.js +37 -0
- package/dist/util/enums.es.js.map +1 -0
- package/dist/util/identity.es.js +138 -0
- package/dist/util/identity.es.js.map +1 -0
- package/dist/util/index.d.ts +6 -0
- package/dist/util/internal-tables.d.ts +10 -0
- package/dist/util/internal-tables.es.js +202 -0
- package/dist/util/internal-tables.es.js.map +1 -0
- package/dist/util/junction-policies.es.js +273 -0
- package/dist/util/junction-policies.es.js.map +1 -0
- package/dist/util/paths.es.js +26 -0
- package/dist/util/paths.es.js.map +1 -0
- package/dist/util/permissions.es.js +108 -0
- package/dist/util/permissions.es.js.map +1 -0
- package/dist/util/pg-column-to-property.es.js +303 -0
- package/dist/util/pg-column-to-property.es.js.map +1 -0
- package/dist/util/policy/compileRulePolicies.d.ts +32 -0
- package/dist/util/policy/compileRulePolicies.es.js +43 -0
- package/dist/util/policy/compileRulePolicies.es.js.map +1 -0
- package/dist/util/policy/evaluatePolicy.es.js +104 -0
- package/dist/util/policy/evaluatePolicy.es.js.map +1 -0
- package/dist/util/policy/index.d.ts +1 -0
- package/dist/util/policy/policyToPostgres.d.ts +24 -0
- package/dist/util/policy/policyToPostgres.es.js +593 -0
- package/dist/util/policy/policyToPostgres.es.js.map +1 -0
- package/dist/util/policy/policyToSecurityRule.es.js +79 -0
- package/dist/util/policy/policyToSecurityRule.es.js.map +1 -0
- package/dist/util/policy/securityRuleToConditions.es.js +48 -0
- package/dist/util/policy/securityRuleToConditions.es.js.map +1 -0
- package/dist/util/policy/sqlToPolicy.es.js +326 -0
- package/dist/util/policy/sqlToPolicy.es.js.map +1 -0
- package/dist/util/relations.es.js +243 -0
- package/dist/util/relations.es.js.map +1 -0
- package/dist/util/resolutions.es.js +318 -0
- package/dist/util/resolutions.es.js.map +1 -0
- package/dist/util/resolve-relation.es.js +163 -0
- package/dist/util/resolve-relation.es.js.map +1 -0
- package/dist/util/resumable-upload-threshold.d.ts +15 -0
- package/dist/util/resumable-upload-threshold.es.js +20 -0
- package/dist/util/resumable-upload-threshold.es.js.map +1 -0
- package/dist/util/resumable-upload.d.ts +51 -0
- package/dist/util/resumable-upload.es.js +121 -0
- package/dist/util/resumable-upload.es.js.map +1 -0
- package/dist/util/sql-redaction.d.ts +21 -0
- package/dist/util/sql-redaction.es.js +107 -0
- package/dist/util/sql-redaction.es.js.map +1 -0
- package/dist/util/sql-rows.es.js +41 -0
- package/dist/util/sql-rows.es.js.map +1 -0
- package/dist/util/storage.d.ts +26 -4
- package/dist/util/storage.es.js +117 -0
- package/dist/util/storage.es.js.map +1 -0
- package/dist/util/string-column-length.es.js +31 -0
- package/dist/util/string-column-length.es.js.map +1 -0
- package/dist/util/tenant.es.js +201 -0
- package/dist/util/tenant.es.js.map +1 -0
- package/dist/util/untrusted-envelope.d.ts +25 -0
- package/dist/util/untrusted-envelope.es.js +40 -0
- package/dist/util/untrusted-envelope.es.js.map +1 -0
- package/package.json +5 -3
- package/dist/index.es.js.map +0 -1
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { normalizeToEntityRelation } from "../util/entities.es.js";
|
|
2
|
+
import { ALL_WHERE_FILTER_OPS, CANONICAL_TO_REST, LIST_OPS, NULL_OPS, REST_TO_CANONICAL, toCanonicalOp } from "@rebasepro/types";
|
|
3
|
+
//#region src/data/filter-dialect.ts
|
|
4
|
+
/**
|
|
5
|
+
* REST wire-format adapter for the unified filter system.
|
|
6
|
+
*
|
|
7
|
+
* This module is the ONLY code in the entire codebase that knows about
|
|
8
|
+
* PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
|
|
9
|
+
* Everything else speaks `FilterValues` exclusively.
|
|
10
|
+
*
|
|
11
|
+
* Wire-format values are always strings — the wire format carries no type
|
|
12
|
+
* metadata, so type coercion is the responsibility of the server-side data
|
|
13
|
+
* driver which has access to the collection schema.
|
|
14
|
+
*
|
|
15
|
+
* Structural characters inside a value are backslash-escaped: `,` → `\,`,
|
|
16
|
+
* `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
|
|
17
|
+
* deliberately conservative — only those four sequences are decoded, so a
|
|
18
|
+
* backslash that arrives unescaped from an older client survives intact.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Serialize a JS value to its querystring representation.
|
|
24
|
+
* `null` is serialized as the literal string `"null"`.
|
|
25
|
+
* Relation values (`EntityRelation` instances or `{ __type: "relation", id, path }`
|
|
26
|
+
* objects) are serialized as their raw id — the wire format only carries the
|
|
27
|
+
* value to compare against the FK column.
|
|
28
|
+
*
|
|
29
|
+
* A `Date` is its ISO 8601 instant — what JSON makes of it on the socket and
|
|
30
|
+
* in-process, so one filter names one instant through every door. `String()`
|
|
31
|
+
* gave the viewer's local zone, no milliseconds and a parenthesised zone name
|
|
32
|
+
* Postgres cannot parse. An invalid date keeps its "Invalid Date" spelling for
|
|
33
|
+
* the server to refuse, rather than throwing a `RangeError` from here.
|
|
34
|
+
*/
|
|
35
|
+
function stringifyValue(value) {
|
|
36
|
+
if (value === null) return "null";
|
|
37
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
38
|
+
const relation = normalizeToEntityRelation(value);
|
|
39
|
+
if (relation) return String(relation.id);
|
|
40
|
+
return String(value);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Characters that carry structure in the wire format and must therefore be
|
|
44
|
+
* escaped inside a value: the separator, the group delimiters, and the escape
|
|
45
|
+
* character itself.
|
|
46
|
+
*
|
|
47
|
+
* Parentheses are here because `and(...)`/`or(...)` groups are parsed by
|
|
48
|
+
* tracking paren depth. A value containing one is not merely ambiguous, it
|
|
49
|
+
* moves where the parser thinks the group ends.
|
|
50
|
+
*/
|
|
51
|
+
var WIRE_SPECIALS = /[\\,()]/g;
|
|
52
|
+
/**
|
|
53
|
+
* Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
|
|
54
|
+
* `)` → `\)`.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* The wire spelling of an empty list.
|
|
58
|
+
*
|
|
59
|
+
* A lone backslash: unproducible by {@link escapeWireValue}, which doubles
|
|
60
|
+
* every backslash it emits, so it cannot collide with any real item.
|
|
61
|
+
*/
|
|
62
|
+
var EMPTY_LIST_TOKEN = "\\";
|
|
63
|
+
function escapeWireValue(value) {
|
|
64
|
+
return value.replace(WIRE_SPECIALS, (ch) => `\\${ch}`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Unescape a wire-format value.
|
|
68
|
+
*
|
|
69
|
+
* **Conservative**, and deliberately so: only the four sequences
|
|
70
|
+
* {@link escapeWireValue} actually produces are decoded. A backslash followed
|
|
71
|
+
* by anything else is left exactly as it is.
|
|
72
|
+
*
|
|
73
|
+
* This used to consume the backslash before *any* character, which is
|
|
74
|
+
* indistinguishable for anything this codec emitted — it only ever emits those
|
|
75
|
+
* four — but not for input arriving from elsewhere. A client on an older
|
|
76
|
+
* release sends a Windows path or a LIKE pattern with a literal `C:\x`
|
|
77
|
+
* unescaped, and greedy unescaping silently turned it into `C:x`, changing
|
|
78
|
+
* which rows matched. Decoding only what the encoder can produce makes the two
|
|
79
|
+
* directions agree across versions.
|
|
80
|
+
*/
|
|
81
|
+
function unescapeWireValue(value) {
|
|
82
|
+
let result = "";
|
|
83
|
+
for (let i = 0; i < value.length; i++) {
|
|
84
|
+
const next = value[i + 1];
|
|
85
|
+
if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
|
|
86
|
+
result += next;
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
result += value[i];
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a scalar, sent raw as a query parameter's operand, would be read
|
|
96
|
+
* back as something other than itself.
|
|
97
|
+
*
|
|
98
|
+
* A query parameter owns its whole value, so its scalar goes out unescaped —
|
|
99
|
+
* `eq.a,b`, `eq.C:\x` — and the decoder returns it verbatim. Two shapes break
|
|
100
|
+
* that. A parenthesised operand is a list for every operator (so that
|
|
101
|
+
* `["==", ["a","b"]]` on an array column stays a list), so raw, the scalar
|
|
102
|
+
* `"(deleted)"` would arrive as `["deleted"]`: an `==` matching nothing, a
|
|
103
|
+
* `!=` returning the very rows it excludes. Such a scalar is sent escaped,
|
|
104
|
+
* `eq.\(deleted\)`. And that spelling has to be told apart from a raw value
|
|
105
|
+
* that looks the same, so a scalar of *that* shape is escaped in turn.
|
|
106
|
+
*
|
|
107
|
+
* Everything else stays raw, so a value an older client sends decodes as it
|
|
108
|
+
* always has — unless it is exactly the escaped spelling of such a scalar,
|
|
109
|
+
* which decodes to the scalar it spells.
|
|
110
|
+
*/
|
|
111
|
+
function scalarNeedsEscape(value) {
|
|
112
|
+
if (value.startsWith("(") && value.endsWith(")")) return true;
|
|
113
|
+
return decodeEscapedScalar(value) !== void 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The scalar an operand is the escaped spelling of, when it is one.
|
|
117
|
+
*
|
|
118
|
+
* Exact on purpose: only an operand that {@link escapeWireValue} produces from
|
|
119
|
+
* a scalar that {@link scalarNeedsEscape} is decoded. Any other operand —
|
|
120
|
+
* including a raw backslash an older client sent, like `\\server\share` — is
|
|
121
|
+
* left verbatim. Terminates because an operand that matches contains an
|
|
122
|
+
* escape pair, so the value is strictly shorter than it.
|
|
123
|
+
*/
|
|
124
|
+
function decodeEscapedScalar(operand) {
|
|
125
|
+
if (!operand.startsWith("\\")) return void 0;
|
|
126
|
+
const value = unescapeWireValue(operand);
|
|
127
|
+
return escapeWireValue(value) === operand && scalarNeedsEscape(value) ? value : void 0;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Split a parenthesized list string on unescaped commas.
|
|
131
|
+
* Input is the content between `(` and `)`.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* splitListItems("admin,editor") // ["admin", "editor"]
|
|
135
|
+
* splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
|
|
136
|
+
*/
|
|
137
|
+
function splitListItems(inner) {
|
|
138
|
+
const items = [];
|
|
139
|
+
let current = "";
|
|
140
|
+
for (let i = 0; i < inner.length; i++) if (inner[i] === "\\" && i + 1 < inner.length) {
|
|
141
|
+
current += inner[i] + inner[i + 1];
|
|
142
|
+
i++;
|
|
143
|
+
} else if (inner[i] === ",") {
|
|
144
|
+
items.push(unescapeWireValue(current));
|
|
145
|
+
current = "";
|
|
146
|
+
} else current += inner[i];
|
|
147
|
+
items.push(unescapeWireValue(current));
|
|
148
|
+
return items;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Split a group body on commas at paren depth 0, honouring escapes.
|
|
152
|
+
*
|
|
153
|
+
* The escape-awareness is the point. The splitter used to track only paren
|
|
154
|
+
* depth, so a comma inside a scalar value ended a condition:
|
|
155
|
+
* `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle
|
|
156
|
+
* one a fabricated `" John" == true`. On an `or` that widens the result set,
|
|
157
|
+
* and nothing anywhere reports an error — the query simply stops meaning what
|
|
158
|
+
* the caller wrote.
|
|
159
|
+
*/
|
|
160
|
+
function splitGroupItems(inner) {
|
|
161
|
+
const parts = [];
|
|
162
|
+
let depth = 0;
|
|
163
|
+
let start = 0;
|
|
164
|
+
for (let i = 0; i < inner.length; i++) {
|
|
165
|
+
const ch = inner[i];
|
|
166
|
+
if (ch === "\\" && i + 1 < inner.length) {
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (ch === "(") depth++;
|
|
171
|
+
else if (ch === ")") depth--;
|
|
172
|
+
else if (ch === "," && depth === 0) {
|
|
173
|
+
parts.push(inner.slice(start, i));
|
|
174
|
+
start = i + 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
parts.push(inner.slice(start));
|
|
178
|
+
return parts;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Operator tables as `Map`s, because the key comes off the wire.
|
|
182
|
+
*
|
|
183
|
+
* Indexed as plain objects, every `Object.prototype` member answered: a query
|
|
184
|
+
* string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
|
|
185
|
+
* and `deserializeTuple` returned it *as the operator*, so a function object
|
|
186
|
+
* travelled on into the compilers in place of a `WhereFilterOp`. The guard one
|
|
187
|
+
* line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
|
|
188
|
+
* and does not: `Object.prototype` is not unknown to a plain object.
|
|
189
|
+
*
|
|
190
|
+
* Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
|
|
191
|
+
* `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
|
|
192
|
+
*/
|
|
193
|
+
var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
|
|
194
|
+
var CANONICAL_OP_LOOKUP = new Map(Object.entries(CANONICAL_TO_REST));
|
|
195
|
+
/** The operator spellings a rejection lists back to the caller. */
|
|
196
|
+
var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
|
|
197
|
+
/**
|
|
198
|
+
* A filter condition named an operator this dialect does not have.
|
|
199
|
+
*
|
|
200
|
+
* ## Why this throws, rather than returning a typed rejection
|
|
201
|
+
*
|
|
202
|
+
* `deserializeFilter` is the *shared* codec: the REST ingress
|
|
203
|
+
* (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
|
|
204
|
+
* admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
|
|
205
|
+
* follow.
|
|
206
|
+
*
|
|
207
|
+
* - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
|
|
208
|
+
* depend on `@rebasepro/server` (the dependency runs the other way), and a
|
|
209
|
+
* browser client has no error handler to render an `ApiError` with. So the
|
|
210
|
+
* rejection is this plain `Error` subclass, whose `message` reads correctly
|
|
211
|
+
* wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
|
|
212
|
+
* - It cannot be a returned rejection *value*. Every caller assigns the result
|
|
213
|
+
* straight into a query it is about to run; a sentinel that none of them
|
|
214
|
+
* check would be ignored, which is exactly the silently-wrong-filter failure
|
|
215
|
+
* this exists to stop. Throwing is also what this file already does for the
|
|
216
|
+
* sibling cases — `serializeTuple` on an unknown canonical operator,
|
|
217
|
+
* `deserializeLogicalCondition` past the nesting bound — and the REST parser
|
|
218
|
+
* already converts the latter into a 400.
|
|
219
|
+
*
|
|
220
|
+
* `statusCode`, `code` and `details` are carried as fields because the server's
|
|
221
|
+
* Hono error handler duck-types those off any thrown error: a decode path that
|
|
222
|
+
* forgets to convert still answers 400 with the canonical envelope instead of a
|
|
223
|
+
* 500 that says "An unexpected error occurred". `query-parser.ts` converts
|
|
224
|
+
* explicitly all the same — that is the path the contract is stated on, and an
|
|
225
|
+
* incidental 400 is not a contract.
|
|
226
|
+
*/
|
|
227
|
+
var UnknownFilterOperatorError = class extends Error {
|
|
228
|
+
/** The field the condition was written against. */
|
|
229
|
+
field;
|
|
230
|
+
/** The operator string as it arrived, verbatim. */
|
|
231
|
+
operator;
|
|
232
|
+
/** Every operator this dialect accepts, in canonical spelling. */
|
|
233
|
+
validOperators = ALL_WHERE_FILTER_OPS;
|
|
234
|
+
/** See the class docblock: read by the server's error handler. */
|
|
235
|
+
statusCode = 400;
|
|
236
|
+
code = "UNKNOWN_FILTER_OPERATOR";
|
|
237
|
+
details;
|
|
238
|
+
constructor(field, operator) {
|
|
239
|
+
super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
|
|
240
|
+
this.name = "UnknownFilterOperatorError";
|
|
241
|
+
this.field = field;
|
|
242
|
+
this.operator = operator;
|
|
243
|
+
this.details = {
|
|
244
|
+
field,
|
|
245
|
+
operator,
|
|
246
|
+
validOperators: ALL_WHERE_FILTER_OPS
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* Two to three characters of ASCII punctuation and nothing else — the shape
|
|
252
|
+
* every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
|
|
253
|
+
* one a column value effectively never has.
|
|
254
|
+
*
|
|
255
|
+
* Two characters minimum on purpose. A *single* punctuation character is a
|
|
256
|
+
* perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
|
|
257
|
+
* condition — and the only single-character operator anyone actually mistypes
|
|
258
|
+
* is `=`, which is named separately below. `<` and `>` need no special case:
|
|
259
|
+
* they are real operators and resolve.
|
|
260
|
+
*/
|
|
261
|
+
var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
|
|
262
|
+
/** Lowercase, strip everything that is not a letter or digit. */
|
|
263
|
+
function normalizeOperatorName(op) {
|
|
264
|
+
return op.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Every real operator name with its case and separators removed, so a
|
|
268
|
+
* respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
|
|
269
|
+
* recognised as an attempt at an operator rather than read as a value.
|
|
270
|
+
*
|
|
271
|
+
* These are rejected rather than accepted: admitting a second spelling of an
|
|
272
|
+
* operator would leave two wire spellings of one thing, and the rejection
|
|
273
|
+
* message names the one that works.
|
|
274
|
+
*/
|
|
275
|
+
var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
|
|
276
|
+
/**
|
|
277
|
+
* Operator names *other* query dialects use, which this one does not have.
|
|
278
|
+
*
|
|
279
|
+
* This list is curated, and deliberately so. For a word-shaped string there is
|
|
280
|
+
* no rule that separates "an operator the caller guessed" from "a value that
|
|
281
|
+
* happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
|
|
282
|
+
* `in` list, so the codec cannot simply refuse every unrecognised word in
|
|
283
|
+
* position 0. The line is therefore drawn by name, and only around names whose
|
|
284
|
+
* use as an operator is far more likely than their use as one of two sibling
|
|
285
|
+
* values. `contains` is the motivating case — the first thing a developer
|
|
286
|
+
* reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
|
|
287
|
+
*
|
|
288
|
+
* Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
|
|
289
|
+
* are left off: as operators they are rare, and as enum values they are common.
|
|
290
|
+
* Everywhere else the tie goes to *rejecting*, because a 400 naming the
|
|
291
|
+
* supported set costs the caller one round trip, and the alternative — which is
|
|
292
|
+
* what every name on this list used to produce — is a query that runs, returns
|
|
293
|
+
* rows, and is wrong.
|
|
294
|
+
*/
|
|
295
|
+
var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
|
|
296
|
+
"contains",
|
|
297
|
+
"notcontains",
|
|
298
|
+
"doesnotcontain",
|
|
299
|
+
"doesnotcontains",
|
|
300
|
+
"includes",
|
|
301
|
+
"notincludes",
|
|
302
|
+
"startswith",
|
|
303
|
+
"notstartswith",
|
|
304
|
+
"beginswith",
|
|
305
|
+
"startingwith",
|
|
306
|
+
"endswith",
|
|
307
|
+
"notendswith",
|
|
308
|
+
"matches",
|
|
309
|
+
"notmatches",
|
|
310
|
+
"regex",
|
|
311
|
+
"regexp",
|
|
312
|
+
"between",
|
|
313
|
+
"notbetween",
|
|
314
|
+
"equals",
|
|
315
|
+
"notequals",
|
|
316
|
+
"equalto",
|
|
317
|
+
"isequalto",
|
|
318
|
+
"isnotequalto",
|
|
319
|
+
"greaterthan",
|
|
320
|
+
"greaterthanorequal",
|
|
321
|
+
"greaterthanorequalto",
|
|
322
|
+
"lessthan",
|
|
323
|
+
"lessthanorequal",
|
|
324
|
+
"lessthanorequalto",
|
|
325
|
+
"isempty",
|
|
326
|
+
"isnotempty",
|
|
327
|
+
"oneof",
|
|
328
|
+
"noneof",
|
|
329
|
+
"anyof",
|
|
330
|
+
"allof",
|
|
331
|
+
"null",
|
|
332
|
+
"isnullorempty"
|
|
333
|
+
]);
|
|
334
|
+
/**
|
|
335
|
+
* Was this string *meant* as an operator?
|
|
336
|
+
*
|
|
337
|
+
* Only consulted after {@link toCanonicalOp} has already failed to resolve it,
|
|
338
|
+
* so a `true` here is always a rejection.
|
|
339
|
+
*/
|
|
340
|
+
function isOperatorShaped(op) {
|
|
341
|
+
if (op === "=") return true;
|
|
342
|
+
if (SYMBOLIC_OPERATOR.test(op)) return true;
|
|
343
|
+
const normalized = normalizeOperatorName(op);
|
|
344
|
+
if (!normalized) return false;
|
|
345
|
+
return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Read a `[op, value]` tuple, if that is what this is.
|
|
349
|
+
*
|
|
350
|
+
* Three outcomes, and the middle one is the defect this function exists for:
|
|
351
|
+
*
|
|
352
|
+
* - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
|
|
353
|
+
* - the operator does not resolve but was plainly meant as one → throw;
|
|
354
|
+
* - it does not look like an operator at all → `undefined`, and the caller
|
|
355
|
+
* falls back to reading the array as a list of values.
|
|
356
|
+
*
|
|
357
|
+
* The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
|
|
358
|
+
* only, with *everything else* — including every REST short-code — dropping
|
|
359
|
+
* through to `["in", raw]`. So the operator string itself became a value in a
|
|
360
|
+
* membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
|
|
361
|
+
* which matches, and the caller got back rows their filter was written to
|
|
362
|
+
* exclude. `["eq", "active"]` had the same shape of failure.
|
|
363
|
+
*/
|
|
364
|
+
function readTuple(field, raw) {
|
|
365
|
+
if (!Array.isArray(raw) || raw.length !== 2) return void 0;
|
|
366
|
+
const [op, value] = raw;
|
|
367
|
+
if (typeof op !== "string") return void 0;
|
|
368
|
+
const canonical = toCanonicalOp(op);
|
|
369
|
+
if (canonical) return [canonical, value];
|
|
370
|
+
if (op.includes(".")) return void 0;
|
|
371
|
+
if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The query-parameter names a REST listing reads as something other than a
|
|
375
|
+
* field filter: pagination and whether to count, sorting, includes,
|
|
376
|
+
* projection, search, the logical groups, the JSON `where` dialect, an
|
|
377
|
+
* aggregate's `select`/`groupBy`, and the soft-delete switches.
|
|
378
|
+
*
|
|
379
|
+
* One list for both ends. The server skips these names when it collects
|
|
380
|
+
* `?<field>=<op>.<value>` filters, and the SDK sends a filter on a column with
|
|
381
|
+
* one of these names inside `?where=` instead — a collection is free to have a
|
|
382
|
+
* `page`, `select` or `hard` column, and a filter on it sent as its own
|
|
383
|
+
* parameter was read as the parameter and never applied.
|
|
384
|
+
*/
|
|
385
|
+
var RESERVED_QUERY_KEYS = /* @__PURE__ */ new Set([
|
|
386
|
+
"limit",
|
|
387
|
+
"offset",
|
|
388
|
+
"page",
|
|
389
|
+
"after",
|
|
390
|
+
"orderBy",
|
|
391
|
+
"include",
|
|
392
|
+
"fields",
|
|
393
|
+
"distinct",
|
|
394
|
+
"count",
|
|
395
|
+
"searchString",
|
|
396
|
+
"searchExplain",
|
|
397
|
+
"vector_search",
|
|
398
|
+
"vector",
|
|
399
|
+
"vector_distance",
|
|
400
|
+
"vector_threshold",
|
|
401
|
+
"or",
|
|
402
|
+
"and",
|
|
403
|
+
"not",
|
|
404
|
+
"where",
|
|
405
|
+
"select",
|
|
406
|
+
"groupBy",
|
|
407
|
+
"deleted",
|
|
408
|
+
"hard"
|
|
409
|
+
]);
|
|
410
|
+
/**
|
|
411
|
+
* Encode the `<op>.<value>` half of a wire condition.
|
|
412
|
+
*
|
|
413
|
+
* This is the single leaf encoder. Both wire positions that carry a condition
|
|
414
|
+
* — a top-level query parameter (`?status=eq.active`) and a leaf inside an
|
|
415
|
+
* `and(...)`/`or(...)` group (`or(status.eq.active,…)`) — go through it, so a
|
|
416
|
+
* rule expressed here holds in both. The group serializer used to carry its
|
|
417
|
+
* own copy, and the copy had drifted on every rule that matters: `null` went
|
|
418
|
+
* out as the four-character string, the empty list as `()`, and an operator
|
|
419
|
+
* this dialect does not have was silently rewritten to `eq` — a filter that
|
|
420
|
+
* ran, returned rows, and answered a different question than the one asked.
|
|
421
|
+
*
|
|
422
|
+
* `escapeScalar` is the one thing the two positions legitimately disagree
|
|
423
|
+
* about. A scalar in a query parameter owns the whole value and needs no
|
|
424
|
+
* escaping; a scalar inside a group sits between the same commas a list item
|
|
425
|
+
* does, so a comma in it would end the condition early.
|
|
426
|
+
*/
|
|
427
|
+
function serializeOperatorAndValue(op, value, { escapeScalar, where }) {
|
|
428
|
+
if (typeof op !== "string") throw new TypeError(`${where}: operator must be a string, got ${typeof op}`);
|
|
429
|
+
const restOp = CANONICAL_OP_LOOKUP.get(op);
|
|
430
|
+
if (!restOp) throw new TypeError(`${where}: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`);
|
|
431
|
+
if (value === null && (op === "==" || op === "!=")) return op === "==" ? "isnull.null" : "notnull.null";
|
|
432
|
+
if (NULL_OPS.has(op)) return `${restOp}.null`;
|
|
433
|
+
if (Array.isArray(value)) {
|
|
434
|
+
if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;
|
|
435
|
+
return `${restOp}.(${value.map((v) => escapeWireValue(stringifyValue(v))).join(",")})`;
|
|
436
|
+
}
|
|
437
|
+
const scalar = stringifyValue(value);
|
|
438
|
+
return `${restOp}.${escapeScalar || !LIST_OPS.has(op) && scalarNeedsEscape(scalar) ? escapeWireValue(scalar) : scalar}`;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Serialize a single canonical condition tuple to a PostgREST dot-string.
|
|
442
|
+
*
|
|
443
|
+
* Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
|
|
444
|
+
*
|
|
445
|
+
* @example
|
|
446
|
+
* serializeTuple(["==", "active"]) // "eq.active"
|
|
447
|
+
* serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
|
|
448
|
+
* serializeTuple([">=", 18]) // "gte.18"
|
|
449
|
+
*/
|
|
450
|
+
function serializeTuple(tuple) {
|
|
451
|
+
if (!Array.isArray(tuple) || tuple.length !== 2) throw new TypeError(`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`);
|
|
452
|
+
const [op, value] = tuple;
|
|
453
|
+
return serializeOperatorAndValue(op, value, {
|
|
454
|
+
escapeScalar: false,
|
|
455
|
+
where: "serializeTuple"
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
|
|
460
|
+
* querystring record.
|
|
461
|
+
*
|
|
462
|
+
* - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
|
|
463
|
+
* - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
|
|
464
|
+
* - Single conditions produce a string value.
|
|
465
|
+
* - Multiple conditions on the same field produce a string array (repeated params).
|
|
466
|
+
*
|
|
467
|
+
* @example
|
|
468
|
+
* serializeFilter({ status: ["==", "active"] })
|
|
469
|
+
* // → { status: "eq.active" }
|
|
470
|
+
*
|
|
471
|
+
* serializeFilter({ age: [[">=", 18], ["<", 65]] })
|
|
472
|
+
* // → { age: ["gte.18", "lt.65"] }
|
|
473
|
+
*
|
|
474
|
+
* // Pre-serialized strings pass through unchanged:
|
|
475
|
+
* serializeFilter({ status: "eq.published" })
|
|
476
|
+
* // → { status: "eq.published" }
|
|
477
|
+
*/
|
|
478
|
+
function serializeFilter(filter) {
|
|
479
|
+
const result = {};
|
|
480
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
481
|
+
if (condition === void 0) continue;
|
|
482
|
+
if (typeof condition === "string") {
|
|
483
|
+
result[field] = condition;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
|
|
487
|
+
else result[field] = serializeTuple(condition);
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The spellings a null-testing operator's operand may take.
|
|
493
|
+
*
|
|
494
|
+
* The serializer writes `isnull.null`; a hand-written `isnull.true` means the
|
|
495
|
+
* same thing and has always been accepted. Anything else after the operator is
|
|
496
|
+
* not an operand it has — `notnull.reason` is a *value* — see
|
|
497
|
+
* {@link deserializeSingle}.
|
|
498
|
+
*/
|
|
499
|
+
var NULL_OPERANDS = /* @__PURE__ */ new Set([
|
|
500
|
+
"null",
|
|
501
|
+
"true",
|
|
502
|
+
"false",
|
|
503
|
+
""
|
|
504
|
+
]);
|
|
505
|
+
/**
|
|
506
|
+
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
507
|
+
*
|
|
508
|
+
* All values are returned as strings — the wire format carries no type
|
|
509
|
+
* metadata, so coercion is the data driver's responsibility.
|
|
510
|
+
*
|
|
511
|
+
* ## When a leading segment is an operator, and when it is part of the value
|
|
512
|
+
*
|
|
513
|
+
* `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean
|
|
514
|
+
* entirely different things, and the reading here decides which. The rule, in
|
|
515
|
+
* full:
|
|
516
|
+
*
|
|
517
|
+
* > A dot-string is read as `operator.operand` **only** when its first segment
|
|
518
|
+
* > names a known REST operator **and** what follows is a well-formed operand
|
|
519
|
+
* > *for that operator's arity*. Otherwise the whole string is the value.
|
|
520
|
+
*
|
|
521
|
+
* Arity, per operator family:
|
|
522
|
+
*
|
|
523
|
+
* - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised
|
|
524
|
+
* list and nothing else. `in.(draft,review)` is the operator; `in.progress`
|
|
525
|
+
* is the *value* `"in.progress"`, because there is no list there and so no
|
|
526
|
+
* `in` filter that could have been written. That case used to compile to
|
|
527
|
+
* `status IN ('progress')` — a filter the caller never wrote, quietly
|
|
528
|
+
* matching the wrong rows and, on a status field, hiding every row they were
|
|
529
|
+
* looking for.
|
|
530
|
+
* - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no
|
|
531
|
+
* operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value
|
|
532
|
+
* `"notnull.reason"`, not "reason is not null".
|
|
533
|
+
* - **Everything else** takes one scalar, and any remainder is one — including
|
|
534
|
+
* the empty string, so `eq.` really is "equals the empty string". A
|
|
535
|
+
* parenthesised remainder is a list for these too (`eq.(a,b)` is array
|
|
536
|
+
* equality); a scalar that is itself parenthesised arrives escaped,
|
|
537
|
+
* `eq.\(none\)`, and is decoded here — see {@link scalarNeedsEscape}.
|
|
538
|
+
*
|
|
539
|
+
* ### The one ambiguity that remains, and how to write past it
|
|
540
|
+
*
|
|
541
|
+
* A scalar operator's operand is unconstrained, so `?status=like.that` is a
|
|
542
|
+
* `LIKE 'that'` and no rule at this layer can tell it from the literal value
|
|
543
|
+
* `"like.that"` — both are well-formed encodings, and picking either by guess
|
|
544
|
+
* would break the other. Two spellings say "value" unambiguously, and both
|
|
545
|
+
* round-trip:
|
|
546
|
+
*
|
|
547
|
+
* - `?status=eq.like.that` — name the operator. The *first* segment is consumed
|
|
548
|
+
* as the operator and everything after it is the value, dots and all. This is
|
|
549
|
+
* what `serializeFilter` emits, which is why the SDK never meets the
|
|
550
|
+
* ambiguity at all.
|
|
551
|
+
* - `?where={"status":["==","like.that"]}` — the JSON dialect's tuple form.
|
|
552
|
+
*
|
|
553
|
+
* Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never
|
|
554
|
+
* ambiguous: their first segment names no operator to begin with.
|
|
555
|
+
*/
|
|
556
|
+
function deserializeSingle(raw) {
|
|
557
|
+
const dotIndex = raw.indexOf(".");
|
|
558
|
+
if (dotIndex === -1) return ["==", raw];
|
|
559
|
+
const prefix = raw.substring(0, dotIndex);
|
|
560
|
+
const rest = raw.substring(dotIndex + 1);
|
|
561
|
+
const canonicalOp = REST_OP_LOOKUP.get(prefix);
|
|
562
|
+
if (!canonicalOp) return ["==", raw];
|
|
563
|
+
if (NULL_OPS.has(canonicalOp)) {
|
|
564
|
+
if (!NULL_OPERANDS.has(rest)) return ["==", raw];
|
|
565
|
+
return [canonicalOp, null];
|
|
566
|
+
}
|
|
567
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
568
|
+
const inner = rest.slice(1, -1);
|
|
569
|
+
return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
|
|
570
|
+
}
|
|
571
|
+
if (LIST_OPS.has(canonicalOp)) return ["==", raw];
|
|
572
|
+
return [canonicalOp, decodeEscapedScalar(rest) ?? rest];
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Convert a PostgREST-style querystring record to `FilterValues`.
|
|
576
|
+
*
|
|
577
|
+
* - String values are parsed as single conditions.
|
|
578
|
+
* - String arrays (repeated query params) become multiple conditions on the same field.
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* deserializeFilter({ status: "eq.active" })
|
|
582
|
+
* // → { status: ["==", "active"] }
|
|
583
|
+
*
|
|
584
|
+
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
585
|
+
* // → { age: [[">=", "18"], ["<", "65"]] }
|
|
586
|
+
*
|
|
587
|
+
* @throws {UnknownFilterOperatorError} when a condition names an operator this
|
|
588
|
+
* dialect does not have. See that class for why a rejection here is a throw.
|
|
589
|
+
*/
|
|
590
|
+
function deserializeFilter(query) {
|
|
591
|
+
const result = {};
|
|
592
|
+
for (const [field, raw] of Object.entries(query)) {
|
|
593
|
+
if (raw === void 0) continue;
|
|
594
|
+
const tuple = readTuple(field, raw);
|
|
595
|
+
if (tuple) {
|
|
596
|
+
result[field] = tuple;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (Array.isArray(raw)) {
|
|
600
|
+
if (raw.length === 0) continue;
|
|
601
|
+
if (Array.isArray(raw[0])) {
|
|
602
|
+
const tuples = raw.map((item) => readTuple(field, item));
|
|
603
|
+
if (tuples.every((t) => t !== void 0)) {
|
|
604
|
+
result[field] = tuples;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
609
|
+
else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
|
|
610
|
+
else result[field] = ["in", raw];
|
|
611
|
+
} else if (typeof raw === "string") result[field] = deserializeSingle(raw);
|
|
612
|
+
else result[field] = ["==", raw];
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
|
|
618
|
+
*
|
|
619
|
+
* Leaf encoding is {@link serializeOperatorAndValue}, the same function
|
|
620
|
+
* `serializeTuple` uses, so `null`, the empty list and an unknown operator
|
|
621
|
+
* behave identically inside a group and in a query parameter.
|
|
622
|
+
*
|
|
623
|
+
* @throws {TypeError} when a leaf names an operator this dialect does not have.
|
|
624
|
+
* It used to fall back to `eq`, which turned `age >= 18` into `age = 18` with
|
|
625
|
+
* no diagnostic anywhere.
|
|
626
|
+
*
|
|
627
|
+
* @example
|
|
628
|
+
* serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
|
|
629
|
+
* // → "status.eq.active"
|
|
630
|
+
*
|
|
631
|
+
* serializeLogicalCondition({ column: "deleted_at", operator: "==", value: null })
|
|
632
|
+
* // → "deleted_at.isnull.null"
|
|
633
|
+
*
|
|
634
|
+
* serializeLogicalCondition({ type: "or", conditions: [...] })
|
|
635
|
+
* // → "or(status.eq.active,status.eq.pending)"
|
|
636
|
+
*/
|
|
637
|
+
function serializeLogicalCondition(cond) {
|
|
638
|
+
if ("type" in cond) {
|
|
639
|
+
const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
|
|
640
|
+
return `${cond.type}(${inner})`;
|
|
641
|
+
}
|
|
642
|
+
return `${escapeWireValue(cond.column)}.${serializeOperatorAndValue(cond.operator, cond.value, {
|
|
643
|
+
escapeScalar: true,
|
|
644
|
+
where: "serializeLogicalCondition"
|
|
645
|
+
})}`;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The positions of the dots that can separate a leaf's segments.
|
|
649
|
+
*
|
|
650
|
+
* Dots inside a list value (`in.(1.5,2.5)`) are not separators. The value
|
|
651
|
+
* always follows the operator, so the search only needs the region before the
|
|
652
|
+
* first unescaped paren.
|
|
653
|
+
*/
|
|
654
|
+
function leafSeparators(str) {
|
|
655
|
+
let limit = str.length;
|
|
656
|
+
for (let i = 0; i < str.length; i++) {
|
|
657
|
+
if (str[i] === "\\") {
|
|
658
|
+
i++;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (str[i] === "(") {
|
|
662
|
+
limit = i;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const dots = [];
|
|
667
|
+
for (let i = 0; i < limit; i++) {
|
|
668
|
+
if (str[i] === "\\") {
|
|
669
|
+
i++;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (str[i] === ".") dots.push(i);
|
|
673
|
+
}
|
|
674
|
+
return dots;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Split a leaf condition into `column`, operator token and value.
|
|
678
|
+
*
|
|
679
|
+
* The naive reading — column is everything before the first dot, operator is
|
|
680
|
+
* everything up to the second — cannot express a relation path. A filter on
|
|
681
|
+
* `author.name` serializes to `author.name.eq.bob` and came back as the column
|
|
682
|
+
* `author` with the operator `name`, which resolves to nothing, so the
|
|
683
|
+
* fallback made it `author == "eq.bob"`: a condition that runs and matches
|
|
684
|
+
* nothing, on a column the caller never named.
|
|
685
|
+
*
|
|
686
|
+
* So the operator is found rather than assumed: it is the first dot-separated
|
|
687
|
+
* segment after the column that resolves to a real operator. Everything before
|
|
688
|
+
* it is the column, everything after is the value. `version.eq.1.2.3` reads as
|
|
689
|
+
* `version == "1.2.3"` because the scan stops at the first match — the `eq` at
|
|
690
|
+
* offset 1, not a later segment — and `metadata->>x.eq.5` never had dots in the
|
|
691
|
+
* column to begin with.
|
|
692
|
+
*
|
|
693
|
+
* Returns `undefined` when no segment resolves — `status.active`, an equality
|
|
694
|
+
* written without an operator, which the caller handles.
|
|
695
|
+
*/
|
|
696
|
+
function splitLeafCondition(str) {
|
|
697
|
+
const dots = leafSeparators(str);
|
|
698
|
+
for (let i = 1; i < dots.length; i++) {
|
|
699
|
+
const operator = toCanonicalOp(str.substring(dots[i - 1] + 1, dots[i]));
|
|
700
|
+
if (!operator) continue;
|
|
701
|
+
return {
|
|
702
|
+
column: unescapeWireValue(str.substring(0, dots[i - 1])),
|
|
703
|
+
operator,
|
|
704
|
+
value: str.substring(dots[i] + 1)
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Parse a logical condition wire-format string back into a
|
|
710
|
+
* `LogicalCondition` or `FilterCondition`.
|
|
711
|
+
*
|
|
712
|
+
* @example
|
|
713
|
+
* deserializeLogicalCondition("status.eq.active")
|
|
714
|
+
* // → { column: "status", operator: "==", value: "active" }
|
|
715
|
+
*
|
|
716
|
+
* deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
|
|
717
|
+
* // → { type: "or", conditions: [...] }
|
|
718
|
+
*/
|
|
719
|
+
/**
|
|
720
|
+
* How deeply `or(...)`/`and(...)` groups may nest.
|
|
721
|
+
*
|
|
722
|
+
* This parser recurses once per level, on a value that arrives in a query
|
|
723
|
+
* string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
|
|
724
|
+
* stack size exceeded`, which a caller sees as a 500 about the call stack
|
|
725
|
+
* rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
|
|
726
|
+
* below that in practice, but "the HTTP layer happens to stop it" is not a
|
|
727
|
+
* bound this parser should rely on.
|
|
728
|
+
*
|
|
729
|
+
* Thirty-two is far past anything a real filter expresses; the deepest in this
|
|
730
|
+
* repository's own tests is three.
|
|
731
|
+
*/
|
|
732
|
+
var MAX_LOGICAL_NESTING_DEPTH = 32;
|
|
733
|
+
function deserializeLogicalCondition(str, nesting = 0) {
|
|
734
|
+
if (nesting > MAX_LOGICAL_NESTING_DEPTH) throw new Error(`Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. Flatten the condition — \`or(a,or(b,c))\` is \`or(a,b,c)\`.`);
|
|
735
|
+
const logicalMatch = str.match(/^(and|or|not)\((.+)\)$/);
|
|
736
|
+
if (logicalMatch) {
|
|
737
|
+
const type = logicalMatch[1];
|
|
738
|
+
const innerStr = logicalMatch[2];
|
|
739
|
+
return {
|
|
740
|
+
type,
|
|
741
|
+
conditions: splitGroupItems(innerStr).map((part) => deserializeLogicalCondition(part, nesting + 1))
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const leaf = splitLeafCondition(str);
|
|
745
|
+
if (!leaf) {
|
|
746
|
+
const firstDot = str.indexOf(".");
|
|
747
|
+
if (firstDot === -1) return {
|
|
748
|
+
column: unescapeWireValue(str),
|
|
749
|
+
operator: "==",
|
|
750
|
+
value: true
|
|
751
|
+
};
|
|
752
|
+
const dots = leafSeparators(str);
|
|
753
|
+
for (let i = 1; i < dots.length; i++) {
|
|
754
|
+
const segment = str.substring(dots[i - 1] + 1, dots[i]);
|
|
755
|
+
if (isOperatorShaped(segment)) throw new UnknownFilterOperatorError(unescapeWireValue(str.substring(0, dots[i - 1])), segment);
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
column: unescapeWireValue(str.substring(0, firstDot)),
|
|
759
|
+
operator: "==",
|
|
760
|
+
value: unescapeWireValue(str.substring(firstDot + 1))
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const { column, operator, value: valueStr } = leaf;
|
|
764
|
+
if (NULL_OPS.has(operator)) return {
|
|
765
|
+
column,
|
|
766
|
+
operator,
|
|
767
|
+
value: null
|
|
768
|
+
};
|
|
769
|
+
if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
|
|
770
|
+
const inner = valueStr.slice(1, -1);
|
|
771
|
+
return {
|
|
772
|
+
column,
|
|
773
|
+
operator,
|
|
774
|
+
value: inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
column,
|
|
779
|
+
operator,
|
|
780
|
+
value: unescapeWireValue(valueStr)
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
//#endregion
|
|
784
|
+
export { MAX_LOGICAL_NESTING_DEPTH, RESERVED_QUERY_KEYS, UnknownFilterOperatorError, deserializeFilter, deserializeLogicalCondition, serializeFilter, serializeLogicalCondition };
|
|
785
|
+
|
|
786
|
+
//# sourceMappingURL=filter-dialect.es.js.map
|