@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,171 @@
|
|
|
1
|
+
# Lesson 9 — Why did this not match?
|
|
2
|
+
|
|
3
|
+
**Goal:** answer "why isn't this row in my list?" in one call, with the values the filter
|
|
4
|
+
actually read.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Previous: [Counting by a field](08-grouping.md) · Next: [A vocabulary](10-vocabulary.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The support question
|
|
11
|
+
|
|
12
|
+
*"Why isn't order 1002 on my screen?"*
|
|
13
|
+
|
|
14
|
+
A filter that returns nothing tells you nothing about which part of it was wrong. Without a
|
|
15
|
+
tool, answering that means commenting clauses out one at a time until the row reappears.
|
|
16
|
+
|
|
17
|
+
`explain(query, item)` takes the query apart and answers for **each piece separately**, with
|
|
18
|
+
the values it read. It is the only function in the library that tells you about a row that did
|
|
19
|
+
*not* match.
|
|
20
|
+
|
|
21
|
+
## A first explanation
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { explain } from "@osqd/jql";
|
|
25
|
+
import { orders } from "./orders.mjs";
|
|
26
|
+
|
|
27
|
+
const filterInUse = { status: "paid", "customer.country": "GB", total: { $gte: 20 } };
|
|
28
|
+
const order = orders.find((o) => o.id === "o-1002");
|
|
29
|
+
|
|
30
|
+
const result = explain(filterInUse, order);
|
|
31
|
+
|
|
32
|
+
console.log(result.matched, "—", result.because);
|
|
33
|
+
for (const part of result.parts) console.log(" ", part.matched ? "yes" : "no ", part.at, "—", part.because);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
false — 1 of 3 parts hold
|
|
38
|
+
no status — status is "open"
|
|
39
|
+
no customer.country — customer.country is "US"
|
|
40
|
+
yes total — total is 30
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
There is the answer, and it is not the clause anybody would have guessed. The order is missing
|
|
44
|
+
because it is **open and American**; whoever asked was almost certainly looking at the total.
|
|
45
|
+
|
|
46
|
+
## What an explanation holds
|
|
47
|
+
|
|
48
|
+
| | |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `matched` | whether this clause holds |
|
|
51
|
+
| `at` | where it is in the query, written as you would reach it in code: `$or[1].tags` |
|
|
52
|
+
| `clause` | the JSON fragment that was evaluated |
|
|
53
|
+
| `because` | one sentence: what was read, or how the parts came out |
|
|
54
|
+
| `parts` | the clauses this one is made of |
|
|
55
|
+
|
|
56
|
+
`parts` nests, so a useful way to look at an explanation is to walk it:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { explain } from "@osqd/jql";
|
|
60
|
+
import { orders } from "./orders.mjs";
|
|
61
|
+
|
|
62
|
+
const draw = (node, depth = 0) => {
|
|
63
|
+
const where = node.at === "" ? "the query" : node.at;
|
|
64
|
+
console.log(`${" ".repeat(depth)}${node.matched ? "yes" : "no "} ${where} — ${node.because}`);
|
|
65
|
+
for (const part of node.parts ?? []) draw(part, depth + 2);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const order = orders.find((o) => o.id === "o-1002");
|
|
69
|
+
|
|
70
|
+
draw(explain({ $or: [{ "lines.sku": "desk-lamp" }, { tags: "gift" }] }, order));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
no $or — 0 of 2 parts hold, and one is enough
|
|
75
|
+
no $or[0].lines.sku — lines.sku is "pad-a5"
|
|
76
|
+
no $or[1].tags — tags is []
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Keep that `draw` function; the rest of the lesson uses it.
|
|
80
|
+
|
|
81
|
+
Notice `$or[0].lines.sku` — the path into the query is exact enough to find the clause in a
|
|
82
|
+
long filter by search, and `because` says how the branch came out as well as what each side
|
|
83
|
+
read.
|
|
84
|
+
|
|
85
|
+
## The values it read
|
|
86
|
+
|
|
87
|
+
This is where the time is saved:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { explain } from "@osqd/jql";
|
|
91
|
+
import { orders } from "./orders.mjs";
|
|
92
|
+
|
|
93
|
+
const draw = (node, depth = 0) => {
|
|
94
|
+
const where = node.at === "" ? "the query" : node.at;
|
|
95
|
+
console.log(`${" ".repeat(depth)}${node.matched ? "yes" : "no "} ${where} — ${node.because}`);
|
|
96
|
+
for (const part of node.parts ?? []) draw(part, depth + 2);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
draw(explain({ tags: "gift" }, orders.find((o) => o.id === "o-1002")));
|
|
100
|
+
draw(explain({ note: "x" }, orders.find((o) => o.id === "o-1002")));
|
|
101
|
+
draw(explain({ "lines.sku": "desk-lamp" }, orders.find((o) => o.id === "o-1001")));
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
no tags — tags is []
|
|
106
|
+
no note — note is missing
|
|
107
|
+
no lines.sku — lines.sku is each of "pen-fine", "ink-blue"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Three different failures, each described differently:
|
|
111
|
+
|
|
112
|
+
- `tags is []` — the array was there and empty, not missing;
|
|
113
|
+
- a missing field says so, rather than being reported as some falsy value;
|
|
114
|
+
- when a path reaches **several** values you get `each of …`, listing them. That last line is
|
|
115
|
+
usually the moment somebody realises their query means what lesson 3 said it meant rather
|
|
116
|
+
than what they read it as.
|
|
117
|
+
|
|
118
|
+
## It runs the real engine
|
|
119
|
+
|
|
120
|
+
Every clause is compiled on its own and tested, rather than interpreted by a second reading of
|
|
121
|
+
the language. That is worth stating plainly, because the alternative is tempting and wrong: a
|
|
122
|
+
second reading would be a second set of semantics, and the only thing worse than a filter you
|
|
123
|
+
cannot explain is an explanation that disagrees with what the filter did.
|
|
124
|
+
|
|
125
|
+
The cost is that it compiles once per clause, so `explain` is for **one row at a time** —
|
|
126
|
+
`compile` is for the collection. That is a deliberate trade: an explanation exists for a person
|
|
127
|
+
asking about one row.
|
|
128
|
+
|
|
129
|
+
## Where to put it
|
|
130
|
+
|
|
131
|
+
- **In a support console**, behind a row: "why is this not in the current view?".
|
|
132
|
+
- **In a test**, when a filter breaks.
|
|
133
|
+
`expect(explain(filter, row).parts.map((p) => [p.at, p.matched]))` fails with the name of the
|
|
134
|
+
clause rather than with `expected [] to have length 1`.
|
|
135
|
+
|
|
136
|
+
## Exercise
|
|
137
|
+
|
|
138
|
+
`o-1007` is not on the "needs chasing" list from lesson 7. Explain it against that request's
|
|
139
|
+
`where`, and say in one sentence what the operator should tell the customer.
|
|
140
|
+
|
|
141
|
+
<details>
|
|
142
|
+
<summary>Answer</summary>
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { explain } from "@osqd/jql";
|
|
146
|
+
import { orders } from "./orders.mjs";
|
|
147
|
+
|
|
148
|
+
const result = explain(
|
|
149
|
+
{ paid: 0, status: { $nin: ["cancelled", "refunded"] } },
|
|
150
|
+
orders.find((o) => o.id === "o-1007"),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
console.log(result.because);
|
|
154
|
+
for (const part of result.parts) console.log(" ", part.matched ? "yes" : "no ", part.at, "—", part.because);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
1 of 2 parts hold
|
|
159
|
+
yes paid — paid is 0
|
|
160
|
+
no status — status is "cancelled"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Nobody owes anything: the order was cancelled, so the unpaid balance is not a debt. The
|
|
164
|
+
explanation names the clause that decided it, and that clause is the sentence the operator
|
|
165
|
+
repeats back.
|
|
166
|
+
</details>
|
|
167
|
+
|
|
168
|
+
## Related
|
|
169
|
+
|
|
170
|
+
- [Library API](../reference/api.md) — `explain` and `Explanation`
|
|
171
|
+
- [The command line](../reference/cli.md) — `jql --explain` prints this tree for the first line of a file
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Lesson 10 — A vocabulary
|
|
2
|
+
|
|
3
|
+
**Goal:** give your data its names, once, so that queries stop knowing where things are
|
|
4
|
+
stored.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Previous: [Why did this not match?](09-explaining.md) · Next: [The search box](11-the-search-box.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Every query so far has had a storage path in it
|
|
11
|
+
|
|
12
|
+
`customer.country` is where the country *lives*. It is not what anybody calls it. Once that
|
|
13
|
+
path is in a saved filter, in a URL and in three dashboards, moving the field is a migration.
|
|
14
|
+
|
|
15
|
+
A **vocabulary** is the one place that says what your data is called:
|
|
16
|
+
|
|
17
|
+
| | |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| **names** | one per field, with the storage path behind it |
|
|
20
|
+
| **aliases** | because people type `at`, and `placed`, and `order`, and `id` |
|
|
21
|
+
| **kinds** | how the search box should read what somebody types after the colon |
|
|
22
|
+
| **computed fields** | names for things the data does not store at all |
|
|
23
|
+
| **`text`** | which fields a bare word searches |
|
|
24
|
+
|
|
25
|
+
One vocabulary serves both the JSON engine and the text front end, deliberately. Two lists of
|
|
26
|
+
field names would be two dialects within a week, and a filter typed into a search box has to
|
|
27
|
+
mean exactly what the JSON it becomes means.
|
|
28
|
+
|
|
29
|
+
## Harbour's vocabulary
|
|
30
|
+
|
|
31
|
+
Save this as `harbour/vocabulary.mjs`. Every example below imports it.
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { defineVocabulary } from "@osqd/jql";
|
|
35
|
+
|
|
36
|
+
/** The names Harbour's data has — for the JSON engine and the search box alike. */
|
|
37
|
+
export const ORDERS = defineVocabulary()({
|
|
38
|
+
fields: {
|
|
39
|
+
id: { kind: "word", aliases: ["order"] },
|
|
40
|
+
status: { kind: "exact", values: ["open", "paid", "refunded", "cancelled"] },
|
|
41
|
+
channel: { kind: "exact", values: ["web", "app", "phone"] },
|
|
42
|
+
who: { path: "customer.name" },
|
|
43
|
+
country: { path: "customer.country", kind: "exact", values: ["GB", "US", "DE", "FR"] },
|
|
44
|
+
email: { path: "customer.email", kind: "word" },
|
|
45
|
+
sku: { path: "lines.sku", kind: "exact" },
|
|
46
|
+
total: { kind: "number" },
|
|
47
|
+
placed: { kind: "date", aliases: ["at"] },
|
|
48
|
+
tag: { path: "tags", kind: "exact" },
|
|
49
|
+
note: {},
|
|
50
|
+
outstanding: { kind: "number", get: (order) => order.total - order.paid },
|
|
51
|
+
},
|
|
52
|
+
text: ["id", "who", "email", "sku", "note"],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Note the empty parentheses in `defineVocabulary()({ … })`. It is curried, so that you can give
|
|
57
|
+
it the document type — `defineVocabulary<Order>()({ … })` — and still have the field names
|
|
58
|
+
inferred from the object rather than having to write them twice.
|
|
59
|
+
|
|
60
|
+
## Querying by name
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { filter } from "@osqd/jql";
|
|
64
|
+
import { orders } from "./orders.mjs";
|
|
65
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
66
|
+
|
|
67
|
+
const ids = (query) => filter(orders, query, { vocabulary: ORDERS }).map((o) => o.id);
|
|
68
|
+
|
|
69
|
+
console.log("a name for a nested path:", ids({ country: "GB" }));
|
|
70
|
+
console.log("a name through a list :", ids({ sku: "desk-lamp" }));
|
|
71
|
+
console.log("an alias :", ids({ at: { $gte: { $date: "2026-09-18" } } }));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
a name for a nested path: [ 'o-1001', 'o-1003', 'o-1005' ]
|
|
76
|
+
a name through a list : [ 'o-1005', 'o-1007' ]
|
|
77
|
+
an alias : [ 'o-1006', 'o-1007', 'o-1008' ]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Not one of those queries mentions `customer`, `lines` or `placed`. The vocabulary is the only
|
|
81
|
+
thing that knows where those values are kept, so moving one is an edit to a single file.
|
|
82
|
+
|
|
83
|
+
## Computed fields
|
|
84
|
+
|
|
85
|
+
`outstanding` is not in the order book at all. It is `total - paid`, worked out when the query
|
|
86
|
+
runs:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { filter } from "@osqd/jql";
|
|
90
|
+
import { orders } from "./orders.mjs";
|
|
91
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
92
|
+
|
|
93
|
+
const ids = (query) => filter(orders, query, { vocabulary: ORDERS }).map((o) => o.id);
|
|
94
|
+
|
|
95
|
+
console.log("still owing anything:", ids({ outstanding: { $gt: 0 } }));
|
|
96
|
+
console.log("owing £100 or more :", ids({ outstanding: { $gte: 100 } }));
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
still owing anything: [ 'o-1002', 'o-1004', 'o-1005', 'o-1007' ]
|
|
101
|
+
owing £100 or more : [ 'o-1005', 'o-1007' ]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
From the outside it is an ordinary field, and that is the point — it works everywhere a name
|
|
105
|
+
works:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { group, search } from "@osqd/jql";
|
|
109
|
+
import { orders } from "./orders.mjs";
|
|
110
|
+
import { ORDERS } from "./vocabulary.mjs";
|
|
111
|
+
|
|
112
|
+
const worst = search(orders, {
|
|
113
|
+
where: { outstanding: { $gt: 0 } },
|
|
114
|
+
sort: { outstanding: -1 },
|
|
115
|
+
fields: ["id", "outstanding"],
|
|
116
|
+
}, { vocabulary: ORDERS });
|
|
117
|
+
|
|
118
|
+
console.log("sorted by it:");
|
|
119
|
+
for (const row of worst) console.log(" ", row.id, row.outstanding);
|
|
120
|
+
|
|
121
|
+
console.log("grouped by country, where it is owed:");
|
|
122
|
+
for (const held of group(orders, "country", { vocabulary: ORDERS, where: { outstanding: { $gt: 0 } } })) {
|
|
123
|
+
console.log(" ", held.key, held.count);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
sorted by it:
|
|
129
|
+
o-1007 280
|
|
130
|
+
o-1005 170
|
|
131
|
+
o-1004 36
|
|
132
|
+
o-1002 30
|
|
133
|
+
grouped by country, where it is owed:
|
|
134
|
+
US 2
|
|
135
|
+
DE 1
|
|
136
|
+
GB 1
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Lesson 7's exercise handed back `total` and `paid` and let the screen do the subtraction. Now
|
|
140
|
+
the screen asks for what it actually means.
|
|
141
|
+
|
|
142
|
+
Two things to know about `get`:
|
|
143
|
+
|
|
144
|
+
- It is called once per document per field it is tested against, and **it must not throw**. It
|
|
145
|
+
is code running inside a filter; treat it as you would a comparison function.
|
|
146
|
+
- A computed field **cannot be pushed to a database** (lesson 15), because a store cannot run
|
|
147
|
+
a function that lives in this process. `plan` knows that, and keeps those clauses here.
|
|
148
|
+
|
|
149
|
+
## Kinds
|
|
150
|
+
|
|
151
|
+
A `kind` is for the **text** front end only. A JSON query already says what it means with its
|
|
152
|
+
operator, so the engine ignores the kind entirely; it is how `total:>70` in a search box knows
|
|
153
|
+
to compare rather than to look for the characters `>70`.
|
|
154
|
+
|
|
155
|
+
| | |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| `text` | contains it, ignoring case. The default, and right for prose |
|
|
158
|
+
| `word` | contains it as a whole component — for identifiers and addresses |
|
|
159
|
+
| `exact` | equals it, ignoring case — for closed sets like a status |
|
|
160
|
+
| `number` | compares: `>70`, `10..20`, `42` |
|
|
161
|
+
| `date` | compares instants: `>2026-09-01`, `>-7d` |
|
|
162
|
+
| `boolean` | `true`/`false`, `yes`/`no`, `1`/`0` |
|
|
163
|
+
|
|
164
|
+
`values` lists what a closed set contains, which lesson 11 turns into completions.
|
|
165
|
+
|
|
166
|
+
## `strict`, for a public contract
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
import { defineVocabulary, validate } from "@osqd/jql";
|
|
170
|
+
|
|
171
|
+
const PUBLIC = defineVocabulary()({
|
|
172
|
+
fields: {
|
|
173
|
+
status: { kind: "exact" },
|
|
174
|
+
country: { path: "customer.country", kind: "exact" },
|
|
175
|
+
placed: { kind: "date" },
|
|
176
|
+
},
|
|
177
|
+
strict: true,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const byName = validate({ country: "GB" }, { vocabulary: PUBLIC });
|
|
181
|
+
const byPath = validate({ "customer.email": "ada@example.com" }, { vocabulary: PUBLIC });
|
|
182
|
+
|
|
183
|
+
console.log("a name it knows :", byName.valid ? "accepted" : byName.error.message);
|
|
184
|
+
console.log("a path it does not:", byPath.valid ? "accepted" : byPath.error.message);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
a name it knows : accepted
|
|
189
|
+
a path it does not: at customer.email: "customer.email" is not a field here
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**With `strict`, the vocabulary is the whole contract.** A query may name those fields and
|
|
193
|
+
nothing else. That is what belongs in front of a public endpoint: documents grow fields nobody
|
|
194
|
+
promised to keep, and a query language that can reach every one of them turns every internal
|
|
195
|
+
field into part of your API.
|
|
196
|
+
|
|
197
|
+
Without `strict`, storage paths keep working alongside the names, which is what you want for
|
|
198
|
+
your own console.
|
|
199
|
+
|
|
200
|
+
## A vocabulary that would mean two things is refused
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
import { defineVocabulary, JqlError } from "@osqd/jql";
|
|
204
|
+
|
|
205
|
+
const attempts = [
|
|
206
|
+
["a name that collides", () => defineVocabulary()({ fields: { a: {}, b: { aliases: ["A"] } } })],
|
|
207
|
+
["a path and a getter", () => defineVocabulary()({ fields: { a: { path: "x", get: () => 1 } } })],
|
|
208
|
+
["a text field that is not a field", () => defineVocabulary()({ fields: { a: {} }, text: ["b"] })],
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
for (const [why, build] of attempts) {
|
|
212
|
+
try {
|
|
213
|
+
build();
|
|
214
|
+
console.log(`${why}: accepted`);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.log(`${why}:`);
|
|
217
|
+
console.log(` ${error instanceof JqlError ? error.message : error}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
a name that collides:
|
|
224
|
+
at vocabulary: "A" names both "a" and "b", so a query using it could mean either
|
|
225
|
+
a path and a getter:
|
|
226
|
+
at vocabulary.a: "a" has both a path and a getter; one of them would be ignored, so say which
|
|
227
|
+
a text field that is not a field:
|
|
228
|
+
at vocabulary.text: the text field "b" is not a field of this vocabulary, so a bare word would never search it
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
All three are refused **when the vocabulary is defined**, not when a query eventually uses it.
|
|
232
|
+
A vocabulary that could mean two things is a bug you want at startup, not in a support ticket.
|
|
233
|
+
|
|
234
|
+
## Exercise
|
|
235
|
+
|
|
236
|
+
Add a `days` field — how long an order has been waiting, in whole days, as of a given clock —
|
|
237
|
+
and find everything open for more than a week as of 25 September 2026.
|
|
238
|
+
|
|
239
|
+
<details>
|
|
240
|
+
<summary>Answer</summary>
|
|
241
|
+
|
|
242
|
+
A `get` receives only the document, so the clock has to come from the closure:
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
import { defineVocabulary, filter } from "@osqd/jql";
|
|
246
|
+
import { orders } from "./orders.mjs";
|
|
247
|
+
|
|
248
|
+
const asOf = Date.parse("2026-09-25T12:00:00Z");
|
|
249
|
+
|
|
250
|
+
const AGED = defineVocabulary()({
|
|
251
|
+
fields: {
|
|
252
|
+
status: { kind: "exact" },
|
|
253
|
+
days: { kind: "number", get: (order) => Math.floor((asOf - Date.parse(order.placed)) / 86_400_000) },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
console.log(filter(orders, { status: "open", days: { $gt: 7 } }, { vocabulary: AGED }).map((o) => o.id));
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
[ 'o-1002', 'o-1005' ]
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Now notice what you have just built: a vocabulary whose meaning depends on **when it was
|
|
265
|
+
made**. That is fine for one created per request, and a trap at module level — a filter that
|
|
266
|
+
says "over a week old" and quietly means "over a week old as of process start" is exactly the
|
|
267
|
+
kind of wrongness the rest of this library works to prevent.
|
|
268
|
+
|
|
269
|
+
The alternative is a relative date from lesson 5, which resolves per compile and says what it
|
|
270
|
+
means: `{ placed: { $lt: { $date: { $ago: "7d" } } } }`.
|
|
271
|
+
</details>
|
|
272
|
+
|
|
273
|
+
## Related
|
|
274
|
+
|
|
275
|
+
- [TypeScript guide](../guides/typescript.md#vocabularies) — vocabularies with the types
|
|
276
|
+
- [Text syntax](../reference/text-syntax.md) — what each `kind` does to a typed value
|