@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,199 @@
|
|
|
1
|
+
# Lesson 13 — Saved filters
|
|
2
|
+
|
|
3
|
+
**Goal:** give every question one shape and one short name, so a list of saved filters does
|
|
4
|
+
not quietly hold the same filter three times.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Previous: [Queries from outside](12-untrusted.md) · Next: [Logs and streams](14-streams-and-cli.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The same question, four spellings
|
|
11
|
+
|
|
12
|
+
Once people can save a filter, they save the same one repeatedly. One was typed into the
|
|
13
|
+
search box, one was written by hand, one was built by a form, one came back from an API with
|
|
14
|
+
its keys in a different order.
|
|
15
|
+
|
|
16
|
+
They are one question and four rows in a table — and a cache keyed on the query text misses on
|
|
17
|
+
every one of them.
|
|
18
|
+
|
|
19
|
+
Two functions fix that:
|
|
20
|
+
|
|
21
|
+
- **`canonical(query)`** rewrites a query into one fixed shape;
|
|
22
|
+
- **`fingerprint(query)`** names that shape in sixteen characters.
|
|
23
|
+
|
|
24
|
+
## One filter, one name
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { fingerprint } from "@osqd/jql";
|
|
28
|
+
|
|
29
|
+
const written = [
|
|
30
|
+
{ status: "open", total: { $gte: 100 } },
|
|
31
|
+
{ total: { $gte: 100 }, status: "open" },
|
|
32
|
+
{ status: { $eq: "open" }, total: { $gte: 100 } },
|
|
33
|
+
{ $and: [{ status: "open" }, { total: { $gte: 100 } }], $comment: "chasing" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
for (const query of written) console.log(fingerprint(query), JSON.stringify(query));
|
|
37
|
+
|
|
38
|
+
console.log("distinct filters here:", new Set(written.map(fingerprint)).size);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
03646d4ac8b6def3 {"status":"open","total":{"$gte":100}}
|
|
43
|
+
03646d4ac8b6def3 {"total":{"$gte":100},"status":"open"}
|
|
44
|
+
03646d4ac8b6def3 {"status":{"$eq":"open"},"total":{"$gte":100}}
|
|
45
|
+
03646d4ac8b6def3 {"$and":[{"status":"open"},{"total":{"$gte":100}}],"$comment":"chasing"}
|
|
46
|
+
distinct filters here: 1
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Four spellings, one name. Note the fourth in particular: a `$and` of two clauses, and a
|
|
50
|
+
comment, and it still lands on the same fingerprint as the other three.
|
|
51
|
+
|
|
52
|
+
## The shape behind the name
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { canonical } from "@osqd/jql";
|
|
56
|
+
|
|
57
|
+
const typed = { $and: [{ status: "open" }, { total: { $gte: 100 } }], $comment: "chasing" };
|
|
58
|
+
|
|
59
|
+
console.log(JSON.stringify(canonical(typed), null, 2));
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
{
|
|
64
|
+
"status": {
|
|
65
|
+
"$eq": "open"
|
|
66
|
+
},
|
|
67
|
+
"total": {
|
|
68
|
+
"$gte": 100
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
What comes back **is still a query** — you can run it, store it, or put it in a URL — and it
|
|
74
|
+
matches exactly what the original matched. The test suite holds it to that on thousands of
|
|
75
|
+
generated queries by running both and comparing answers, rather than by comparing shapes.
|
|
76
|
+
|
|
77
|
+
| Written | Becomes |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `{ a: 1 }` | `{ a: { $eq: 1 } }` — a bare value is a condition |
|
|
80
|
+
| keys in any order | keys sorted |
|
|
81
|
+
| `$comment: "…"` | dropped: a note decides nothing |
|
|
82
|
+
| `{ $and: [x, y] }` | `{ …x, …y }` — the box writes the first, a person writes the second |
|
|
83
|
+
| `$and` inside `$and` | one `$and` |
|
|
84
|
+
| `{ $in: [3, 1, 3] }` | `{ $in: [1, 3] }` — a list that is a set is sorted and deduplicated |
|
|
85
|
+
| `$options: "ui"` | `$options: "iu"` |
|
|
86
|
+
| the branches of an `$or` | sorted, so branch order stops mattering |
|
|
87
|
+
|
|
88
|
+
## Different questions, different names
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { fingerprint } from "@osqd/jql";
|
|
92
|
+
|
|
93
|
+
const questions = [
|
|
94
|
+
{ status: "open" },
|
|
95
|
+
{ status: "paid" },
|
|
96
|
+
{ status: { $ne: "open" } },
|
|
97
|
+
{ $or: [{ status: "open" }, { status: "paid" }] },
|
|
98
|
+
{ $or: [{ status: "paid" }, { status: "open" }] },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
for (const query of questions) console.log(fingerprint(query), JSON.stringify(query));
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
d4d13aede48bfdad {"status":"open"}
|
|
106
|
+
0c24bc335f3c3269 {"status":"paid"}
|
|
107
|
+
a5ba405e8cd835c8 {"status":{"$ne":"open"}}
|
|
108
|
+
bc66ac7eefa26bc0 {"$or":[{"status":"open"},{"status":"paid"}]}
|
|
109
|
+
bc66ac7eefa26bc0 {"$or":[{"status":"paid"},{"status":"open"}]}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The last two are the same question with its branches the other way round, so they share a
|
|
113
|
+
name.
|
|
114
|
+
|
|
115
|
+
## What it deliberately will not do
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
import { fingerprint } from "@osqd/jql";
|
|
119
|
+
|
|
120
|
+
console.log(fingerprint({ total: { $gt: 3 } }), "{ total: { $gt: 3 } }");
|
|
121
|
+
console.log(fingerprint({ total: { $gte: 4 } }), "{ total: { $gte: 4 } }");
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
e7a73371277f4960 { total: { $gt: 3 } }
|
|
126
|
+
a27bb5f54416fc83 { total: { $gte: 4 } }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Those two agree on every integer, and disagree on 3.5. They get different names, and that is
|
|
130
|
+
the honest limit of the whole idea:
|
|
131
|
+
|
|
132
|
+
> **Equal fingerprints mean the same query. Different fingerprints mean only that the queries
|
|
133
|
+
> are written differently.**
|
|
134
|
+
|
|
135
|
+
One direction is a cheap, decidable property. The other — deciding whether two arbitrary
|
|
136
|
+
queries select the same rows — is not, and a tool that claimed to do it would be wrong in ways
|
|
137
|
+
you would find out about much later.
|
|
138
|
+
|
|
139
|
+
## About the name itself
|
|
140
|
+
|
|
141
|
+
A fingerprint is a 64-bit checksum in hexadecimal. It is **not** a cryptographic hash: two
|
|
142
|
+
different queries can collide, rarely, and never in a way that matters for a cache you are
|
|
143
|
+
allowed to miss.
|
|
144
|
+
|
|
145
|
+
It **is** stable across processes and across versions of this library, so it is safe as a
|
|
146
|
+
stored key. A set of fingerprints is written into the test suite precisely so that changing
|
|
147
|
+
what a query is called takes a deliberate act rather than happening as a side effect.
|
|
148
|
+
|
|
149
|
+
## Where this earns its place
|
|
150
|
+
|
|
151
|
+
- **A saved-filter list.** Fingerprint before inserting, and say "you already have this one,
|
|
152
|
+
saved as *Chasing large orders*" instead of adding a fifth copy.
|
|
153
|
+
- **A cache key.** `` `orders:${fingerprint(query)}` ``.
|
|
154
|
+
- **A log line.** Sixteen characters that say which filter ran, without printing a query that
|
|
155
|
+
may be long and may contain a customer's email address.
|
|
156
|
+
- **Telling two dashboards apart** when both claim to show "open orders" and one of them is
|
|
157
|
+
wrong.
|
|
158
|
+
|
|
159
|
+
## Exercise
|
|
160
|
+
|
|
161
|
+
Harbour's filter list stores `{ name, query }`. Write `save(list, name, query)` so that saving
|
|
162
|
+
a duplicate gives back the existing entry instead of adding a row.
|
|
163
|
+
|
|
164
|
+
<details>
|
|
165
|
+
<summary>Answer</summary>
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
import { canonical, fingerprint } from "@osqd/jql";
|
|
169
|
+
|
|
170
|
+
const save = (list, name, query) => {
|
|
171
|
+
const key = fingerprint(query);
|
|
172
|
+
const already = list.find((entry) => entry.key === key);
|
|
173
|
+
if (already !== undefined) return { saved: false, as: already.name };
|
|
174
|
+
list.push({ key, name, query: canonical(query) });
|
|
175
|
+
return { saved: true, as: name };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const list = [];
|
|
179
|
+
|
|
180
|
+
console.log(save(list, "Chasing large orders", { status: "open", total: { $gte: 100 } }));
|
|
181
|
+
console.log(save(list, "Big open ones", { total: { $gte: 100 }, status: { $eq: "open" } }));
|
|
182
|
+
console.log("rows in the list:", list.length);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
{ saved: true, as: 'Chasing large orders' }
|
|
187
|
+
{ saved: false, as: 'Chasing large orders' }
|
|
188
|
+
rows in the list: 1
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Store `canonical(query)` rather than whatever was typed, so the row you read back tomorrow has
|
|
192
|
+
one shape instead of whichever spelling happened to arrive first. Keep the fingerprint beside
|
|
193
|
+
it, so the lookup is an index rather than a scan.
|
|
194
|
+
</details>
|
|
195
|
+
|
|
196
|
+
## Related
|
|
197
|
+
|
|
198
|
+
- [Library API](../reference/api.md) — `canonical` and `fingerprint`
|
|
199
|
+
- [Specification §13](../reference/specification.md#13-canonical-form-optional) — the rules, so another implementation agrees
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Lesson 14 — Logs and streams
|
|
2
|
+
|
|
3
|
+
**Goal:** run a query over something that arrives over time, and over a file, from a shell.
|
|
4
|
+
|
|
5
|
+
← [Course](index.md) · Previous: [Saved filters](13-saved-filters.md) · Next: [Pushing into a store](15-pushdown.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Data that arrives over time
|
|
10
|
+
|
|
11
|
+
Harbour exports its order book nightly as JSON lines. That is the shape most real data arrives
|
|
12
|
+
in — a log, a `kubectl` dump, a store's export — and by the time it matters it is too big to
|
|
13
|
+
hold in memory.
|
|
14
|
+
|
|
15
|
+
The asynchronous helpers are the same engine with an `await` in the loop. The query is
|
|
16
|
+
compiled once, before the first item, and **they stop reading as soon as the answer cannot
|
|
17
|
+
change** — which is what makes a source that never ends answerable at all.
|
|
18
|
+
|
|
19
|
+
First, write the export:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node -e "import('./orders.mjs').then(({orders}) => require('node:fs').writeFileSync('orders.jsonl', orders.map(o => JSON.stringify(o)).join('\n') + '\n'))"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then save this as `harbour/exported.mjs`, so the examples can read it back a line at a time:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { createReadStream } from "node:fs";
|
|
29
|
+
import { createInterface } from "node:readline";
|
|
30
|
+
|
|
31
|
+
/** Every line of the nightly export, parsed, as it arrives. */
|
|
32
|
+
export async function* exported() {
|
|
33
|
+
const reader = createInterface({
|
|
34
|
+
input: createReadStream("orders.jsonl", "utf8"),
|
|
35
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
36
|
+
});
|
|
37
|
+
for await (const line of reader) if (line.trim() !== "") yield JSON.parse(line);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Asking a source the usual questions
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { countAsync, findAsync, someAsync } from "@osqd/jql";
|
|
45
|
+
import { exported } from "./exported.mjs";
|
|
46
|
+
|
|
47
|
+
console.log("the first open one:", (await findAsync(exported(), { status: "open" })).id);
|
|
48
|
+
console.log("how many paid :", await countAsync(exported(), { status: "paid" }));
|
|
49
|
+
console.log("anything over £200:", await someAsync(exported(), { total: { $gt: 200 } }));
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
the first open one: o-1002
|
|
54
|
+
how many paid : 4
|
|
55
|
+
anything over £200: true
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Same names as lesson 1, same queries, `await` in front. `findAsync` and `someAsync` stop at
|
|
59
|
+
the first match — the rest of the file is never read.
|
|
60
|
+
|
|
61
|
+
## A page from a stream
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { searchAsync } from "@osqd/jql";
|
|
65
|
+
import { exported } from "./exported.mjs";
|
|
66
|
+
|
|
67
|
+
const page = await searchAsync(exported(), {
|
|
68
|
+
where: { status: { $ne: "cancelled" } },
|
|
69
|
+
sort: { total: -1 },
|
|
70
|
+
limit: 2,
|
|
71
|
+
fields: ["id", "total"],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
console.log(page);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
[ { id: 'o-1005', total: 170 }, { id: 'o-1004', total: 36 } ]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## A stream you can walk away from
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { filterStream } from "@osqd/jql";
|
|
85
|
+
import { exported } from "./exported.mjs";
|
|
86
|
+
|
|
87
|
+
for await (const order of filterStream(exported(), { "customer.country": "GB" })) {
|
|
88
|
+
console.log(order.id, order.customer.name);
|
|
89
|
+
if (order.id === "o-1003") break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log("stopped early, and the file stopped being read");
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
o-1001 Ada Lovelace
|
|
97
|
+
o-1003 Alan Turing
|
|
98
|
+
stopped early, and the file stopped being read
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`filterStream` yields matches as it finds them. Stop consuming and the source stops being
|
|
102
|
+
pulled — which is the whole point of the next example.
|
|
103
|
+
|
|
104
|
+
## A source that never ends
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import { filterStream } from "@osqd/jql";
|
|
108
|
+
|
|
109
|
+
/** A feed with no end, like a socket or a tail. */
|
|
110
|
+
async function* forever() {
|
|
111
|
+
for (let i = 0; ; i++) yield { id: i, status: i % 3 === 0 ? "open" : "paid" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const pulled = [];
|
|
115
|
+
for await (const item of filterStream(forever(), { status: "open" })) {
|
|
116
|
+
pulled.push(item.id);
|
|
117
|
+
if (pulled.length === 3) break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(pulled);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
[ 0, 3, 6 ]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
That program terminates. Nothing pulls from `forever()` once the loop breaks, so an endless
|
|
128
|
+
feed is an ordinary source as long as the question has a stopping point.
|
|
129
|
+
|
|
130
|
+
## What stops early, and what cannot
|
|
131
|
+
|
|
132
|
+
| | |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `findAsync`, `someAsync` | stop at the first match |
|
|
135
|
+
| `filterStream` | yields as it goes; stop consuming and the source stops |
|
|
136
|
+
| `searchAsync` **without** a sort | stops once the page is full |
|
|
137
|
+
| `searchAsync` **with** a sort | reads everything — it has to — but holds only `skip + limit` items while it does |
|
|
138
|
+
|
|
139
|
+
That last row is the one that surprises people, and it is worth being explicit: an order over
|
|
140
|
+
items you have not seen yet is not an order. A sorted page needs the whole source. What it
|
|
141
|
+
does *not* need is the whole source in memory, and the bounded heap from lesson 7 keeps only
|
|
142
|
+
the page you asked for.
|
|
143
|
+
|
|
144
|
+
## The same thing from a shell
|
|
145
|
+
|
|
146
|
+
The library ships the command that does all this over a file, which is often quicker than
|
|
147
|
+
writing the script:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
jql '{"status":"open"}' orders.jsonl | head -1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
{"id":"o-1002","placed":"2026-09-03T14:02:00Z","status":"open","channel":"web","customer":{"name":"Grace Hopper","country":"US","email":"grace@example.com"},"lines":[{"sku":"pad-a5","title":"A5 pad","quantity":10,"price":3}],"total":30,"paid":0,"tags":[]}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
jql --count '{"customer.country":"GB"}' orders.jsonl
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
3
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
jql --text 'status:paid total:>20' --sort total:desc --fields id,total orders.jsonl
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
{"id":"o-1006","total":33}
|
|
171
|
+
{"id":"o-1001","total":22.5}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
jql --group status '{}' orders.jsonl
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
{"key":"paid","count":4}
|
|
180
|
+
{"key":"open","count":2}
|
|
181
|
+
{"key":"cancelled","count":1}
|
|
182
|
+
{"key":"refunded","count":1}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
cat orders.jsonl | jql --text 'customer.country:gb' --fields id,customer.name -
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
{"id":"o-1001","customer":{"name":"Ada Lovelace"}}
|
|
191
|
+
{"id":"o-1003","customer":{"name":"Alan Turing"}}
|
|
192
|
+
{"id":"o-1005","customer":{"name":"Tim Berners-Lee"}}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Two rules shape the command, and both are worth copying into tools of your own.
|
|
196
|
+
|
|
197
|
+
### stdout is the artifact
|
|
198
|
+
|
|
199
|
+
Matching lines and nothing else, so `jql … > kept.jsonl` produces a file worth having. Counts,
|
|
200
|
+
warnings and explanations go to stderr, which is why `--explain` combines with anything:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
jql --explain --count '{"status":"paid"}' orders.jsonl
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
4
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
— on stdout, with the explanation beside it on stderr.
|
|
211
|
+
|
|
212
|
+
A matching line is written back **exactly as it arrived**, byte for byte, so filtering a file
|
|
213
|
+
cannot change the data in it. That is not fussiness: re-serialising turns a thirty-digit
|
|
214
|
+
identifier into `1.2345678901234568e+29`, and `1e400` into `null`, silently. Ask for the
|
|
215
|
+
result to be reshaped — `--fields`, `--omit`, `--group`, `--pretty` — and you get JSON,
|
|
216
|
+
because then you have asked for something other than the line.
|
|
217
|
+
|
|
218
|
+
### A value is validated, never coerced
|
|
219
|
+
|
|
220
|
+
`--limit all` is an error rather than a limit that quietly stops limiting, and a query that is
|
|
221
|
+
not JQL is refused with the reason and exit code 2:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
jql '{"total":{"$gtt":1}}' orders.jsonl; echo "exit $?"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
jql: at total.$gtt: "$gtt" is not an operator; did you mean "$gt"?
|
|
229
|
+
exit 2
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Note what `--text` did in the examples above: `customer.country:gb` uses the **storage path**,
|
|
233
|
+
because there is no vocabulary on the command line to translate a short name. Everything in
|
|
234
|
+
lesson 10 still applies in your own code; the command is the generic tool.
|
|
235
|
+
|
|
236
|
+
## Exercise
|
|
237
|
+
|
|
238
|
+
Find the three largest unpaid orders in the export, printing only the id and the total, from
|
|
239
|
+
the shell alone.
|
|
240
|
+
|
|
241
|
+
<details>
|
|
242
|
+
<summary>Answer</summary>
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
jql '{"paid":0}' --sort total:desc --limit 3 --fields id,total orders.jsonl
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
{"id":"o-1007","total":280}
|
|
250
|
+
{"id":"o-1005","total":170}
|
|
251
|
+
{"id":"o-1004","total":36}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Now try the spelling you probably reached for first:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
jql --text 'paid:0' --sort total:desc --limit 3 --fields id,total orders.jsonl
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Nothing at all. **There is no vocabulary on the command line**, so every field is `text` — and
|
|
264
|
+
`paid:0` becomes `{ paid: { $contains: "0", $options: "i" } }`, which asks whether a *string*
|
|
265
|
+
contains a zero. `paid` holds a number, and `$contains` does not match numbers.
|
|
266
|
+
|
|
267
|
+
That is the argument for lesson 10 in a single line. A `kind` is what turns `paid:0` into a
|
|
268
|
+
comparison; without one the text syntax can only guess from the shape of what was typed, and
|
|
269
|
+
it guesses conservatively rather than coercing. On the command line, prefer JSON for anything
|
|
270
|
+
that is not prose.
|
|
271
|
+
</details>
|
|
272
|
+
|
|
273
|
+
## Related
|
|
274
|
+
|
|
275
|
+
- [The command](../reference/cli.md) — every option, the exit codes, the two rules
|
|
276
|
+
- [Library API](../reference/api.md) — the `*Async` helpers
|