@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,239 @@
1
+ # Lesson 3 — Arrays and paths
2
+
3
+ **Goal:** know exactly what happens when a path meets an array. This is the lesson that stops
4
+ you writing a filter that looks right and is not.
5
+
6
+ ← [Course](index.md) · Previous: [Asking precisely](02-operators.md) · Next: [Combining and negating](04-combining.md)
7
+
8
+ ---
9
+
10
+ ## The rule
11
+
12
+ A path like `customer.country` is resolved one segment at a time. On an object, take the
13
+ field. On an array, one of two things happens:
14
+
15
+ - the segment is a **number** — take that position, so `lines.0.sku` is the first line's sku;
16
+ - the segment is anything else — apply it to **every element**. The array is *seen through*.
17
+
18
+ Because of the second case a path can reach **several values at once**, and then:
19
+
20
+ > **A condition holds when it holds for any one of the values the path reached.**
21
+
22
+ That one sentence explains almost every surprise in this language. The rest of this lesson is
23
+ four small programs showing what it does.
24
+
25
+ ## A value matches any element
26
+
27
+ ```js
28
+ import { filter } from "@osqd/jql";
29
+ import { orders } from "./orders.mjs";
30
+
31
+ const repeat = filter(orders, { tags: "repeat" });
32
+
33
+ console.log(repeat.map((order) => `${order.id} ${JSON.stringify(order.tags)}`));
34
+ ```
35
+
36
+ ```
37
+ [
38
+ 'o-1003 ["repeat"]',
39
+ 'o-1004 ["damaged","repeat"]',
40
+ 'o-1008 ["repeat"]'
41
+ ]
42
+ ```
43
+
44
+ `tags` is a list, and `"repeat"` is not a list — so the condition is tried against each tag in
45
+ turn, and one match is enough. You do not write anything special to search inside a list.
46
+
47
+ ## A path can go through one
48
+
49
+ ```js
50
+ import { filter } from "@osqd/jql";
51
+ import { orders } from "./orders.mjs";
52
+
53
+ const ids = (query) => filter(orders, query).map((order) => order.id);
54
+
55
+ console.log("any line is a fine liner ", ids({ "lines.sku": "pen-fine" }));
56
+ console.log("the FIRST line is one ", ids({ "lines.0.sku": "pen-fine" }));
57
+ ```
58
+
59
+ ```
60
+ any line is a fine liner [ 'o-1001', 'o-1003', 'o-1006' ]
61
+ the FIRST line is one [ 'o-1001', 'o-1003' ]
62
+ ```
63
+
64
+ `lines.sku` reaches the sku of *every* line. `lines.0.sku` reaches exactly one value, because
65
+ `0` is a position rather than a field name. `o-1006` has a fine liner as its second line, so
66
+ it answers the first question and not the second.
67
+
68
+ ## The trap
69
+
70
+ Here is the mistake this lesson exists for. Read this query aloud:
71
+
72
+ ```json
73
+ { "lines.quantity": { "$gt": 5 }, "lines.price": { "$gt": 100 } }
74
+ ```
75
+
76
+ Most people say *"a line of more than five, at over a hundred pounds"*. Now run it:
77
+
78
+ ```js
79
+ import { filter } from "@osqd/jql";
80
+ import { orders } from "./orders.mjs";
81
+
82
+ const found = filter(orders, { "lines.quantity": { $gt: 5 }, "lines.price": { $gt: 100 } });
83
+
84
+ for (const order of found) {
85
+ console.log(order.id);
86
+ for (const line of order.lines) console.log(" ", line.quantity, "x", line.title, "@", line.price);
87
+ }
88
+ ```
89
+
90
+ ```
91
+ o-1005
92
+ 1 x Desk lamp @ 140
93
+ 10 x A5 pad @ 3
94
+ ```
95
+
96
+ There is no expensive bulk line in that order at all. The query says **"some line has
97
+ quantity over five, *and* some line costs over a hundred"** — and `o-1005` satisfies it with
98
+ two *different* lines.
99
+
100
+ Each condition is resolved on its own, and each is happy as soon as any one element satisfies
101
+ it. Nothing ties them to the same element unless you say so.
102
+
103
+ ## `$elemMatch` says "the same element"
104
+
105
+ ```js
106
+ import { filter } from "@osqd/jql";
107
+ import { orders } from "./orders.mjs";
108
+
109
+ const ids = (query) => filter(orders, query).map((order) => order.id);
110
+
111
+ console.log("big AND expensive, same line:", ids({
112
+ lines: { $elemMatch: { quantity: { $gt: 5 }, price: { $gt: 100 } } },
113
+ }));
114
+
115
+ console.log("at least 2, at least £9 :", ids({
116
+ lines: { $elemMatch: { quantity: { $gte: 2 }, price: { $gte: 9 } } },
117
+ }));
118
+ ```
119
+
120
+ ```
121
+ big AND expensive, same line: []
122
+ at least 2, at least £9 : [ 'o-1004', 'o-1007' ]
123
+ ```
124
+
125
+ The first is empty, which is the true answer: nobody has bought more than five of anything
126
+ costing over a hundred pounds. The second shows `$elemMatch` finding real matches, so you can
127
+ see it is not simply stricter about everything.
128
+
129
+ Both readings are legitimate — "this order has a big line *and* an expensive line" is a real
130
+ question someone might ask. The danger is that the loose one is what you get by default, and
131
+ in small test data the two usually agree.
132
+
133
+ **Rule of thumb:** the moment a query puts two conditions on the *same array*, stop and decide
134
+ whether they must hold of the same element. If they must, it is `$elemMatch`.
135
+
136
+ ## `$all` — several values, any elements
137
+
138
+ ```js
139
+ import { filter } from "@osqd/jql";
140
+ import { orders } from "./orders.mjs";
141
+
142
+ const ids = (query) => filter(orders, query).map((order) => order.id);
143
+
144
+ console.log("repeat ", ids({ tags: "repeat" }));
145
+ console.log("repeat AND damaged", ids({ tags: { $all: ["repeat", "damaged"] } }));
146
+ ```
147
+
148
+ ```
149
+ repeat [ 'o-1003', 'o-1004', 'o-1008' ]
150
+ repeat AND damaged [ 'o-1004' ]
151
+ ```
152
+
153
+ `$all` is the other half of the pair: *every one of these values is somewhere in the list*,
154
+ each possibly in a different element. Use `$all` for a set of tags, `$elemMatch` for several
155
+ facts about one object.
156
+
157
+ ## Counting: `$size` and `$length`
158
+
159
+ ```js
160
+ import { filter } from "@osqd/jql";
161
+ import { orders } from "./orders.mjs";
162
+
163
+ const ids = (query) => filter(orders, query).map((order) => order.id);
164
+
165
+ console.log("exactly two lines", ids({ lines: { $size: 2 } }));
166
+ console.log("no tags at all ", ids({ tags: { $size: 0 } }));
167
+ console.log("exactly two tags ", ids({ tags: { $length: 2 } }));
168
+ ```
169
+
170
+ ```
171
+ exactly two lines [ 'o-1001', 'o-1004', 'o-1005', 'o-1006', 'o-1008' ]
172
+ no tags at all [ 'o-1002', 'o-1006' ]
173
+ exactly two tags [ 'o-1004' ]
174
+ ```
175
+
176
+ `$size` counts elements. `$length` measures an array **or a string** — its elements, or its
177
+ UTF-16 code units — and accepts a nested condition, so `{ $length: { $gt: 18 } }` is a valid
178
+ question about a note.
179
+
180
+ Unlike the string operators, neither of these is tried against the elements. If `$length` were
181
+ seen through the array, `{ tags: { $length: 1 } }` would mean both "one tag" and "a tag one
182
+ character long" at once, with nothing in the query to say which.
183
+
184
+ ## The same rule, all the way down
185
+
186
+ The "seen through" rule applies at *every* segment, including before the first one — an item
187
+ that is itself an array is seen through too:
188
+
189
+ ```js
190
+ import { filter } from "@osqd/jql";
191
+
192
+ const nested = [[{ sku: "pen-fine" }], [{ sku: "ink-red" }]];
193
+
194
+ console.log("item is a list of objects:", filter(nested, { sku: "pen-fine" }).length);
195
+ console.log("items are bare numbers :", filter([3, 8, 1, 12], { $gt: 5 }));
196
+ ```
197
+
198
+ ```
199
+ item is a list of objects: 1
200
+ items are bare numbers : [ 8, 12 ]
201
+ ```
202
+
203
+ The second line is worth a moment. `{ $gt: 5 }` has no field name in it at all — a field
204
+ operator at the top of a query is a condition on **the item itself**. That is how you query a
205
+ list of numbers, or of strings, where there is no field to name.
206
+
207
+ ## Exercise
208
+
209
+ Find the orders containing a line of **ten or more A5 pads**. Then try the same question
210
+ without `$elemMatch`, and work out why it is not a safe way to write it.
211
+
212
+ <details>
213
+ <summary>Answer</summary>
214
+
215
+ ```js
216
+ import { filter } from "@osqd/jql";
217
+ import { orders } from "./orders.mjs";
218
+
219
+ const ids = (query) => filter(orders, query).map((order) => order.id);
220
+
221
+ console.log("with $elemMatch :", ids({ lines: { $elemMatch: { sku: "pad-a5", quantity: { $gte: 10 } } } }));
222
+ console.log("without $elemMatch:", ids({ "lines.sku": "pad-a5", "lines.quantity": { $gte: 10 } }));
223
+ ```
224
+
225
+ ```
226
+ with $elemMatch : [ 'o-1002', 'o-1005' ]
227
+ without $elemMatch: [ 'o-1002', 'o-1005' ]
228
+ ```
229
+
230
+ The two agree — on *this* data, because in both orders it really is the pad line that is
231
+ large. That is exactly why the loose version survives testing: it needs a row where the two
232
+ conditions land on different elements before it goes wrong, and your fixtures probably do not
233
+ have one.
234
+ </details>
235
+
236
+ ## Related
237
+
238
+ - [Specification §3](../reference/specification.md#3-paths) — path resolution, formally
239
+ - [Specification §4.1](../reference/specification.md#41-how-a-condition-meets-an-array) — the "any of them" rule
@@ -0,0 +1,221 @@
1
+ # Lesson 4 — Combining and negating
2
+
3
+ **Goal:** `$or`, `$nor` and `$not` — and what negation means when a path reaches more than one
4
+ value.
5
+
6
+ ← [Course](index.md) · Previous: [Arrays and paths](03-arrays-and-paths.md) · Next: [Dates and windows](05-dates.md)
7
+
8
+ ---
9
+
10
+ ## Four operators that take whole queries
11
+
12
+ Two keys side by side already mean *and*. For everything else there are four operators whose
13
+ arguments are themselves queries:
14
+
15
+ | | |
16
+ | --- | --- |
17
+ | `$and: [ … ]` | every one of them holds |
18
+ | `$or: [ … ]` | at least one holds |
19
+ | `$nor: [ … ]` | none of them holds |
20
+ | `$not: { … }` | this one does not hold |
21
+
22
+ They nest freely, and `$not` may sit at the very top of a query, which makes "everything
23
+ except this saved filter" a one-character change.
24
+
25
+ The first half of this lesson is mechanical. The second half — negation over a list, and
26
+ negation of a field nobody filled in — is where the surprises are.
27
+
28
+ ## `$or` and `$and`
29
+
30
+ ```js
31
+ import { filter } from "@osqd/jql";
32
+ import { orders } from "./orders.mjs";
33
+
34
+ const ids = (query) => filter(orders, query).map((order) => order.id);
35
+
36
+ console.log("cancelled OR over £150:", ids({
37
+ $or: [{ status: "cancelled" }, { total: { $gt: 150 } }],
38
+ }));
39
+
40
+ console.log("paid AND under £20 :", ids({
41
+ $and: [{ status: "paid" }, { total: { $lt: 20 } }],
42
+ }));
43
+ ```
44
+
45
+ ```
46
+ cancelled OR over £150: [ 'o-1005', 'o-1007' ]
47
+ paid AND under £20 : [ 'o-1003', 'o-1008' ]
48
+ ```
49
+
50
+ That `$and` could have been written `{ status: "paid", total: { $lt: 20 } }`. You need the
51
+ spelled-out form only when two branches would collide on the same key — two different
52
+ conditions on `total`, say, or a list of filters you built in a loop and cannot merge into one
53
+ object.
54
+
55
+ ## `$nor` and `$not`
56
+
57
+ ```js
58
+ import { filter } from "@osqd/jql";
59
+ import { orders } from "./orders.mjs";
60
+
61
+ const ids = (query) => filter(orders, query).map((order) => order.id);
62
+
63
+ console.log("neither paid nor open :", ids({ $nor: [{ status: "paid" }, { status: "open" }] }));
64
+ console.log("not a British customer:", ids({ $not: { "customer.country": "GB" } }));
65
+ console.log("$not on a field :", ids({ status: { $not: { $in: ["paid", "open"] } } }));
66
+ ```
67
+
68
+ ```
69
+ neither paid nor open : [ 'o-1004', 'o-1007' ]
70
+ not a British customer: [ 'o-1002', 'o-1004', 'o-1006', 'o-1007', 'o-1008' ]
71
+ $not on a field : [ 'o-1004', 'o-1007' ]
72
+ ```
73
+
74
+ `$nor` is "none of these". `$not` comes in two positions: wrapped around a **whole query**, as
75
+ on the second line, and inside a **field's condition**, as on the third.
76
+
77
+ The first and third lines returned the same two orders, and that is not a coincidence — they
78
+ are the same question written two ways. Reach for whichever reads better where it is written:
79
+ `$nor` when the alternatives are whole queries, `$not` on the field when they are all about
80
+ one field.
81
+
82
+ ## Negation is about *all* the values, not one of them
83
+
84
+ Now the lesson. `o-1004` is tagged `["damaged", "repeat"]`. Watch where it appears:
85
+
86
+ ```js
87
+ import { filter } from "@osqd/jql";
88
+ import { orders } from "./orders.mjs";
89
+
90
+ const ids = (query) => filter(orders, query).map((order) => order.id);
91
+
92
+ console.log("has the tag repeat :", ids({ tags: "repeat" }));
93
+ console.log("$ne repeat :", ids({ tags: { $ne: "repeat" } }));
94
+ console.log("an element that is not:", ids({ tags: { $elemMatch: { $ne: "repeat" } } }));
95
+ ```
96
+
97
+ ```
98
+ has the tag repeat : [ 'o-1003', 'o-1004', 'o-1008' ]
99
+ $ne repeat : [ 'o-1001', 'o-1002', 'o-1005', 'o-1006', 'o-1007' ]
100
+ an element that is not: [ 'o-1001', 'o-1004', 'o-1005', 'o-1007' ]
101
+ ```
102
+
103
+ `o-1004` is in the **first and third** lists at once. That is not a contradiction:
104
+
105
+ - `$ne` holds when **no** value the path reached equals `"repeat"`. `o-1004` has one that
106
+ does, so it fails.
107
+ - `$elemMatch` holds when **some** element satisfies the condition inside. `o-1004` has the
108
+ tag `"damaged"`, which is not `"repeat"`, so it passes.
109
+
110
+ Both are true of the same order. "Not tagged repeat" and "has a tag other than repeat" are
111
+ different questions, and a list is exactly where they come apart.
112
+
113
+ Every negating operator works this way. `$ne`, `$nin`, `$exists: false` and `$not` all hold
114
+ when their positive counterpart holds for **none** of the reached values.
115
+
116
+ ## Negation and a field that is not there
117
+
118
+ The same rule has a second consequence, and it catches people more often than the array one:
119
+
120
+ ```js
121
+ import { filter } from "@osqd/jql";
122
+ import { orders } from "./orders.mjs";
123
+
124
+ const ids = (query) => filter(orders, query).map((order) => order.id);
125
+
126
+ console.log("note is not 'x' :", ids({ note: { $ne: "x" } }).length, "of 8");
127
+ console.log("has a note, not 'x' :", ids({ note: { $exists: true, $ne: "x" } }).length, "of 8");
128
+ ```
129
+
130
+ ```
131
+ note is not 'x' : 8 of 8
132
+ has a note, not 'x' : 5 of 8
133
+ ```
134
+
135
+ A path that reaches nothing has no value equal to `"x"` — so `$ne` is satisfied, and the three
136
+ orders with no `note` at all come back. This is consistent, but it is rarely what someone
137
+ means by "everything except x".
138
+
139
+ If you mean "has a note, and it is not x", say both things:
140
+
141
+ ```json
142
+ { "note": { "$exists": true, "$ne": "x" } }
143
+ ```
144
+
145
+ ## Empty lists
146
+
147
+ ```js
148
+ import { count } from "@osqd/jql";
149
+ import { orders } from "./orders.mjs";
150
+
151
+ console.log("$and: [] matches", count(orders, { $and: [] }), "of 8");
152
+ console.log("$or: [] matches", count(orders, { $or: [] }), "of 8");
153
+ ```
154
+
155
+ ```
156
+ $and: [] matches 8 of 8
157
+ $or: [] matches 0 of 8
158
+ ```
159
+
160
+ An empty `$and` matches everything and an empty `$or` matches nothing. That looks asymmetric
161
+ until you build the list in a loop: "no filters were selected" should narrow nothing, and "no
162
+ alternatives were offered" should accept nothing. Refusing the empty list instead would turn a
163
+ perfectly ordinary state of the user interface into an error every caller has to special-case.
164
+
165
+ ## `$comment`
166
+
167
+ ```js
168
+ import { filter } from "@osqd/jql";
169
+ import { orders } from "./orders.mjs";
170
+
171
+ const found = filter(orders, { status: "paid", $comment: "the weekly reconciliation" });
172
+
173
+ console.log(found.map((order) => order.id));
174
+ ```
175
+
176
+ ```
177
+ [ 'o-1001', 'o-1003', 'o-1006', 'o-1008' ]
178
+ ```
179
+
180
+ `$comment` decides nothing. It is there because a stored filter gets read by a person months
181
+ later, and a sentence of intent beside it is worth more than a commit message they will not
182
+ find. It is also dropped from the canonical form (lesson 13), so it never makes one filter
183
+ look like two.
184
+
185
+ ## Exercise
186
+
187
+ The support console wants a "needs attention" filter: orders with **no tags at all**, or **no
188
+ note**. One query.
189
+
190
+ <details>
191
+ <summary>Answer</summary>
192
+
193
+ ```js
194
+ import { filter } from "@osqd/jql";
195
+ import { orders } from "./orders.mjs";
196
+
197
+ const ids = (query) => filter(orders, query).map((order) => order.id);
198
+
199
+ console.log("with $size:", ids({ $or: [{ tags: { $size: 0 } }, { note: { $exists: false } }] }));
200
+ console.log("with [] :", ids({ $or: [{ tags: [] }, { note: { $exists: false } }] }));
201
+ ```
202
+
203
+ ```
204
+ with $size: [ 'o-1002', 'o-1005', 'o-1006', 'o-1008' ]
205
+ with [] : [ 'o-1002', 'o-1005', 'o-1006', 'o-1008' ]
206
+ ```
207
+
208
+ The two agree, and it is worth knowing why they are not the same question.
209
+
210
+ Equality is tried against **the whole array first**, and then against each element. So
211
+ `{ tags: [] }` asks "is this array exactly the empty array?", while `{ tags: { $size: 0 } }`
212
+ asks "how many elements are in it?". At zero they coincide. One step further and they part
213
+ company: `{ tags: ["gift"] }` matches an order whose tags are exactly `["gift"]`, whereas
214
+ `{ tags: { $size: 1 } }` matches an order with one tag whatever it is — so an order tagged
215
+ `["fragile"]` satisfies the second and not the first.
216
+ </details>
217
+
218
+ ## Related
219
+
220
+ - [Specification §6.1](../reference/specification.md#61-combining-queries) — the combinators
221
+ - [Specification §5.8](../reference/specification.md#58-not-on-a-field) — `$not` on a field
@@ -0,0 +1,214 @@
1
+ # Lesson 5 — Dates and windows
2
+
3
+ **Goal:** write an instant in JSON, and a window that still means the same thing next week.
4
+
5
+ ← [Course](index.md) · Previous: [Combining and negating](04-combining.md) · Next: [Typed queries](06-typed-queries.md)
6
+
7
+ ---
8
+
9
+ ## JSON has no date type
10
+
11
+ So JQL adds one wrapper:
12
+
13
+ ```json
14
+ { "placed": { "$gte": { "$date": "2026-09-14" } } }
15
+ ```
16
+
17
+ `{ "$date": … }` is the query **saying** that this comparison is about time. Without it a
18
+ timestamp is a string, and is compared as one. The wrapper accepts four things:
19
+
20
+ | | |
21
+ | --- | --- |
22
+ | `{ "$date": "2026-09-14" }` | an ISO 8601 date or timestamp |
23
+ | `{ "$date": 1788254100000 }` | epoch milliseconds |
24
+ | `{ "$date": "now" }` | the moment the query is compiled |
25
+ | `{ "$date": { "$ago": "7d" } }` | that long before now — `$ahead` for the future |
26
+
27
+ ## A fixed window
28
+
29
+ ```js
30
+ import { filter } from "@osqd/jql";
31
+ import { orders } from "./orders.mjs";
32
+
33
+ const ids = (query) => filter(orders, query).map((order) => order.id);
34
+
35
+ console.log("on or after the 14th:", ids({ placed: { $gte: { $date: "2026-09-14" } } }));
36
+
37
+ console.log("the 7th to the 18th :", ids({
38
+ placed: { $gte: { $date: "2026-09-07" }, $lt: { $date: "2026-09-18" } },
39
+ }));
40
+ ```
41
+
42
+ ```
43
+ on or after the 14th: [ 'o-1005', 'o-1006', 'o-1007', 'o-1008' ]
44
+ the 7th to the 18th : [ 'o-1003', 'o-1004', 'o-1005' ]
45
+ ```
46
+
47
+ Two operators in one condition, exactly as in lesson 2 — a window is just a range that happens
48
+ to be made of instants.
49
+
50
+ ## A window that moves with the clock
51
+
52
+ This is what `{ "$ago": … }` is for. A saved filter written this way means *"the last seven
53
+ days"*, not *"since the Tuesday somebody saved it"*:
54
+
55
+ ```js
56
+ import { filter } from "@osqd/jql";
57
+ import { orders } from "./orders.mjs";
58
+
59
+ // The clock is an argument, which is what makes this example reproducible.
60
+ const now = () => Date.parse("2026-09-25T12:00:00Z");
61
+ const ids = (query) => filter(orders, query, { now }).map((order) => order.id);
62
+
63
+ console.log("the last seven days:", ids({ placed: { $gte: { $date: { $ago: "7d" } } } }));
64
+ console.log("the last 36 hours :", ids({ placed: { $gte: { $date: { $ago: "36h" } } } }));
65
+ console.log("anything before now:", ids({ placed: { $lt: { $date: "now" } } }).length, "of 8");
66
+ ```
67
+
68
+ ```
69
+ the last seven days: [ 'o-1006', 'o-1007', 'o-1008' ]
70
+ the last 36 hours : [ 'o-1008' ]
71
+ anything before now: 8 of 8
72
+ ```
73
+
74
+ ## "Now" is an argument, not a clock
75
+
76
+ Notice `{ now }` in that example. The current time is an **option you pass**, never something
77
+ read from the environment behind your back. Two things fall out of that:
78
+
79
+ - a relative window is testable without waiting for time to pass, which is the only reason the
80
+ output above can be printed in a document at all;
81
+ - a scheduled job can ask for "the last hour" as of the hour it was *scheduled* for, rather
82
+ than the moment it happened to start.
83
+
84
+ ### The window is fixed when the query compiles
85
+
86
+ ```js
87
+ import { compile } from "@osqd/jql";
88
+ import { orders } from "./orders.mjs";
89
+
90
+ const now = () => Date.parse("2026-09-25T12:00:00Z");
91
+ const lastWeek = compile({ placed: { $gte: { $date: { $ago: "7d" } } } }, { now });
92
+
93
+ console.log("first pass :", orders.filter(lastWeek).map((order) => order.id));
94
+ console.log("second pass:", orders.filter(lastWeek).map((order) => order.id));
95
+ ```
96
+
97
+ ```
98
+ first pass : [ 'o-1006', 'o-1007', 'o-1008' ]
99
+ second pass: [ 'o-1006', 'o-1007', 'o-1008' ]
100
+ ```
101
+
102
+ A relative date resolves **once**, when the query is compiled — not once per row. That matters
103
+ more than it sounds. Resolving per comparison would judge two rows a second apart against two
104
+ different windows, and a scan over a million rows takes long enough for that to happen. When
105
+ you want the window to move, compile again; it costs well under a microsecond.
106
+
107
+ ## What counts as a date in a document
108
+
109
+ The wrapper says how the *query* means its value. The other half of the contract is how the
110
+ value in the **row** is read:
111
+
112
+ ```js
113
+ import { filter } from "@osqd/jql";
114
+
115
+ const held = [
116
+ { id: "iso-z", at: "2026-09-01T09:15:00Z" },
117
+ { id: "iso-no-offset", at: "2026-09-01T09:15:00" },
118
+ { id: "space", at: "2026-09-01 09:15:00" },
119
+ { id: "epoch", at: 1788254100000 },
120
+ { id: "date-object", at: new Date("2026-09-01T09:15:00Z") },
121
+ { id: "not-a-date", at: "1 September 2026" },
122
+ ];
123
+
124
+ const seen = filter(held, { at: { $eq: { $date: "2026-09-01T09:15:00Z" } } });
125
+
126
+ console.log(seen.map((row) => row.id));
127
+ ```
128
+
129
+ ```
130
+ [ 'iso-z', 'iso-no-offset', 'space', 'epoch', 'date-object' ]
131
+ ```
132
+
133
+ Five of the six are the same instant. A `Date` object, epoch milliseconds, or an ISO 8601
134
+ string with either a `T` or a space all count. `"1 September 2026"` does not — even though
135
+ JavaScript's own parser would accept it happily.
136
+
137
+ That restriction is deliberate. Host date parsers accept things no standard requires
138
+ (`"12/31/2020"` is a date in one country and nonsense in another), and a document that matched
139
+ in one implementation and not in another would make the same query mean two things.
140
+
141
+ ### A timestamp with no offset is UTC
142
+
143
+ `"2026-09-01T09:15:00"` means 09:15 **UTC**, not 09:15 wherever the process happens to be
144
+ running — which is why `iso-no-offset` and `space` are in that list.
145
+
146
+ JavaScript itself does not do this: it reads a date-only string as UTC and a timestamp without
147
+ an offset as local time. A query language cannot afford that, or the same filter over the same
148
+ data would give different answers in London and in Tokyo.
149
+
150
+ ### Nothing is a date unless the query says so
151
+
152
+ ```js
153
+ import { filter } from "@osqd/jql";
154
+
155
+ const held = [
156
+ { id: "iso-z", at: "2026-09-01T09:15:00Z" },
157
+ { id: "space", at: "2026-09-01 09:15:00" },
158
+ { id: "epoch", at: 1788254100000 },
159
+ ];
160
+
161
+ console.log("with $date :", filter(held, { at: { $eq: { $date: "2026-09-01T09:15:00Z" } } }).map((r) => r.id));
162
+ console.log("without $date:", filter(held, { at: "2026-09-01T09:15:00Z" }).map((r) => r.id));
163
+ ```
164
+
165
+ ```
166
+ with $date : [ 'iso-z', 'space', 'epoch' ]
167
+ without $date: [ 'iso-z' ]
168
+ ```
169
+
170
+ Without the wrapper it is a string comparison, and only the row holding that exact string
171
+ matches. No value is ever read as a date unless the query asked for it.
172
+
173
+ ## Durations
174
+
175
+ `$ago` and `$ahead` take `ms`, `s`, `m`, `h`, `d` and `w`, in any order and as many as you
176
+ like: `"90s"`, `"1h30m"`, `"7d"`, `"2w3d"`.
177
+
178
+ Months and years are deliberately missing. Neither has a fixed length, and a window that
179
+ changed size with the calendar is one nobody can reason about. Write `"30d"` and mean it.
180
+
181
+ ## Exercise
182
+
183
+ Write the query behind a "paid this month" figure on the Harbour dashboard, for September
184
+ 2026. Fixed dates, not relative ones — a monthly total should not move.
185
+
186
+ <details>
187
+ <summary>Answer</summary>
188
+
189
+ ```js
190
+ import { filter } from "@osqd/jql";
191
+ import { orders } from "./orders.mjs";
192
+
193
+ const paidInSeptember = filter(orders, {
194
+ status: "paid",
195
+ placed: { $gte: { $date: "2026-09-01" }, $lt: { $date: "2026-10-01" } },
196
+ });
197
+
198
+ console.log(paidInSeptember.map((order) => order.id));
199
+ console.log("total:", paidInSeptember.reduce((sum, order) => sum + order.paid, 0));
200
+ ```
201
+
202
+ ```
203
+ [ 'o-1001', 'o-1003', 'o-1006', 'o-1008' ]
204
+ total: 72
205
+ ```
206
+
207
+ Half-open — `$gte` the first of the month, `$lt` the first of the next — is the range to reach
208
+ for by default. It has no gap and no overlap with the neighbouring month, whatever the
209
+ precision of the timestamps involved.
210
+ </details>
211
+
212
+ ## Related
213
+
214
+ - [Specification §7](../reference/specification.md#7-dates) — the date rules in full