@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,217 @@
1
+ # Lesson 1 — Your first query
2
+
3
+ **Goal:** ask a collection a question with a JSON document, and see what happens when the
4
+ document is wrong.
5
+
6
+ ← [Course](index.md) · Next: [Asking precisely](02-operators.md)
7
+
8
+ ---
9
+
10
+ ## A query is data
11
+
12
+ Most filters are code. You write a function, it takes a row, it returns true or false. That
13
+ works right up to the moment you want to *do* something with the filter itself — store it,
14
+ put it in a URL, send it to another service, show it to somebody, check it before running it.
15
+ A function can only be called.
16
+
17
+ A JQL query is **a JSON document**:
18
+
19
+ ```json
20
+ { "status": "open" }
21
+ ```
22
+
23
+ That is a whole query. It says: the field `status` equals the string `"open"`. It is also a
24
+ value you could have read from a file or taken out of a request body — and everything else in
25
+ this course follows from that one decision.
26
+
27
+ > Every example below is a complete program. Put it in a file next to `orders.mjs` and run it
28
+ > with `node`.
29
+
30
+ ## Asking the question
31
+
32
+ ```js
33
+ import { filter } from "@osqd/jql";
34
+ import { orders } from "./orders.mjs";
35
+
36
+ const open = filter(orders, { status: "open" });
37
+
38
+ for (const order of open) console.log(order.id, order.customer.name);
39
+ ```
40
+
41
+ ```
42
+ o-1002 Grace Hopper
43
+ o-1005 Tim Berners-Lee
44
+ ```
45
+
46
+ `filter` takes the collection and the query, and gives back every order that matches. There is
47
+ no callback anywhere — the shape of the question is entirely in that little object.
48
+
49
+ ## The query really is just data
50
+
51
+ Watch it survive a round trip through text, which is what happens when a filter is saved in a
52
+ database or arrives in a request:
53
+
54
+ ```js
55
+ import { count } from "@osqd/jql";
56
+ import { orders } from "./orders.mjs";
57
+
58
+ const query = { status: "open" };
59
+ const sent = JSON.stringify(query);
60
+ const arrived = JSON.parse(sent);
61
+
62
+ console.log("sent over the wire:", sent);
63
+ console.log("open orders, here :", count(orders, query));
64
+ console.log("open orders, there:", count(orders, arrived));
65
+ ```
66
+
67
+ ```
68
+ sent over the wire: {"status":"open"}
69
+ open orders, here : 2
70
+ open orders, there: 2
71
+ ```
72
+
73
+ Nothing was lost, because there was nothing to lose. This is the property the rest of the
74
+ library is built on: a saved filter, a URL parameter and a line of code are the same thing.
75
+
76
+ ## Two keys mean "and"
77
+
78
+ ```js
79
+ import { filter } from "@osqd/jql";
80
+ import { orders } from "./orders.mjs";
81
+
82
+ const britishAndPaid = filter(orders, {
83
+ status: "paid",
84
+ "customer.country": "GB",
85
+ });
86
+
87
+ console.log(britishAndPaid.map((order) => order.id));
88
+ ```
89
+
90
+ ```
91
+ [ 'o-1001', 'o-1003' ]
92
+ ```
93
+
94
+ Two things to notice.
95
+
96
+ **Both keys have to hold.** Listing them side by side means *and*. There is an explicit `$and`
97
+ for the times you need it, and lesson 4 covers when that is.
98
+
99
+ **`customer.country` is a path.** A dot reaches inside a nested object, so you can ask about
100
+ `customer.country` without pulling the customer out first. Lesson 3 is about what a path does
101
+ when it meets a *list*, which is the part worth slowing down for.
102
+
103
+ ## Four ways to ask
104
+
105
+ `filter` is one of a family. They all take the same query and differ only in what they hand
106
+ back:
107
+
108
+ ```js
109
+ import { count, find, filter, some } from "@osqd/jql";
110
+ import { orders } from "./orders.mjs";
111
+
112
+ const unpaid = { paid: 0 };
113
+
114
+ console.log("the first one :", find(orders, unpaid)?.id);
115
+ console.log("all of them :", filter(orders, unpaid).map((order) => order.id));
116
+ console.log("how many :", count(orders, unpaid));
117
+ console.log("are there any :", some(orders, unpaid));
118
+ ```
119
+
120
+ ```
121
+ the first one : o-1002
122
+ all of them : [ 'o-1002', 'o-1004', 'o-1005', 'o-1007' ]
123
+ how many : 4
124
+ are there any : true
125
+ ```
126
+
127
+ | | |
128
+ | --- | --- |
129
+ | `find` | the first match, or `undefined`. Stops looking at it |
130
+ | `filter` | every match, as a new array |
131
+ | `count` | how many, without building the list |
132
+ | `some` / `every` | whether any / all match. Both stop early |
133
+
134
+ There are more — `findIndex`, `partition`, `filterMap`, `filterRecord`, `findEntry` — and they
135
+ work over arrays, `Set`s, `Map`s, iterables and plain objects. The query never changes; only
136
+ the question about the collection does.
137
+
138
+ ## Compiling, when you ask the same thing often
139
+
140
+ Every helper turns the query into a predicate before it starts. If you are asking the same
141
+ question many times, do that once yourself:
142
+
143
+ ```js
144
+ import { compile } from "@osqd/jql";
145
+ import { orders } from "./orders.mjs";
146
+
147
+ const isPaid = compile({ status: "paid" });
148
+
149
+ console.log("the predicate is just a function:", typeof isPaid);
150
+ console.log("so anything that takes one works:", orders.filter(isPaid).length);
151
+ console.log("and it answers one item too :", isPaid(orders[0]));
152
+ ```
153
+
154
+ ```
155
+ the predicate is just a function: function
156
+ so anything that takes one works: 4
157
+ and it answers one item too : true
158
+ ```
159
+
160
+ `compile` is not something you need before you need it — a small query costs about as much to
161
+ compile as testing a handful of rows. It is for a hot loop, or for handing the predicate to
162
+ something that expects one.
163
+
164
+ ## A wrong query is refused
165
+
166
+ This is the one behaviour to take away from lesson 1:
167
+
168
+ ```js
169
+ import { filter, JqlError } from "@osqd/jql";
170
+ import { orders } from "./orders.mjs";
171
+
172
+ try {
173
+ filter(orders, { total: { $gtt: 100 } });
174
+ } catch (error) {
175
+ console.log("refused:", error instanceof JqlError);
176
+ console.log(error.message);
177
+ }
178
+ ```
179
+
180
+ ```
181
+ refused: true
182
+ at total.$gtt: "$gtt" is not an operator; did you mean "$gt"?
183
+ ```
184
+
185
+ `$gtt` is a typo. The query is **refused** — it does not quietly match nothing.
186
+
187
+ That matters more than it looks. A filter that silently matches nothing still returns a
188
+ result, the page still renders, and the number on it is wrong in a way no test notices.
189
+ Everything in this language that seems strict is protecting you from that one outcome, and
190
+ the error always says *where* in the query the problem is.
191
+
192
+ ## Exercise
193
+
194
+ Find the orders placed through the app that nobody has paid for. Two keys, no operators.
195
+
196
+ <details>
197
+ <summary>Answer</summary>
198
+
199
+ ```js
200
+ import { filter } from "@osqd/jql";
201
+ import { orders } from "./orders.mjs";
202
+
203
+ console.log(filter(orders, { channel: "app", paid: 0 }).map((order) => order.id));
204
+ ```
205
+
206
+ ```
207
+ [ 'o-1005' ]
208
+ ```
209
+
210
+ `o-1006` is on the app too, but it has been paid, so `paid: 0` leaves it out.
211
+ </details>
212
+
213
+ ## Related
214
+
215
+ - [Quick start](../start/quick-start.md) — the same ground in five minutes
216
+ - [Library API](../reference/api.md) — every helper, and what each takes
217
+ - [Specification §2](../reference/specification.md#2-queries) — what a query document is
@@ -0,0 +1,285 @@
1
+ # Lesson 2 — Asking precisely
2
+
3
+ **Goal:** the field operators, and which one to reach for.
4
+
5
+ ← [Course](index.md) · Previous: [Your first query](01-first-query.md) · Next: [Arrays and paths](03-arrays-and-paths.md)
6
+
7
+ ---
8
+
9
+ ## Beyond equality
10
+
11
+ `{ status: "open" }` asks whether a value *equals* something. For everything else, put an
12
+ object in place of the value and fill it with **operators**:
13
+
14
+ ```json
15
+ { "total": { "$gte": 30 } }
16
+ ```
17
+
18
+ An operator is one question about the value the path reached. Several operators in one object
19
+ all have to hold, which is how you write a range:
20
+
21
+ ```json
22
+ { "total": { "$gte": 30, "$lt": 140 } }
23
+ ```
24
+
25
+ The names are the conventional ones — `$gte`, `$in`, `$exists` — so most of this lesson is
26
+ recognition rather than learning. Read it for the three or four places where the answer is
27
+ not the one you expected.
28
+
29
+ > As in lesson 1, every example is a complete program to run next to `orders.mjs`.
30
+
31
+ ## Comparing
32
+
33
+ ```js
34
+ import { filter } from "@osqd/jql";
35
+ import { orders } from "./orders.mjs";
36
+
37
+ const ids = (query) => filter(orders, query).map((order) => order.id);
38
+
39
+ console.log("at least 30 ", ids({ total: { $gte: 30 } }));
40
+ console.log("30 up to 140 ", ids({ total: { $gte: 30, $lt: 140 } }));
41
+ console.log("under 20 ", ids({ total: { $lt: 20 } }));
42
+ ```
43
+
44
+ ```
45
+ at least 30 [ 'o-1002', 'o-1004', 'o-1005', 'o-1006', 'o-1007' ]
46
+ 30 up to 140 [ 'o-1002', 'o-1004', 'o-1006' ]
47
+ under 20 [ 'o-1003', 'o-1008' ]
48
+ ```
49
+
50
+ `$gt`, `$gte`, `$lt` and `$lte` compare **like with like only**. A number is compared with
51
+ numbers and a string with strings; `"10"` is never greater than `9`, and asking is not an
52
+ error, it simply does not match. A comparison that quietly converted would give a different
53
+ answer depending on which side happened to be text, and a filter that means different things
54
+ on different rows is one nobody can reason about.
55
+
56
+ ## Membership
57
+
58
+ ```js
59
+ import { filter } from "@osqd/jql";
60
+ import { orders } from "./orders.mjs";
61
+
62
+ const ids = (query) => filter(orders, query).map((order) => order.id);
63
+
64
+ console.log("open or cancelled", ids({ status: { $in: ["open", "cancelled"] } }));
65
+ console.log("anything but paid", ids({ status: { $nin: ["paid"] } }));
66
+ ```
67
+
68
+ ```
69
+ open or cancelled [ 'o-1002', 'o-1005', 'o-1007' ]
70
+ anything but paid [ 'o-1002', 'o-1004', 'o-1005', 'o-1007' ]
71
+ ```
72
+
73
+ `$in` is *any of these*, `$nin` is *none of these*. Both take a plain list, so a set of
74
+ checkboxes in a user interface turns into one operator rather than a chain of `$or`.
75
+
76
+ ## Missing, null, and neither
77
+
78
+ This is the part of the lesson worth slowing down for. Three of the eight orders have no
79
+ `note` field at all; `o-1007` has one, and its value is `null`.
80
+
81
+ ```js
82
+ import { filter } from "@osqd/jql";
83
+ import { orders } from "./orders.mjs";
84
+
85
+ const ids = (query) => filter(orders, query).map((order) => order.id);
86
+
87
+ console.log("the field is there ", ids({ note: { $exists: true } }));
88
+ console.log("the field is absent", ids({ note: { $exists: false } }));
89
+ console.log("note is null ", ids({ note: null }));
90
+ console.log("note is text ", ids({ note: { $type: "string" } }));
91
+ ```
92
+
93
+ ```
94
+ the field is there [ 'o-1001', 'o-1003', 'o-1004', 'o-1006', 'o-1007' ]
95
+ the field is absent [ 'o-1002', 'o-1005', 'o-1008' ]
96
+ note is null [ 'o-1002', 'o-1005', 'o-1007', 'o-1008' ]
97
+ note is text [ 'o-1001', 'o-1003', 'o-1004', 'o-1006' ]
98
+ ```
99
+
100
+ Compare the second and third lines carefully:
101
+
102
+ | The query | What it asks |
103
+ | --- | --- |
104
+ | `{ note: { $exists: true } }` | the field is present — **including `o-1007`, whose note is `null`** |
105
+ | `{ note: { $exists: false } }` | the field is not present at all |
106
+ | `{ note: null }` | the value is `null` **or the field is missing** — both |
107
+ | `{ note: { $type: "string" } }` | present, and text |
108
+
109
+ `{ field: null }` deliberately covers both cases, because "has no value" is what people mean
110
+ by it far more often than "holds the value null". When the difference does matter — an
111
+ optional field that was explicitly cleared versus one never set — `$exists` is the operator
112
+ that can tell them apart.
113
+
114
+ ## Text, without a pattern
115
+
116
+ ```js
117
+ import { filter } from "@osqd/jql";
118
+ import { orders } from "./orders.mjs";
119
+
120
+ const ids = (query) => filter(orders, query).map((order) => order.id);
121
+
122
+ console.log("name contains Ada ", ids({ "customer.name": { $contains: "Ada" } }));
123
+ console.log("email starts ada ", ids({ "customer.email": { $startsWith: "ada" } }));
124
+ console.log("id ends 1 or 8 ", ids({ id: { $endsWith: ["1", "8"] } }));
125
+ ```
126
+
127
+ ```
128
+ name contains Ada [ 'o-1001', 'o-1008' ]
129
+ email starts ada [ 'o-1001', 'o-1008' ]
130
+ id ends 1 or 8 [ 'o-1001', 'o-1008' ]
131
+ ```
132
+
133
+ These four — `$contains`, `$startsWith`, `$endsWith` and `$word` — need no pattern and run in
134
+ linear time, which is why they stay available when `$regex` is turned off for queries arriving
135
+ from outside (lesson 12).
136
+
137
+ Each of them takes **one string or a list meaning *any of these***. `$endsWith: ["1", "8"]`
138
+ is one thought, not three ORed together.
139
+
140
+ ### Ignoring case
141
+
142
+ ```js
143
+ import { filter } from "@osqd/jql";
144
+ import { orders } from "./orders.mjs";
145
+
146
+ const ids = (query) => filter(orders, query).map((order) => order.id);
147
+
148
+ console.log("exactly as typed", ids({ "customer.name": { $contains: "ada" } }));
149
+ console.log("ignoring case ", ids({ "customer.name": { $contains: "ada", $options: "i" } }));
150
+ ```
151
+
152
+ ```
153
+ exactly as typed []
154
+ ignoring case [ 'o-1001', 'o-1008' ]
155
+ ```
156
+
157
+ `$options: "i"` applies to **every string comparison in that condition**, not only to a
158
+ pattern. Put it beside `$contains`, `$eq`, `$in` — anywhere a condition compares text — and
159
+ that whole condition stops caring about case.
160
+
161
+ ### Why `$word` exists
162
+
163
+ `$contains` is the wrong tool for anything with separators in it, and the way it is wrong is
164
+ quiet:
165
+
166
+ ```js
167
+ import { filter } from "@osqd/jql";
168
+
169
+ const hits = [{ actor: "203.0.113.4" }, { actor: "203.0.113.45" }, { actor: "10.203.0.113" }];
170
+ const seen = (query) => filter(hits, query).map((hit) => hit.actor);
171
+
172
+ console.log("$contains the address", seen({ actor: { $contains: "203.0.113.4" } }));
173
+ console.log("$word the address ", seen({ actor: { $word: "203.0.113.4" } }));
174
+ console.log("$word a whole network", seen({ actor: { $word: "203.0.113." } }));
175
+ ```
176
+
177
+ ```
178
+ $contains the address [ '203.0.113.4', '203.0.113.45' ]
179
+ $word the address [ '203.0.113.4' ]
180
+ $word a whole network [ '203.0.113.4', '203.0.113.45' ]
181
+ ```
182
+
183
+ A block rule written with `$contains` for one address caught its neighbour as well.
184
+
185
+ `$word` matches the value as a **whole component**: bounded by the start or end of the text,
186
+ or by any character that is not a letter or a digit. So `203.0.113.4` stops finding
187
+ `203.0.113.45`. A value that itself ends in a separator has chosen its own boundary, which is
188
+ why `203.0.113.` still finds the whole network — on purpose.
189
+
190
+ ## Patterns
191
+
192
+ ```js
193
+ import { filter } from "@osqd/jql";
194
+ import { orders } from "./orders.mjs";
195
+
196
+ const ids = (query) => filter(orders, query).map((order) => order.id);
197
+
198
+ console.log("glob, any ink ", ids({ "lines.sku": { $glob: "ink-*" } }));
199
+ console.log("glob, one char ", ids({ "lines.sku": { $glob: "pad-a?" } }));
200
+ console.log("a real regex ", ids({ id: { $regex: "0{2}[13]$" } }));
201
+ ```
202
+
203
+ ```
204
+ glob, any ink [ 'o-1001', 'o-1004', 'o-1008' ]
205
+ glob, one char [ 'o-1002', 'o-1005', 'o-1006', 'o-1008' ]
206
+ a real regex [ 'o-1001', 'o-1003' ]
207
+ ```
208
+
209
+ `lines.sku` is a path through a *list* of order lines, and it matched an order when any
210
+ of its lines did. That is lesson 3's subject, and it is the one rule here worth meeting
211
+ slowly.
212
+
213
+ `$glob` is the pattern people usually mean: `*` for any run of characters, `?` for exactly
214
+ one, `\` to escape either. It has no engine behind it and cannot backtrack, so it is safe to
215
+ accept from a stranger. `$regex` is a full ECMAScript pattern and is the one operator you will
216
+ want to switch off at the edge of your system.
217
+
218
+ ## Numbers and sizes
219
+
220
+ ```js
221
+ import { filter } from "@osqd/jql";
222
+ import { orders } from "./orders.mjs";
223
+
224
+ const ids = (query) => filter(orders, query).map((order) => order.id);
225
+
226
+ console.log("even totals ", ids({ total: { $mod: [2, 0] } }));
227
+ console.log("a longish note ", ids({ note: { $length: { $gt: 18 } } }));
228
+ console.log("two order lines", ids({ lines: { $size: 2 } }));
229
+ ```
230
+
231
+ ```
232
+ even totals [ 'o-1002', 'o-1004', 'o-1005', 'o-1007', 'o-1008' ]
233
+ a longish note [ 'o-1001', 'o-1006' ]
234
+ two order lines [ 'o-1001', 'o-1004', 'o-1005', 'o-1006', 'o-1008' ]
235
+ ```
236
+
237
+ `$mod` takes `[divisor, remainder]`. `$size` counts the elements of an array. `$length`
238
+ measures an array **or a string**, and takes a nested condition, so "longer than 18
239
+ characters" is `{ $length: { $gt: 18 } }` rather than a second query.
240
+
241
+ ## The operators, in full
242
+
243
+ | | |
244
+ | --- | --- |
245
+ | `$eq` `$ne` | equal, and not equal to any reached value |
246
+ | `$gt` `$gte` `$lt` `$lte` | ordering, like with like only |
247
+ | `$in` `$nin` | membership of a list |
248
+ | `$exists` `$type` | presence, and what kind |
249
+ | `$contains` `$startsWith` `$endsWith` `$word` | text, without a pattern |
250
+ | `$glob` | `*`, `?`, `\` — a pattern with no engine behind it |
251
+ | `$regex` `$options` | an ECMAScript pattern, and its flags |
252
+ | `$mod` | `[divisor, remainder]` |
253
+ | `$size` `$length` | elements of an array; length of an array **or a string** |
254
+ | `$all` `$elemMatch` | lesson 3 |
255
+ | `$not` | lesson 4 |
256
+
257
+ ## Exercise
258
+
259
+ Find the orders whose note mentions a gift — but not the ones merely *tagged* `gift` —
260
+ without caring about case.
261
+
262
+ <details>
263
+ <summary>Answer</summary>
264
+
265
+ ```js
266
+ import { filter } from "@osqd/jql";
267
+ import { orders } from "./orders.mjs";
268
+
269
+ const found = filter(orders, { note: { $contains: "gift", $options: "i" } });
270
+
271
+ console.log(found.map((order) => `${order.id}: ${order.note}`));
272
+ ```
273
+
274
+ ```
275
+ [ 'o-1006: gift wrap, no receipt' ]
276
+ ```
277
+
278
+ `o-1001` is tagged `gift`, but its note says "leave with the neighbour" — and a condition on
279
+ `note` only ever reads `note`.
280
+ </details>
281
+
282
+ ## Related
283
+
284
+ - [Specification §5](../reference/specification.md#5-field-operators) — every operator, precisely
285
+ - [Queries from outside](../guides/untrusted-input.md) — why `$regex` is the one that gets turned off