@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,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
|