@osqd/jql 0.1.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/CHANGELOG.md +220 -0
- package/LICENSE +102 -0
- package/README.md +84 -0
- package/bin/jql.mjs +5 -0
- package/conformance/cases.json +290 -0
- package/dist/array.d.ts +11 -0
- package/dist/async.d.ts +44 -0
- package/dist/canonical.d.ts +18 -0
- package/dist/cjs/array.d.ts +11 -0
- package/dist/cjs/async.d.ts +44 -0
- package/dist/cjs/canonical.d.ts +18 -0
- package/dist/cjs/cli.d.ts +15 -0
- package/dist/cjs/collections.d.ts +34 -0
- package/dist/cjs/core.d.ts +117 -0
- package/dist/cjs/errors.d.ts +17 -0
- package/dist/cjs/explain.d.ts +30 -0
- package/dist/cjs/global.d.ts +90 -0
- package/dist/cjs/group.d.ts +47 -0
- package/dist/cjs/index.d.ts +25 -0
- package/dist/cjs/internal/closest.d.ts +9 -0
- package/dist/cjs/internal/duration.d.ts +15 -0
- package/dist/cjs/internal/equal.d.ts +34 -0
- package/dist/cjs/internal/glob.d.ts +30 -0
- package/dist/cjs/internal/order.d.ts +24 -0
- package/dist/cjs/internal/path.d.ts +109 -0
- package/dist/cjs/internal/record.d.ts +18 -0
- package/dist/cjs/internal/values.d.ts +41 -0
- package/dist/cjs/limits.d.ts +64 -0
- package/dist/cjs/operators.d.ts +81 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/plan.d.ts +79 -0
- package/dist/cjs/search.d.ts +42 -0
- package/dist/cjs/targets/mongo.d.ts +55 -0
- package/dist/cjs/text/index.d.ts +12 -0
- package/dist/cjs/text/parse.d.ts +91 -0
- package/dist/cjs/text/suggest.d.ts +16 -0
- package/dist/cjs/text/write.d.ts +34 -0
- package/dist/cjs/types.d.ts +236 -0
- package/dist/cjs/vocabulary.d.ts +106 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +2729 -0
- package/dist/cli.js.map +1 -0
- package/dist/collections.d.ts +34 -0
- package/dist/core.d.ts +117 -0
- package/dist/errors.d.ts +17 -0
- package/dist/explain.d.ts +30 -0
- package/dist/global.cjs +1953 -0
- package/dist/global.cjs.map +1 -0
- package/dist/global.d.ts +90 -0
- package/dist/global.js +1950 -0
- package/dist/global.js.map +1 -0
- package/dist/group.d.ts +47 -0
- package/dist/index.cjs +2529 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +2495 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/closest.d.ts +9 -0
- package/dist/internal/duration.d.ts +15 -0
- package/dist/internal/equal.d.ts +34 -0
- package/dist/internal/glob.d.ts +30 -0
- package/dist/internal/order.d.ts +24 -0
- package/dist/internal/path.d.ts +109 -0
- package/dist/internal/record.d.ts +18 -0
- package/dist/internal/values.d.ts +41 -0
- package/dist/limits.d.ts +64 -0
- package/dist/mongo.cjs +357 -0
- package/dist/mongo.cjs.map +1 -0
- package/dist/mongo.js +354 -0
- package/dist/mongo.js.map +1 -0
- package/dist/operators.d.ts +81 -0
- package/dist/plan.d.ts +79 -0
- package/dist/search.d.ts +42 -0
- package/dist/targets/mongo.d.ts +55 -0
- package/dist/text/index.d.ts +12 -0
- package/dist/text/parse.d.ts +91 -0
- package/dist/text/suggest.d.ts +16 -0
- package/dist/text/write.d.ts +34 -0
- package/dist/text.cjs +674 -0
- package/dist/text.cjs.map +1 -0
- package/dist/text.js +667 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/vocabulary.d.ts +106 -0
- package/docs/course/01-first-query.md +217 -0
- package/docs/course/02-operators.md +285 -0
- package/docs/course/03-arrays-and-paths.md +239 -0
- package/docs/course/04-combining.md +221 -0
- package/docs/course/05-dates.md +214 -0
- package/docs/course/06-typed-queries.md +240 -0
- package/docs/course/07-requests.md +261 -0
- package/docs/course/08-grouping.md +210 -0
- package/docs/course/09-explaining.md +171 -0
- package/docs/course/10-vocabulary.md +276 -0
- package/docs/course/11-the-search-box.md +349 -0
- package/docs/course/12-untrusted.md +257 -0
- package/docs/course/13-saved-filters.md +199 -0
- package/docs/course/14-streams-and-cli.md +276 -0
- package/docs/course/15-pushdown.md +240 -0
- package/docs/course/16-extending.md +199 -0
- package/docs/course/index.md +185 -0
- package/docs/design/decisions.md +198 -0
- package/docs/design/performance.md +102 -0
- package/docs/guides/adopting.md +81 -0
- package/docs/guides/pushdown.md +147 -0
- package/docs/guides/typescript.md +115 -0
- package/docs/guides/untrusted-input.md +86 -0
- package/docs/index.md +102 -0
- package/docs/reference/api.md +266 -0
- package/docs/reference/cli.md +103 -0
- package/docs/reference/index.md +12 -0
- package/docs/reference/specification.md +549 -0
- package/docs/reference/text-syntax.md +152 -0
- package/docs/start/quick-start.md +84 -0
- package/package.json +136 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Lesson 11 — The search box
|
|
2
|
+
|
|
3
|
+
**Goal:** let people type, and get back the same JQL you would have written yourself.
|
|
4
|
+
|
|
5
|
+
← [Course](index.md) · Previous: [A vocabulary](10-vocabulary.md) · Next: [Queries from outside](12-untrusted.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Nobody types JSON into a search box
|
|
10
|
+
|
|
11
|
+
`@osqd/jql/text` is the syntax people *do* type:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
status:open total:>100 -has:note
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It has no matcher of its own. It **compiles into a JQL document**, and the engine you have
|
|
18
|
+
been using for ten lessons runs that. There is one set of semantics, and the text is a second
|
|
19
|
+
way of writing it.
|
|
20
|
+
|
|
21
|
+
That matters more than it sounds. A search box with its own matcher drifts from the JSON one
|
|
22
|
+
within a week, and then a saved filter means one thing on the screen and another in the
|
|
23
|
+
export.
|
|
24
|
+
|
|
25
|
+
## Typing into it
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { filter } from "@osqd/jql";
|
|
29
|
+
import { parseText } from "@osqd/jql/text";
|
|
30
|
+
import { orders } from "./orders.mjs";
|
|
31
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
32
|
+
|
|
33
|
+
// A fixed clock, so "the last week" means the same thing every time this runs.
|
|
34
|
+
const options = { vocabulary: ORDERS, now: () => Date.parse("2026-09-25T12:00:00Z") };
|
|
35
|
+
|
|
36
|
+
const box = (typed) => {
|
|
37
|
+
const query = parseText(typed, { vocabulary: ORDERS });
|
|
38
|
+
const found = filter(orders, query, options).map((o) => o.id).join(" ") || "(none)";
|
|
39
|
+
console.log(typed.padEnd(28), found);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
box("status:open");
|
|
43
|
+
box("status:open country:gb");
|
|
44
|
+
box("-status:paid");
|
|
45
|
+
box("status:$in(open, cancelled)");
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
status:open o-1002 o-1005
|
|
50
|
+
status:open country:gb o-1005
|
|
51
|
+
-status:paid o-1002 o-1004 o-1005 o-1007
|
|
52
|
+
status:$in(open, cancelled) o-1002 o-1005 o-1007
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Adjacent terms are **and**, which is what narrowing means to the person typing. `-` is not,
|
|
56
|
+
and `$in(…)` is one of a set.
|
|
57
|
+
|
|
58
|
+
### Numbers and dates
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { filter } from "@osqd/jql";
|
|
62
|
+
import { parseText } from "@osqd/jql/text";
|
|
63
|
+
import { orders } from "./orders.mjs";
|
|
64
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
65
|
+
|
|
66
|
+
const options = { vocabulary: ORDERS, now: () => Date.parse("2026-09-25T12:00:00Z") };
|
|
67
|
+
|
|
68
|
+
const box = (typed) => {
|
|
69
|
+
const found = filter(orders, parseText(typed, { vocabulary: ORDERS }), options);
|
|
70
|
+
console.log(typed.padEnd(28), found.map((o) => o.id).join(" ") || "(none)");
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
box("total:>100");
|
|
74
|
+
box("total:10..40");
|
|
75
|
+
box("at:>2026-09-18");
|
|
76
|
+
box("at:>-7d");
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
total:>100 o-1005 o-1007
|
|
81
|
+
total:10..40 o-1001 o-1002 o-1004 o-1006 o-1008
|
|
82
|
+
at:>2026-09-18 o-1006 o-1007 o-1008
|
|
83
|
+
at:>-7d o-1006 o-1007 o-1008
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`total` and `placed` were declared `number` and `date` in the vocabulary, which is what lets
|
|
87
|
+
`>100` and `>-7d` be read as comparisons rather than searched for as text. That is the whole
|
|
88
|
+
job of `kind`.
|
|
89
|
+
|
|
90
|
+
### Presence, alternatives and bare words
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { filter } from "@osqd/jql";
|
|
94
|
+
import { parseText } from "@osqd/jql/text";
|
|
95
|
+
import { orders } from "./orders.mjs";
|
|
96
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
97
|
+
|
|
98
|
+
const box = (typed) => {
|
|
99
|
+
const found = filter(orders, parseText(typed, { vocabulary: ORDERS }), { vocabulary: ORDERS });
|
|
100
|
+
console.log(typed.padEnd(28), found.map((o) => o.id).join(" ") || "(none)");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
box("has:note");
|
|
104
|
+
box("-has:note");
|
|
105
|
+
box("sku:desk-lamp $or tag:gift");
|
|
106
|
+
box("ada");
|
|
107
|
+
box('"gift wrap"');
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
has:note o-1001 o-1003 o-1004 o-1006 o-1007
|
|
112
|
+
-has:note o-1002 o-1005 o-1008
|
|
113
|
+
sku:desk-lamp $or tag:gift o-1001 o-1005 o-1007
|
|
114
|
+
ada o-1001 o-1008
|
|
115
|
+
"gift wrap" o-1006
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A bare word searches the `text` fields the vocabulary listed — `id`, `who`, `email`, `sku` and
|
|
119
|
+
`note` — so `ada` finds both Adas without anybody naming a field. A quoted phrase is one term.
|
|
120
|
+
|
|
121
|
+
## The syntax, in one table
|
|
122
|
+
|
|
123
|
+
| Typed | Means |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `status:open` | the field, read according to its `kind` |
|
|
126
|
+
| `status:open country:gb` | adjacent terms are **and** |
|
|
127
|
+
| `-status:paid`, `!status:paid` | not |
|
|
128
|
+
| `status:$in(open, cancelled)` | one of a set; `$notin(…)` for none of them |
|
|
129
|
+
| `total:>100`, `total:>=100`, `total:10..20` | comparisons and ranges, on a `number` or `date` field |
|
|
130
|
+
| `at:>2026-09-18`, `at:>-7d` | an instant, or a signed duration from now |
|
|
131
|
+
| `has:note`, `-has:note` | the field is set, or is not |
|
|
132
|
+
| `a $or b`, `$not a`, `(a $or b) c` | precedence: `$not` tightest, then and, then `$or` |
|
|
133
|
+
| `ada`, `"gift wrap"` | a bare word or phrase, in the vocabulary's `text` fields |
|
|
134
|
+
|
|
135
|
+
Operators carry a `$` on purpose. A bare `or` is a word that appears in notes and addresses,
|
|
136
|
+
and a language where an ordinary search word silently becomes an operator is a language that
|
|
137
|
+
lies about what it matched.
|
|
138
|
+
|
|
139
|
+
## What it compiles to
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { parseText } from "@osqd/jql/text";
|
|
143
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
144
|
+
|
|
145
|
+
console.log(JSON.stringify(parseText("status:open total:>100", { vocabulary: ORDERS }), null, 2));
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
{
|
|
150
|
+
"$and": [
|
|
151
|
+
{
|
|
152
|
+
"status": {
|
|
153
|
+
"$eq": "open",
|
|
154
|
+
"$options": "i"
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"total": {
|
|
159
|
+
"$gt": 100
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Nothing exotic: it is the query you would have written by hand. `$options: "i"` is there
|
|
167
|
+
because `status` was declared `exact`, which means "equals it, ignoring case".
|
|
168
|
+
|
|
169
|
+
## The parser never throws
|
|
170
|
+
|
|
171
|
+
It runs on **every keystroke**, so half-typed input is the normal state rather than an error:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
import { count } from "@osqd/jql";
|
|
175
|
+
import { parseText } from "@osqd/jql/text";
|
|
176
|
+
import { orders } from "./orders.mjs";
|
|
177
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
178
|
+
|
|
179
|
+
const halfTyped = parseText("status:open $and (total:>", { vocabulary: ORDERS });
|
|
180
|
+
|
|
181
|
+
console.log(JSON.stringify(halfTyped));
|
|
182
|
+
console.log("matches", count(orders, halfTyped, { vocabulary: ORDERS }), "of 8");
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
{"$and":[{"status":{"$eq":"open","$options":"i"}},{"total":{"$in":[]}}]}
|
|
187
|
+
matches 0 of 8
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
An unclosed bracket, a dangling `$or`, a `$in(` with nothing after it: each parses to the best
|
|
191
|
+
reading available. Look at what the unfinished half became — `{ "$in": [] }`, a term that
|
|
192
|
+
matches **nothing**.
|
|
193
|
+
|
|
194
|
+
That direction is chosen deliberately. A half-typed filter that quietly *widened* to everything
|
|
195
|
+
would show the operator a screen full of rows that are not the ones they are looking for, and
|
|
196
|
+
nothing on the screen would say the filter was incomplete.
|
|
197
|
+
|
|
198
|
+
## Putting a saved filter back in the box
|
|
199
|
+
|
|
200
|
+
A dashboard that stores filters as JSON has to show one in the box when somebody opens it to
|
|
201
|
+
edit. That is `toText`:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
import { toText } from "@osqd/jql/text";
|
|
205
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
206
|
+
|
|
207
|
+
const stored = [
|
|
208
|
+
{ status: { $eq: "open", $options: "i" } },
|
|
209
|
+
{ $and: [{ country: { $eq: "GB", $options: "i" } }, { total: { $gt: 100 } }] },
|
|
210
|
+
{ placed: { $gte: { $date: { $ago: "7d" } } } },
|
|
211
|
+
{ $not: { tag: { $eq: "gift", $options: "i" } } },
|
|
212
|
+
{ total: { $gte: 10, $lte: 40 } },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
for (const query of stored) console.log(JSON.stringify(toText(query, { vocabulary: ORDERS }).text));
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
"status:open"
|
|
220
|
+
"country:GB total:>100"
|
|
221
|
+
"placed:>=-7d"
|
|
222
|
+
"-tag:gift"
|
|
223
|
+
"total:10..40"
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Parsed back with the same vocabulary, each of those means precisely the clauses it carries —
|
|
227
|
+
a property the test suite holds the writer to on thousands of generated queries.
|
|
228
|
+
|
|
229
|
+
### When the box cannot say it
|
|
230
|
+
|
|
231
|
+
`toText` is deliberately **not** total. The text syntax is smaller than JQL, and some queries
|
|
232
|
+
have no spelling in it:
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
import { toText } from "@osqd/jql/text";
|
|
236
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
237
|
+
|
|
238
|
+
const hard = { status: { $eq: "open", $options: "i" }, tag: { $size: 2 } };
|
|
239
|
+
const form = toText(hard, { vocabulary: ORDERS });
|
|
240
|
+
|
|
241
|
+
console.log("text :", JSON.stringify(form.text));
|
|
242
|
+
console.log("complete :", form.complete);
|
|
243
|
+
console.log("unexpressed:", JSON.stringify(form.unexpressed, null, 2));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
text : "status:open"
|
|
248
|
+
complete : false
|
|
249
|
+
unexpressed: [
|
|
250
|
+
{
|
|
251
|
+
"at": "tag.$size",
|
|
252
|
+
"clause": {
|
|
253
|
+
"$size": 2
|
|
254
|
+
},
|
|
255
|
+
"why": "\"$size\" has no spelling in the text syntax"
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
This is the contract, and it is worth reading twice. The text holds only the part the box
|
|
261
|
+
*could* express — `status:open` — and says nothing at all about the `$size` constraint that is
|
|
262
|
+
still in force. So `complete` is `false`, and `unexpressed` names the clause that got left
|
|
263
|
+
behind and why.
|
|
264
|
+
|
|
265
|
+
A caller that shows `form.text` and ignores `form.complete` has just shown somebody a filter
|
|
266
|
+
**wider than the one that is running**. The signature returns all three so that the check is
|
|
267
|
+
one line:
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
import { toText } from "@osqd/jql/text";
|
|
271
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
272
|
+
|
|
273
|
+
const show = (query) => {
|
|
274
|
+
const form = toText(query, { vocabulary: ORDERS });
|
|
275
|
+
console.log(form.complete ? "shows it all " : "⚠ incomplete ", JSON.stringify(form.text));
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
show({ status: { $eq: "open", $options: "i" } });
|
|
279
|
+
show({ status: { $eq: "open", $options: "i" }, tag: { $size: 2 } });
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
shows it all "status:open"
|
|
284
|
+
⚠ incomplete "status:open"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Completions
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
import { suggest } from "@osqd/jql/text";
|
|
291
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
292
|
+
|
|
293
|
+
const caretAt = [["stat", 4], ["status:", 7], ["status:op", 9], ["-co", 3], ["status:open c", 13], ["$", 1]];
|
|
294
|
+
|
|
295
|
+
for (const [typed, caret] of caretAt) {
|
|
296
|
+
const { options, from, to } = suggest(typed, caret, ORDERS);
|
|
297
|
+
console.log(`${JSON.stringify(typed).padEnd(16)} replace [${from},${to}] with: ${options.join(" ") || "(nothing)"}`);
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
"stat" replace [0,4] with: status:
|
|
303
|
+
"status:" replace [0,7] with: status:open status:paid status:refunded status:cancelled
|
|
304
|
+
"status:op" replace [0,9] with: status:open
|
|
305
|
+
"-co" replace [0,3] with: -country:
|
|
306
|
+
"status:open c" replace [12,13] with: channel: country:
|
|
307
|
+
"$" replace [0,1] with: $and $or $not $in $notin
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`from` and `to` are the span to replace, so inserting a completion does not disturb the rest
|
|
311
|
+
of the input.
|
|
312
|
+
|
|
313
|
+
The values come from the `values` you listed in the vocabulary. A field that can hold anything
|
|
314
|
+
offers none, because guessing there would be inventing options rather than completing them.
|
|
315
|
+
|
|
316
|
+
## Exercise
|
|
317
|
+
|
|
318
|
+
Harbour's console has a saved filter: `{ status: { $eq: "open", $options: "i" }, outstanding: { $gt: 100 } }`.
|
|
319
|
+
Put it in the box and say what the operator sees.
|
|
320
|
+
|
|
321
|
+
<details>
|
|
322
|
+
<summary>Answer</summary>
|
|
323
|
+
|
|
324
|
+
```js
|
|
325
|
+
import { toText } from "@osqd/jql/text";
|
|
326
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
327
|
+
|
|
328
|
+
const form = toText({ status: { $eq: "open", $options: "i" }, outstanding: { $gt: 100 } }, { vocabulary: ORDERS });
|
|
329
|
+
|
|
330
|
+
console.log(form);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
{
|
|
335
|
+
text: 'status:open outstanding:>100',
|
|
336
|
+
complete: true,
|
|
337
|
+
unexpressed: []
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
A computed field is a name like any other here — the box neither knows nor cares that
|
|
342
|
+
`outstanding` is worked out rather than stored. Type that text back in and you get the same
|
|
343
|
+
query.
|
|
344
|
+
</details>
|
|
345
|
+
|
|
346
|
+
## Related
|
|
347
|
+
|
|
348
|
+
- [Text syntax](../reference/text-syntax.md) — every term, and what each `kind` does with it
|
|
349
|
+
- [Library API](../reference/api.md) — `parseText`, `toText`, `suggest`
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Lesson 12 — Queries from outside
|
|
2
|
+
|
|
3
|
+
**Goal:** accept a query from a URL or a request body without accepting *every* query.
|
|
4
|
+
|
|
5
|
+
← [Course](index.md) · Previous: [The search box](11-the-search-box.md) · Next: [Saved filters](13-saved-filters.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## A filter in a URL is a lovely API
|
|
10
|
+
|
|
11
|
+
…right up until it is a way to read every field of every row, and spend a minute of CPU doing
|
|
12
|
+
it. A query is data, which is what lets it travel — and what lets one arrive from somebody you
|
|
13
|
+
have never met.
|
|
14
|
+
|
|
15
|
+
Three independent things make that safe, and a fourth ties them together:
|
|
16
|
+
|
|
17
|
+
| | |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| **the untrusted limits** | bound **cost** — size, depth, patterns |
|
|
20
|
+
| **an allowlist** | bounds **capability** — the operators this endpoint answers |
|
|
21
|
+
| **a strict vocabulary** | bounds **surface** — the fields it will talk about |
|
|
22
|
+
| **`validate`** | one call that answers 400 with a sentence instead of 500 with a stack |
|
|
23
|
+
|
|
24
|
+
## The endpoint
|
|
25
|
+
|
|
26
|
+
Save this as `harbour/public-api.mjs`; the examples below use it.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import { UNTRUSTED_LIMITS, defineVocabulary, validate } from "@osqd/jql";
|
|
30
|
+
import { orders } from "./orders.mjs";
|
|
31
|
+
|
|
32
|
+
/** The fields a stranger may ask about — and, because of `strict`, no others. */
|
|
33
|
+
const PUBLIC = defineVocabulary()({
|
|
34
|
+
fields: {
|
|
35
|
+
status: { kind: "exact", values: ["open", "paid", "refunded", "cancelled"] },
|
|
36
|
+
country: { path: "customer.country", kind: "exact" },
|
|
37
|
+
total: { kind: "number" },
|
|
38
|
+
placed: { kind: "date" },
|
|
39
|
+
},
|
|
40
|
+
strict: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** The untrusted caps, plus the short menu of operators this endpoint answers. */
|
|
44
|
+
const limits = {
|
|
45
|
+
...UNTRUSTED_LIMITS,
|
|
46
|
+
allowOperators: ["$eq", "$ne", "$in", "$gt", "$gte", "$lt", "$lte", "$and", "$or"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** One request: JSON in, a status line out. */
|
|
50
|
+
export function endpoint(body) {
|
|
51
|
+
const checked = validate(JSON.parse(body), { vocabulary: PUBLIC, limits });
|
|
52
|
+
if (!checked.valid) return `400 ${checked.error.message}`;
|
|
53
|
+
return `200 ${orders.filter(checked.test).map((o) => o.id).join(" ") || "(no matches)"}`;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What it accepts
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { endpoint } from "./public-api.mjs";
|
|
61
|
+
|
|
62
|
+
console.log(endpoint('{"status":"open"}'));
|
|
63
|
+
console.log(endpoint('{"country":"GB","total":{"$gte":20}}'));
|
|
64
|
+
console.log(endpoint('{"placed":{"$gte":{"$date":"2026-09-18"}}}'));
|
|
65
|
+
console.log(endpoint('{"status":{"$in":[]}}'));
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
200 o-1002 o-1005
|
|
70
|
+
200 o-1001 o-1005
|
|
71
|
+
200 o-1006 o-1007 o-1008
|
|
72
|
+
200 (no matches)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Ordinary queries, named through the vocabulary. The last one matches nothing and is still a
|
|
76
|
+
perfectly valid request — "no results" is an answer, not an error.
|
|
77
|
+
|
|
78
|
+
## What it refuses, and what it says
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { endpoint } from "./public-api.mjs";
|
|
82
|
+
|
|
83
|
+
for (const body of [
|
|
84
|
+
'{"customer.email":"ada@example.com"}',
|
|
85
|
+
'{"note":{"$contains":"gift"}}',
|
|
86
|
+
'{"total":{"$gtt":20}}',
|
|
87
|
+
'{"status":{"$regex":"^o"}}',
|
|
88
|
+
'{"$text":"ada"}',
|
|
89
|
+
]) {
|
|
90
|
+
console.log(body);
|
|
91
|
+
console.log(" ", endpoint(body));
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
{"customer.email":"ada@example.com"}
|
|
97
|
+
400 at customer.email: "customer.email" is not a field here
|
|
98
|
+
{"note":{"$contains":"gift"}}
|
|
99
|
+
400 at note: "note" is not a field here
|
|
100
|
+
{"total":{"$gtt":20}}
|
|
101
|
+
400 at total.$gtt: "$gtt" is not an operator this query may use ($eq, $ne, $in, $gt, $gte, $lt, $lte, $and, …)
|
|
102
|
+
{"status":{"$regex":"^o"}}
|
|
103
|
+
400 at status.$regex: "$regex" is not an operator this query may use ($eq, $ne, $in, $gt, $gte, $lt, $lte, $and, …)
|
|
104
|
+
{"$text":"ada"}
|
|
105
|
+
400 at $text: "$text" is not an operator this query may use ($eq, $ne, $in, $gt, $gte, $lt, $lte, $and, …)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Every refusal is a sentence you can hand straight back to the caller. It quotes only the query
|
|
109
|
+
they sent, and it names the place in it — so a partner integrating against your API can fix
|
|
110
|
+
their request without opening a support ticket.
|
|
111
|
+
|
|
112
|
+
## Why `validate` and not `try`/`compile`
|
|
113
|
+
|
|
114
|
+
`validate` **hands back the predicate it compiled**. That is not a convenience; it closes a
|
|
115
|
+
real hole. The obvious alternative has a gap in the middle of it:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
function handler(body: string) {
|
|
119
|
+
const query = JSON.parse(body);
|
|
120
|
+
if (!isValid(query, { limits: UNTRUSTED_LIMITS })) return respond(400);
|
|
121
|
+
const test = compile(query); // ← the untrusted limits are gone
|
|
122
|
+
return respond(200, orders.filter(test));
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
!! THREW
|
|
128
|
+
file:///tmp/claude-1000/-home-micha-Documents-OSQD/b2e0526c-49d7-4fed-9959-41234adfc826/scratchpad/harbour/authoring-3.mjs:1
|
|
129
|
+
if (!isValid(query, { limits: UNTRUSTED_LIMITS })) return respond(400);
|
|
130
|
+
^^^^^^
|
|
131
|
+
|
|
132
|
+
SyntaxError: Illegal return statement
|
|
133
|
+
at compileSourceTextModule (node:internal/modules/esm/utils:346:16)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Two compiles, two sets of options, and the check no longer describes the thing that runs.
|
|
137
|
+
Nothing in that code looks wrong. With `validate`, the query you checked *is* the query you
|
|
138
|
+
run.
|
|
139
|
+
|
|
140
|
+
## The limits
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
import { DEFAULT_LIMITS, UNTRUSTED_LIMITS } from "@osqd/jql";
|
|
144
|
+
|
|
145
|
+
for (const [name, limits] of [["default", DEFAULT_LIMITS], ["untrusted", UNTRUSTED_LIMITS]]) {
|
|
146
|
+
console.log(name);
|
|
147
|
+
for (const [key, value] of Object.entries(limits)) console.log(" ", key.padEnd(18), JSON.stringify(value));
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
default
|
|
153
|
+
maxDepth 32
|
|
154
|
+
maxNodes 10000
|
|
155
|
+
maxPatternLength 1024
|
|
156
|
+
maxGlobLength 1024
|
|
157
|
+
maxTextDepth 16
|
|
158
|
+
allowRegex true
|
|
159
|
+
allowOperators "all"
|
|
160
|
+
untrusted
|
|
161
|
+
maxDepth 16
|
|
162
|
+
maxNodes 512
|
|
163
|
+
maxPatternLength 0
|
|
164
|
+
maxGlobLength 256
|
|
165
|
+
maxTextDepth 8
|
|
166
|
+
allowRegex false
|
|
167
|
+
allowOperators "all"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`UNTRUSTED_LIMITS` halves the depth, caps the query at 512 fields and operators, and **turns
|
|
171
|
+
`$regex` off** — `maxPatternLength: 0`.
|
|
172
|
+
|
|
173
|
+
That last one is the important one. No engine can tell a pattern that backtracks for a minute
|
|
174
|
+
(`(a+)+$` against a long run of `a`s) from one that returns immediately, without running it.
|
|
175
|
+
The string operators from lesson 2 answer almost everything a pattern would, in linear time,
|
|
176
|
+
and they stay available.
|
|
177
|
+
|
|
178
|
+
Every cap **refuses** rather than truncates. A query quietly cut short answers a different
|
|
179
|
+
question, and nobody downstream can tell that it did.
|
|
180
|
+
|
|
181
|
+
## The allowlist
|
|
182
|
+
|
|
183
|
+
`$regex` being off is a decision this library makes for you. Which of the *rest* your endpoint
|
|
184
|
+
wants is a decision only you can make: `$text` walks a whole document, `$elemMatch` carries a
|
|
185
|
+
query of its own. An endpoint that needs neither says so once, in `allowOperators`, rather
|
|
186
|
+
than finding out later which of them somebody used.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
import { validate } from "@osqd/jql";
|
|
190
|
+
|
|
191
|
+
const typo = validate({ a: 1 }, { limits: { allowOperators: ["$eq", "$gte", "$grater"] } });
|
|
192
|
+
|
|
193
|
+
console.log(typo.valid ? "accepted" : typo.error.message);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
at limits.allowOperators: "$grater" is not an operator, so allowing it allows nothing
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A typo in the allowlist is refused too. An allowlist that allows nothing while looking like it
|
|
201
|
+
allows something is precisely the failure this library exists to prevent.
|
|
202
|
+
|
|
203
|
+
## The strict vocabulary
|
|
204
|
+
|
|
205
|
+
This is the defence people skip and regret. Without it a query can reach **every field of
|
|
206
|
+
every row you run it over** — including the ones you added last week and never meant to
|
|
207
|
+
publish. `customer.email` and `note` were refused above not because they are secret, but
|
|
208
|
+
because they were never promised.
|
|
209
|
+
|
|
210
|
+
## What this is not
|
|
211
|
+
|
|
212
|
+
**Not an access control layer.** A query runs over the rows you hand it. If a caller must not
|
|
213
|
+
see a row, do not put the row in the collection — filter first, then run their query over what
|
|
214
|
+
is left.
|
|
215
|
+
|
|
216
|
+
**Not a bound on the request.** A `$in` of a million values is a legitimate query, and `$in`
|
|
217
|
+
lists deliberately do not count towards `maxNodes`, because looking one item up in a set costs
|
|
218
|
+
the same however long the list is. Cap the body where you read it.
|
|
219
|
+
|
|
220
|
+
## Exercise
|
|
221
|
+
|
|
222
|
+
Harbour's public API should let a partner ask about their own orders by status and date, but
|
|
223
|
+
never about money. Write the vocabulary, and prove it by showing what happens to
|
|
224
|
+
`{"total":{"$gte":100}}`.
|
|
225
|
+
|
|
226
|
+
<details>
|
|
227
|
+
<summary>Answer</summary>
|
|
228
|
+
|
|
229
|
+
Leave `total` out of the vocabulary entirely. `strict` does the rest:
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { UNTRUSTED_LIMITS, defineVocabulary, validate } from "@osqd/jql";
|
|
233
|
+
|
|
234
|
+
const PARTNER = defineVocabulary()({
|
|
235
|
+
fields: { status: { kind: "exact" }, placed: { kind: "date" } },
|
|
236
|
+
strict: true,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const asked = validate({ total: { $gte: 100 } }, { vocabulary: PARTNER, limits: UNTRUSTED_LIMITS });
|
|
240
|
+
|
|
241
|
+
console.log(asked.valid ? "accepted" : asked.error.message);
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
at total: "total" is not a field here
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
A field that is not in the vocabulary cannot be filtered on — *and* cannot be sorted by or
|
|
249
|
+
projected with, because `fields`, `omit` and `sort` resolve their names through the same
|
|
250
|
+
vocabulary. One list, one contract.
|
|
251
|
+
</details>
|
|
252
|
+
|
|
253
|
+
## Related
|
|
254
|
+
|
|
255
|
+
- [Queries from outside](../guides/untrusted-input.md) — the same ground as a reference page
|
|
256
|
+
- [Security](../../SECURITY.md) — what this does and does not promise
|
|
257
|
+
- [Specification §10](../reference/specification.md#10-limits) — what the standard says about caps
|