@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,240 @@
|
|
|
1
|
+
# Lesson 6 — Typed queries
|
|
2
|
+
|
|
3
|
+
**Goal:** make a typo in a field name a compile error instead of an empty result.
|
|
4
|
+
|
|
5
|
+
← [Course](index.md) · Previous: [Dates and windows](05-dates.md) · Next: [Requests](07-requests.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The most expensive mistake
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{ "statsu": "paid" }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That is a perfectly valid query. It asks about a field nothing has, so it matches nothing, so
|
|
16
|
+
the page comes back empty, so somebody reports "there were no paid orders this week" — and
|
|
17
|
+
they are wrong.
|
|
18
|
+
|
|
19
|
+
Nothing at run time can catch this. `statsu` might genuinely be a field on some other
|
|
20
|
+
collection; the engine has no way to know it is not one of yours. The compiler does.
|
|
21
|
+
|
|
22
|
+
`Query<T>` knows the shape of `T` and checks three things: the **paths**, the **values**, and
|
|
23
|
+
which **operators** make sense for each field.
|
|
24
|
+
|
|
25
|
+
This is the one lesson that needs TypeScript.
|
|
26
|
+
|
|
27
|
+
## Setting up
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install --save-dev typescript
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`harbour/tsconfig.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"compilerOptions": {
|
|
38
|
+
"target": "es2022",
|
|
39
|
+
"module": "node16",
|
|
40
|
+
"moduleResolution": "node16",
|
|
41
|
+
"strict": true,
|
|
42
|
+
"noEmit": true
|
|
43
|
+
},
|
|
44
|
+
"files": ["lesson-06.ts"]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
And the shape of an order, which the rest of the lesson refers to. Put this at the top of
|
|
49
|
+
`harbour/lesson-06.ts`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { filter, type Query } from "@osqd/jql";
|
|
53
|
+
|
|
54
|
+
interface Order {
|
|
55
|
+
id: string;
|
|
56
|
+
placed: string;
|
|
57
|
+
status: "open" | "paid" | "refunded" | "cancelled";
|
|
58
|
+
channel: "web" | "app" | "phone";
|
|
59
|
+
customer: { name: string; country: string; email: string };
|
|
60
|
+
lines: { sku: string; title: string; quantity: number; price: number }[];
|
|
61
|
+
total: number;
|
|
62
|
+
paid: number;
|
|
63
|
+
tags: string[];
|
|
64
|
+
note?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
declare const orders: Order[];
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What a typed query looks like
|
|
71
|
+
|
|
72
|
+
Everything you learned in lessons 1 to 5 still applies — paths, operators, `$elemMatch`,
|
|
73
|
+
`$date`. The type adds nothing to write:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const good: Query<Order> = {
|
|
77
|
+
status: "paid",
|
|
78
|
+
"customer.country": "GB",
|
|
79
|
+
"lines.sku": "pen-fine",
|
|
80
|
+
total: { $gte: 30 },
|
|
81
|
+
tags: "repeat",
|
|
82
|
+
lines: { $elemMatch: { quantity: { $gt: 5 } } },
|
|
83
|
+
placed: { $gte: { $date: "2026-09-01" } },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
filter(orders, good);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
That compiles cleanly. Run `npx tsc -p tsconfig.json` and you will get nothing back, which is
|
|
90
|
+
what "nothing wrong" looks like.
|
|
91
|
+
|
|
92
|
+
## Six mistakes, six errors
|
|
93
|
+
|
|
94
|
+
Now add these one at a time and compile after each.
|
|
95
|
+
|
|
96
|
+
### A misspelt field
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const q: Query<Order> = { statsu: "paid" };
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
error TS2561: Object literal may only specify known properties, but 'statsu' does not exist in type 'TypedQuery<Order, {}>'. Did you mean to write 'status'?
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The suggestion is TypeScript's own spell-checker, working from the key list JQL built out of
|
|
107
|
+
your interface.
|
|
108
|
+
|
|
109
|
+
### The wrong kind of value
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const q: Query<Order> = { total: "30" };
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
error TS2322: Type 'string' is not assignable to type 'FieldQuery<number> | undefined'.
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`total` is a number, so a string is refused — which matters because lesson 2's comparisons
|
|
120
|
+
never coerce, so `"30"` really would have matched nothing.
|
|
121
|
+
|
|
122
|
+
### A value outside a union
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const q: Query<Order> = { status: "posted" };
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
error TS2322: Type '"posted"' is not assignable to type 'FieldQuery<"open" | "paid" | "refunded" | "cancelled"> | undefined'.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
A union type is checked member by member, so a status that does not exist is caught by name.
|
|
133
|
+
|
|
134
|
+
### A misspelt path
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const q: Query<Order> = { "customer.contry": "GB" };
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
error TS2353: Object literal may only specify known properties, and '"customer.contry"' does not exist in type 'TypedQuery<Order, {}>'.
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Paths are enumerated five levels deep, through arrays as well as objects. Deeper ones still
|
|
145
|
+
work at run time; they are simply not offered as suggestions.
|
|
146
|
+
|
|
147
|
+
### An operator the field cannot serve
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
const q: Query<Order> = { status: { $size: 3 } };
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
error TS2322: Type 'number' is not assignable to type 'undefined'.
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
This is the one message worth memorising, because it reads so oddly. `$size` counts array
|
|
158
|
+
elements and `status` is a string, so `$size`'s type on this field is `undefined` — and
|
|
159
|
+
anything at all you pass to it is an error. **`… is not assignable to type 'undefined'` always
|
|
160
|
+
means "this operator does not apply to this field".**
|
|
161
|
+
|
|
162
|
+
### A typo inside `$elemMatch`
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const q: Query<Order> = { lines: { $elemMatch: { quantiy: 5 } } };
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
error TS2322: Type '{ $elemMatch: { quantiy: number; }; }' is not assignable to type 'FieldQuery<{ sku: string; title: string; quantity: number; price: number; }[]> | undefined'.
|
|
170
|
+
Types of property '$elemMatch' are incompatible.
|
|
171
|
+
Object literal may only specify known properties, but 'quantiy' does not exist in type 'TypedQuery<{ sku: string; title: string; quantity: number; price: number; }, {}>'. Did you mean to write 'quantity'?
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Inside `$elemMatch` is a full query over the **element** type, checked just as thoroughly as
|
|
175
|
+
the outer one. Read that message from the bottom up; the last line is the actual problem.
|
|
176
|
+
|
|
177
|
+
Every one of those six would otherwise have compiled, run, and matched nothing.
|
|
178
|
+
|
|
179
|
+
## When the shape is not known
|
|
180
|
+
|
|
181
|
+
A query parsed out of a request body, read from a database or typed into a search box has no
|
|
182
|
+
`T` to check against — its shape only exists at run time. Say so rather than fighting the
|
|
183
|
+
compiler:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { untyped, validate } from "@osqd/jql";
|
|
187
|
+
|
|
188
|
+
// A query that arrived as text: check it, then run the thing you checked.
|
|
189
|
+
const checked = validate<Order>(JSON.parse(body));
|
|
190
|
+
if (!checked.valid) return respond(400, { error: checked.error.message });
|
|
191
|
+
const matching = orders.filter(checked.test);
|
|
192
|
+
|
|
193
|
+
// A query built at run time, used where a typed one is expected.
|
|
194
|
+
filter(orders, untyped(somethingBuiltAtRunTime));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- **`untyped(value)`** marks a query as one to check at run time instead of at compile time,
|
|
198
|
+
and is accepted anywhere a typed query is. It is what makes `orders.jqlFilter(parseText(input))`
|
|
199
|
+
compile in lesson 11.
|
|
200
|
+
- **`validate`** is lesson 12's subject. Note that it hands back the predicate it compiled, so
|
|
201
|
+
the query you checked is guaranteed to be the query you run.
|
|
202
|
+
- **`Query<unknown>`** accepts any field, for the same reason.
|
|
203
|
+
|
|
204
|
+
In all three cases the engine still refuses anything that is not valid JQL. What you give up
|
|
205
|
+
is only the compiler's help with names.
|
|
206
|
+
|
|
207
|
+
## Exercise
|
|
208
|
+
|
|
209
|
+
Add `outstanding: number` to `Order` and write a typed query for orders on the app with more
|
|
210
|
+
than £50 outstanding. Then misspell `outstanding` and see which of the six errors you get.
|
|
211
|
+
|
|
212
|
+
<details>
|
|
213
|
+
<summary>Answer</summary>
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
interface Order { /* … as before … */ outstanding: number }
|
|
217
|
+
|
|
218
|
+
const chasing: Query<Order> = { channel: "app", outstanding: { $gt: 50 } };
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Misspell it —
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const chasing: Query<Order> = { channel: "app", outstandng: { $gt: 50 } };
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
— and it is the first of the six errors again:
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
error TS2561: Object literal may only specify known properties, but 'outstandng' does not exist in type 'TypedQuery<Order, {}>'. Did you mean to write 'outstanding'?
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
In lesson 10 this same field becomes a **computed** one that is not on the interface at all —
|
|
234
|
+
and the types still know about it.
|
|
235
|
+
</details>
|
|
236
|
+
|
|
237
|
+
## Related
|
|
238
|
+
|
|
239
|
+
- [TypeScript guide](../guides/typescript.md) — typed queries, the array methods, vocabularies
|
|
240
|
+
- [Library API](../reference/api.md) — `Query`, `UntypedQuery`, `validate`
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Lesson 7 — Requests
|
|
2
|
+
|
|
3
|
+
**Goal:** ask a whole question — which rows, in what order, which page, which fields — as one
|
|
4
|
+
document.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Previous: [Typed queries](06-typed-queries.md) · Next: [Counting by a field](08-grouping.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## A table on a screen is four questions
|
|
11
|
+
|
|
12
|
+
It is a filter, an order, a page, and a decision about which columns are allowed to leave the
|
|
13
|
+
server. A **request** is all four in one JSON document:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"where": { "status": "paid" },
|
|
18
|
+
"sort": { "total": -1 },
|
|
19
|
+
"skip": 0,
|
|
20
|
+
"limit": 20,
|
|
21
|
+
"fields": ["id", "total"],
|
|
22
|
+
"omit": []
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Every key is optional, and `where` takes a query — everything from lessons 1 to 5. Because the
|
|
27
|
+
whole thing is still one JSON document, the whole question can travel in a URL, be saved, or
|
|
28
|
+
be validated in one go.
|
|
29
|
+
|
|
30
|
+
`search(collection, request)` runs it.
|
|
31
|
+
|
|
32
|
+
## Filter, sort, and take a page
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { search } from "@osqd/jql";
|
|
36
|
+
import { orders } from "./orders.mjs";
|
|
37
|
+
|
|
38
|
+
const page = search(orders, {
|
|
39
|
+
where: { status: { $ne: "cancelled" } },
|
|
40
|
+
sort: { total: -1 },
|
|
41
|
+
limit: 3,
|
|
42
|
+
fields: ["id", "total"],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
for (const row of page) console.log(row);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
{ id: 'o-1005', total: 170 }
|
|
50
|
+
{ id: 'o-1004', total: 36 }
|
|
51
|
+
{ id: 'o-1006', total: 33 }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## The next page
|
|
55
|
+
|
|
56
|
+
Add `skip`. Nothing else changes:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { search } from "@osqd/jql";
|
|
60
|
+
import { orders } from "./orders.mjs";
|
|
61
|
+
|
|
62
|
+
const request = {
|
|
63
|
+
where: { status: { $ne: "cancelled" } },
|
|
64
|
+
sort: { total: -1 },
|
|
65
|
+
limit: 3,
|
|
66
|
+
fields: ["id", "total"],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
console.log("page 1:", search(orders, { ...request, skip: 0 }).map((row) => row.id));
|
|
70
|
+
console.log("page 2:", search(orders, { ...request, skip: 3 }).map((row) => row.id));
|
|
71
|
+
console.log("page 3:", search(orders, { ...request, skip: 6 }).map((row) => row.id));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
page 1: [ 'o-1005', 'o-1004', 'o-1006' ]
|
|
76
|
+
page 2: [ 'o-1002', 'o-1001', 'o-1008' ]
|
|
77
|
+
page 3: [ 'o-1003' ]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Sorting by more than one key
|
|
81
|
+
|
|
82
|
+
The keys are applied in order: the first sorts, the next breaks its ties. `1` or `"asc"` is
|
|
83
|
+
ascending, `-1` or `"desc"` descending.
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
import { search } from "@osqd/jql";
|
|
87
|
+
import { orders } from "./orders.mjs";
|
|
88
|
+
|
|
89
|
+
const line = (row) => `${row.status.padEnd(10)}${String(row.total).padStart(6)} ${row.id}`;
|
|
90
|
+
|
|
91
|
+
console.log("by status alone:");
|
|
92
|
+
for (const row of search(orders, { sort: { status: "asc" } })) console.log(" ", line(row));
|
|
93
|
+
|
|
94
|
+
console.log("by status, then total, descending:");
|
|
95
|
+
for (const row of search(orders, { sort: { status: "asc", total: -1 } })) console.log(" ", line(row));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
by status alone:
|
|
100
|
+
cancelled 280 o-1007
|
|
101
|
+
open 30 o-1002
|
|
102
|
+
open 170 o-1005
|
|
103
|
+
paid 22.5 o-1001
|
|
104
|
+
paid 4.5 o-1003
|
|
105
|
+
paid 33 o-1006
|
|
106
|
+
paid 12 o-1008
|
|
107
|
+
refunded 36 o-1004
|
|
108
|
+
by status, then total, descending:
|
|
109
|
+
cancelled 280 o-1007
|
|
110
|
+
open 170 o-1005
|
|
111
|
+
open 30 o-1002
|
|
112
|
+
paid 33 o-1006
|
|
113
|
+
paid 22.5 o-1001
|
|
114
|
+
paid 12 o-1008
|
|
115
|
+
paid 4.5 o-1003
|
|
116
|
+
refunded 36 o-1004
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Why this paging can be trusted
|
|
120
|
+
|
|
121
|
+
**The sort is stable.** In the first list the four paid orders tie on the only key there is,
|
|
122
|
+
and they come back in the order they arrived: `o-1001`, `o-1003`, `o-1006`, `o-1008`. An
|
|
123
|
+
unstable sort is free to return them in any order at all, and a *different* order each time —
|
|
124
|
+
so a reader paging through would see one of them twice and never see another. Adding `total`
|
|
125
|
+
as a second key, as the second list does, settles them deliberately instead.
|
|
126
|
+
|
|
127
|
+
**Missing values have a defined place.** The full order is written down in the specification —
|
|
128
|
+
missing and `null` first, then numbers, strings, objects, booleans, dates. It is not the order
|
|
129
|
+
you would guess. It is the order you can *rely* on, which is the property that matters when
|
|
130
|
+
two systems sort the same result.
|
|
131
|
+
|
|
132
|
+
## `fields` — keeping some of the document
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
import { search } from "@osqd/jql";
|
|
136
|
+
import { orders } from "./orders.mjs";
|
|
137
|
+
|
|
138
|
+
const [row] = search(orders, {
|
|
139
|
+
where: { id: "o-1001" },
|
|
140
|
+
fields: ["id", "customer.name", "lines.sku"],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log(JSON.stringify(row, null, 2));
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
{
|
|
148
|
+
"id": "o-1001",
|
|
149
|
+
"customer": {
|
|
150
|
+
"name": "Ada Lovelace"
|
|
151
|
+
},
|
|
152
|
+
"lines": [
|
|
153
|
+
{
|
|
154
|
+
"sku": "pen-fine"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"sku": "ink-blue"
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`fields` keeps the paths you name **in the document's own shape**. `customer.name` comes back
|
|
164
|
+
as `{ customer: { name } }`, and an array on the way stays an array of projected elements. A
|
|
165
|
+
caller reads a projected row exactly as it reads a whole one — no flattening, no `"customer.name"`
|
|
166
|
+
keys to unpick.
|
|
167
|
+
|
|
168
|
+
## `omit` — dropping some of it
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
import { search } from "@osqd/jql";
|
|
172
|
+
import { orders } from "./orders.mjs";
|
|
173
|
+
|
|
174
|
+
const [row] = search(orders, {
|
|
175
|
+
where: { id: "o-1001" },
|
|
176
|
+
omit: ["customer.email", "lines", "note"],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
console.log(JSON.stringify(row, null, 2));
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
{
|
|
184
|
+
"id": "o-1001",
|
|
185
|
+
"placed": "2026-09-01T09:15:00Z",
|
|
186
|
+
"status": "paid",
|
|
187
|
+
"channel": "web",
|
|
188
|
+
"customer": {
|
|
189
|
+
"name": "Ada Lovelace",
|
|
190
|
+
"country": "GB"
|
|
191
|
+
},
|
|
192
|
+
"total": 22.5,
|
|
193
|
+
"paid": 22.5,
|
|
194
|
+
"tags": [
|
|
195
|
+
"gift"
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`omit` runs **after** `fields`, and that order is what redaction needs:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{ "fields": ["request"], "omit": ["request.headers.cookie"] }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Keep the whole request object, except the one header that must not leave the process. Naming
|
|
207
|
+
every header you *do* want would mean a code change every time a new one appears; naming the
|
|
208
|
+
one you do not is a rule that stays true.
|
|
209
|
+
|
|
210
|
+
The row is copied rather than changed, and only the objects on the way to a dropped field are
|
|
211
|
+
copied — so redacting one field of a large record does not duplicate the record.
|
|
212
|
+
|
|
213
|
+
## Two things `search` does that a hand-written version would not
|
|
214
|
+
|
|
215
|
+
**Without a sort, it stops early.** The first page of matches is the first `skip + limit` of
|
|
216
|
+
them, so the walk ends as soon as it has them rather than testing the rest of the collection.
|
|
217
|
+
|
|
218
|
+
**With a sort and a limit, it keeps only the best `skip + limit`** in a bounded heap rather
|
|
219
|
+
than sorting everything to keep ten. "The ten largest orders out of a million" sorts ten. This
|
|
220
|
+
is the one case where `search` is genuinely *faster* than filter-sort-slice by hand, rather
|
|
221
|
+
than merely equal to it.
|
|
222
|
+
|
|
223
|
+
## Exercise
|
|
224
|
+
|
|
225
|
+
The console shows "the five newest orders that still owe money", with the id, the customer's
|
|
226
|
+
name, and enough to work out what is outstanding. Write the request.
|
|
227
|
+
|
|
228
|
+
<details>
|
|
229
|
+
<summary>Answer</summary>
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { search } from "@osqd/jql";
|
|
233
|
+
import { orders } from "./orders.mjs";
|
|
234
|
+
|
|
235
|
+
const chase = search(orders, {
|
|
236
|
+
where: { paid: 0, status: { $nin: ["cancelled", "refunded"] } },
|
|
237
|
+
sort: { placed: -1 },
|
|
238
|
+
limit: 5,
|
|
239
|
+
fields: ["id", "customer.name", "total", "paid"],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
for (const row of chase) console.log(row.id, row.customer.name, "owes", row.total - row.paid);
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
o-1005 Tim Berners-Lee owes 170
|
|
247
|
+
o-1002 Grace Hopper owes 30
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`paid: 0` is the filter. The `$nin` is what stops a cancelled or refunded order appearing on a
|
|
251
|
+
list of people to chase — worth writing down, because "unpaid" and "owes money" are not the
|
|
252
|
+
same set.
|
|
253
|
+
|
|
254
|
+
In lesson 10 `outstanding` becomes a computed field, and this request gets to name it
|
|
255
|
+
directly instead of handing back two numbers for the screen to subtract.
|
|
256
|
+
</details>
|
|
257
|
+
|
|
258
|
+
## Related
|
|
259
|
+
|
|
260
|
+
- [Specification §8](../reference/specification.md#8-requests) — the request envelope
|
|
261
|
+
- [Specification §8.1](../reference/specification.md#81-sort-order) — the order, in full
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Lesson 8 — Counting by a field
|
|
2
|
+
|
|
3
|
+
**Goal:** the number above the table, without writing the tally loop again.
|
|
4
|
+
|
|
5
|
+
← [Course](index.md) · Previous: [Requests](07-requests.md) · Next: [Why did this not match?](09-explaining.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Everybody writes this loop
|
|
10
|
+
|
|
11
|
+
Orders per status, requests per verdict, errors per service. It is five lines of `Map` and a
|
|
12
|
+
`sort`, which is why every screen writes its own — and why no two of them agree. Three
|
|
13
|
+
questions decide the answer, and each project answers them differently by accident:
|
|
14
|
+
|
|
15
|
+
- what does an item with **two** values count as?
|
|
16
|
+
- where do the items with **no** value go?
|
|
17
|
+
- what order do the groups come back in?
|
|
18
|
+
|
|
19
|
+
`group` answers all three, the same way every time.
|
|
20
|
+
|
|
21
|
+
## The simplest tally
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { group } from "@osqd/jql";
|
|
25
|
+
import { orders } from "./orders.mjs";
|
|
26
|
+
|
|
27
|
+
for (const held of group(orders, "status")) console.log(held.count, held.key);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
4 paid
|
|
32
|
+
2 open
|
|
33
|
+
1 cancelled
|
|
34
|
+
1 refunded
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each group is `{ key, count }`, biggest first. The second argument is a **path**, so anything
|
|
38
|
+
lessons 1 to 3 taught you to reach is something you can count by:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { group } from "@osqd/jql";
|
|
42
|
+
import { orders } from "./orders.mjs";
|
|
43
|
+
|
|
44
|
+
for (const held of group(orders, "customer.country")) console.log(held.count, held.key);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
3 GB
|
|
49
|
+
3 US
|
|
50
|
+
1 DE
|
|
51
|
+
1 FR
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Counting only some of the rows
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { group } from "@osqd/jql";
|
|
58
|
+
import { orders } from "./orders.mjs";
|
|
59
|
+
|
|
60
|
+
const big = group(orders, "status", { where: { total: { $gte: 30 } } });
|
|
61
|
+
|
|
62
|
+
console.log(big.map((held) => `${held.key}=${held.count}`));
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
[ 'open=2', 'cancelled=1', 'paid=1', 'refunded=1' ]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`where` takes an ordinary query. Put the filter **here**, not around the result: grouping
|
|
70
|
+
everything and then presenting it beside a filtered table is a wrong number with nothing on
|
|
71
|
+
the screen to say so.
|
|
72
|
+
|
|
73
|
+
## Grouping by something that holds several values
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { group } from "@osqd/jql";
|
|
77
|
+
import { orders } from "./orders.mjs";
|
|
78
|
+
|
|
79
|
+
console.log("by tag:");
|
|
80
|
+
for (const held of group(orders, "tags")) console.log(" ", held.count, JSON.stringify(held.key));
|
|
81
|
+
|
|
82
|
+
console.log("by sku:");
|
|
83
|
+
for (const held of group(orders, "lines.sku")) console.log(" ", held.count, held.key);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
by tag:
|
|
88
|
+
3 "repeat"
|
|
89
|
+
2 null
|
|
90
|
+
2 "fragile"
|
|
91
|
+
1 "damaged"
|
|
92
|
+
1 "gift"
|
|
93
|
+
by sku:
|
|
94
|
+
4 pad-a5
|
|
95
|
+
3 pen-fine
|
|
96
|
+
2 desk-lamp
|
|
97
|
+
2 ink-blue
|
|
98
|
+
2 ink-red
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Two things are happening here, and both are answers to the questions at the top.
|
|
102
|
+
|
|
103
|
+
**An item counts once in each group it belongs to.** `o-1004` is tagged
|
|
104
|
+
`["damaged", "repeat"]` and appears under both. A value repeated *within* one item still
|
|
105
|
+
counts once, because the question is "how many orders involved this sku", not "how many
|
|
106
|
+
lines".
|
|
107
|
+
|
|
108
|
+
So **the counts do not add up to the number of rows**, and should not: the sku tally comes to
|
|
109
|
+
thirteen over eight orders. If you want the counts to add up, group by something each row has
|
|
110
|
+
exactly one of.
|
|
111
|
+
|
|
112
|
+
**Everything that cannot name a group shares `null`.** That is the `null` in the tag list: the
|
|
113
|
+
two orders with no tags at all. A missing field, a `null`, a value that is an object, and an
|
|
114
|
+
empty list all land there.
|
|
115
|
+
|
|
116
|
+
That is the "unlabelled" heading on a chart, and it is deliberately a real answer rather than
|
|
117
|
+
a row that quietly vanished. A count that silently omits rows is worse than no count, because
|
|
118
|
+
it still looks like a count.
|
|
119
|
+
|
|
120
|
+
## Order and size
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { group } from "@osqd/jql";
|
|
124
|
+
import { orders } from "./orders.mjs";
|
|
125
|
+
|
|
126
|
+
const line = (groups) => groups.map((held) => `${held.key}=${held.count}`).join(" ");
|
|
127
|
+
|
|
128
|
+
console.log("biggest first (default):", line(group(orders, "status")));
|
|
129
|
+
console.log("alphabetically by key :", line(group(orders, "status", { sort: "key" })));
|
|
130
|
+
console.log("the top two :", line(group(orders, "status", { limit: 2 })));
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
biggest first (default): paid=4 open=2 cancelled=1 refunded=1
|
|
135
|
+
alphabetically by key : cancelled=1 open=2 paid=4 refunded=1
|
|
136
|
+
the top two : paid=4 open=2
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Ties break by the key, so the same data always comes back in the same order and a chart does
|
|
140
|
+
not reshuffle itself between two refreshes that found nothing new.
|
|
141
|
+
|
|
142
|
+
## Keeping the rows behind each group
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { group } from "@osqd/jql";
|
|
146
|
+
import { orders } from "./orders.mjs";
|
|
147
|
+
|
|
148
|
+
for (const held of group(orders, "channel", { items: 2 })) {
|
|
149
|
+
console.log(String(held.key).padEnd(6), held.count, "→", held.items.map((order) => order.id).join(" "));
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
web 4 → o-1001 o-1002
|
|
155
|
+
app 2 → o-1005 o-1006
|
|
156
|
+
phone 2 → o-1003 o-1008
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`items: true` keeps every row; `items: 2` keeps the first two. This is what turns a chart into
|
|
160
|
+
a chart you can click — and the cap is why a group of ten thousand rows does not arrive whole
|
|
161
|
+
in a dashboard payload.
|
|
162
|
+
|
|
163
|
+
## The options
|
|
164
|
+
|
|
165
|
+
| | |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `where` | which items to count |
|
|
168
|
+
| `sort` | `"count"` (the default) or `"key"` |
|
|
169
|
+
| `limit` | how many groups, largest first |
|
|
170
|
+
| `items` | `true` to keep every item, or a number to keep the first few |
|
|
171
|
+
|
|
172
|
+
## Exercise
|
|
173
|
+
|
|
174
|
+
Harbour wants "money outstanding, by country" — only orders that still owe, largest first.
|
|
175
|
+
`group` counts rows rather than summing a field, so this one needs a little thought.
|
|
176
|
+
|
|
177
|
+
<details>
|
|
178
|
+
<summary>Answer</summary>
|
|
179
|
+
|
|
180
|
+
`group` deliberately counts rather than sums. A general aggregation language is a much bigger
|
|
181
|
+
thing than a query language, and this one stops at the edge on purpose. Keep the rows and sum
|
|
182
|
+
them yourself:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
import { group } from "@osqd/jql";
|
|
186
|
+
import { orders } from "./orders.mjs";
|
|
187
|
+
|
|
188
|
+
const owing = group(orders, "customer.country", {
|
|
189
|
+
where: { paid: 0, status: "open" },
|
|
190
|
+
items: true,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
for (const held of owing) {
|
|
194
|
+
const total = held.items.reduce((sum, order) => sum + order.total, 0);
|
|
195
|
+
console.log(held.key, held.count, "order(s),", total, "outstanding");
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
GB 1 order(s), 170 outstanding
|
|
201
|
+
US 1 order(s), 30 outstanding
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
If you find yourself really wanting a `$sum` here, that is the signal that the *store* should
|
|
205
|
+
be answering this question rather than your process — which is lesson 15.
|
|
206
|
+
</details>
|
|
207
|
+
|
|
208
|
+
## Related
|
|
209
|
+
|
|
210
|
+
- [Library API](../reference/api.md) — `group` and its options
|