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