@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/LICENSE +102 -0
  3. package/README.md +84 -0
  4. package/bin/jql.mjs +5 -0
  5. package/conformance/cases.json +290 -0
  6. package/dist/array.d.ts +11 -0
  7. package/dist/async.d.ts +44 -0
  8. package/dist/canonical.d.ts +18 -0
  9. package/dist/cjs/array.d.ts +11 -0
  10. package/dist/cjs/async.d.ts +44 -0
  11. package/dist/cjs/canonical.d.ts +18 -0
  12. package/dist/cjs/cli.d.ts +15 -0
  13. package/dist/cjs/collections.d.ts +34 -0
  14. package/dist/cjs/core.d.ts +117 -0
  15. package/dist/cjs/errors.d.ts +17 -0
  16. package/dist/cjs/explain.d.ts +30 -0
  17. package/dist/cjs/global.d.ts +90 -0
  18. package/dist/cjs/group.d.ts +47 -0
  19. package/dist/cjs/index.d.ts +25 -0
  20. package/dist/cjs/internal/closest.d.ts +9 -0
  21. package/dist/cjs/internal/duration.d.ts +15 -0
  22. package/dist/cjs/internal/equal.d.ts +34 -0
  23. package/dist/cjs/internal/glob.d.ts +30 -0
  24. package/dist/cjs/internal/order.d.ts +24 -0
  25. package/dist/cjs/internal/path.d.ts +109 -0
  26. package/dist/cjs/internal/record.d.ts +18 -0
  27. package/dist/cjs/internal/values.d.ts +41 -0
  28. package/dist/cjs/limits.d.ts +64 -0
  29. package/dist/cjs/operators.d.ts +81 -0
  30. package/dist/cjs/package.json +3 -0
  31. package/dist/cjs/plan.d.ts +79 -0
  32. package/dist/cjs/search.d.ts +42 -0
  33. package/dist/cjs/targets/mongo.d.ts +55 -0
  34. package/dist/cjs/text/index.d.ts +12 -0
  35. package/dist/cjs/text/parse.d.ts +91 -0
  36. package/dist/cjs/text/suggest.d.ts +16 -0
  37. package/dist/cjs/text/write.d.ts +34 -0
  38. package/dist/cjs/types.d.ts +236 -0
  39. package/dist/cjs/vocabulary.d.ts +106 -0
  40. package/dist/cli.d.ts +15 -0
  41. package/dist/cli.js +2729 -0
  42. package/dist/cli.js.map +1 -0
  43. package/dist/collections.d.ts +34 -0
  44. package/dist/core.d.ts +117 -0
  45. package/dist/errors.d.ts +17 -0
  46. package/dist/explain.d.ts +30 -0
  47. package/dist/global.cjs +1953 -0
  48. package/dist/global.cjs.map +1 -0
  49. package/dist/global.d.ts +90 -0
  50. package/dist/global.js +1950 -0
  51. package/dist/global.js.map +1 -0
  52. package/dist/group.d.ts +47 -0
  53. package/dist/index.cjs +2529 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.ts +25 -0
  56. package/dist/index.js +2495 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/internal/closest.d.ts +9 -0
  59. package/dist/internal/duration.d.ts +15 -0
  60. package/dist/internal/equal.d.ts +34 -0
  61. package/dist/internal/glob.d.ts +30 -0
  62. package/dist/internal/order.d.ts +24 -0
  63. package/dist/internal/path.d.ts +109 -0
  64. package/dist/internal/record.d.ts +18 -0
  65. package/dist/internal/values.d.ts +41 -0
  66. package/dist/limits.d.ts +64 -0
  67. package/dist/mongo.cjs +357 -0
  68. package/dist/mongo.cjs.map +1 -0
  69. package/dist/mongo.js +354 -0
  70. package/dist/mongo.js.map +1 -0
  71. package/dist/operators.d.ts +81 -0
  72. package/dist/plan.d.ts +79 -0
  73. package/dist/search.d.ts +42 -0
  74. package/dist/targets/mongo.d.ts +55 -0
  75. package/dist/text/index.d.ts +12 -0
  76. package/dist/text/parse.d.ts +91 -0
  77. package/dist/text/suggest.d.ts +16 -0
  78. package/dist/text/write.d.ts +34 -0
  79. package/dist/text.cjs +674 -0
  80. package/dist/text.cjs.map +1 -0
  81. package/dist/text.js +667 -0
  82. package/dist/text.js.map +1 -0
  83. package/dist/types.d.ts +236 -0
  84. package/dist/vocabulary.d.ts +106 -0
  85. package/docs/course/01-first-query.md +217 -0
  86. package/docs/course/02-operators.md +285 -0
  87. package/docs/course/03-arrays-and-paths.md +239 -0
  88. package/docs/course/04-combining.md +221 -0
  89. package/docs/course/05-dates.md +214 -0
  90. package/docs/course/06-typed-queries.md +240 -0
  91. package/docs/course/07-requests.md +261 -0
  92. package/docs/course/08-grouping.md +210 -0
  93. package/docs/course/09-explaining.md +171 -0
  94. package/docs/course/10-vocabulary.md +276 -0
  95. package/docs/course/11-the-search-box.md +349 -0
  96. package/docs/course/12-untrusted.md +257 -0
  97. package/docs/course/13-saved-filters.md +199 -0
  98. package/docs/course/14-streams-and-cli.md +276 -0
  99. package/docs/course/15-pushdown.md +240 -0
  100. package/docs/course/16-extending.md +199 -0
  101. package/docs/course/index.md +185 -0
  102. package/docs/design/decisions.md +198 -0
  103. package/docs/design/performance.md +102 -0
  104. package/docs/guides/adopting.md +81 -0
  105. package/docs/guides/pushdown.md +147 -0
  106. package/docs/guides/typescript.md +115 -0
  107. package/docs/guides/untrusted-input.md +86 -0
  108. package/docs/index.md +102 -0
  109. package/docs/reference/api.md +266 -0
  110. package/docs/reference/cli.md +103 -0
  111. package/docs/reference/index.md +12 -0
  112. package/docs/reference/specification.md +549 -0
  113. package/docs/reference/text-syntax.md +152 -0
  114. package/docs/start/quick-start.md +84 -0
  115. 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