@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,198 @@
|
|
|
1
|
+
# Decisions
|
|
2
|
+
|
|
3
|
+
The trades that shaped JQL, each with what it cost.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Each entry is **the decision / why / the cost**, so a future change can weigh what it is undoing.
|
|
10
|
+
|
|
11
|
+
## The language is JSON, not text
|
|
12
|
+
|
|
13
|
+
**The decision.** A query is a JSON document. The search-box syntax exists, but it compiles into
|
|
14
|
+
the JSON and has no semantics of its own.
|
|
15
|
+
|
|
16
|
+
**Why.** A query that is data can be stored, put in a URL, sent to another service, diffed,
|
|
17
|
+
generated by a program and validated by a schema. BotHandler's text filter could do the first
|
|
18
|
+
two and none of the rest, and every project that wanted filtering would have grown its own
|
|
19
|
+
text dialect. With one JSON form, the text is one way of writing it, and a filter typed into a
|
|
20
|
+
dashboard means exactly what the JSON a service receives means.
|
|
21
|
+
|
|
22
|
+
**The cost.** JSON is verbose to write by hand, which is why the text syntax is kept. And the
|
|
23
|
+
two forms can express different things: the text syntax cannot write `$elemMatch` or `$size`.
|
|
24
|
+
|
|
25
|
+
## The conventional operator names, not new ones
|
|
26
|
+
|
|
27
|
+
**The decision.** `$eq`, `$gt`, `$in`, `$exists`, `$elemMatch` and the rest keep the names and
|
|
28
|
+
the array behaviour that document-database query languages have used for years, rather than a
|
|
29
|
+
vocabulary of our own. Where this language departs from that convention it does so on purpose:
|
|
30
|
+
object equality ignores key order, ordering never coerces across types, `$options: "i"` reaches
|
|
31
|
+
every string comparison in its condition, and there are string operators that need no pattern
|
|
32
|
+
at all.
|
|
33
|
+
|
|
34
|
+
**Why.** A reader who has met one query document can read this one without the documentation
|
|
35
|
+
open, and the names carry their meaning with them. The departures are each a place where the
|
|
36
|
+
convention rests on something JSON does not carry — key order, a database's own type ordering —
|
|
37
|
+
or where the only tool it offers is a pattern, which is the one thing unsafe to accept from a
|
|
38
|
+
stranger.
|
|
39
|
+
|
|
40
|
+
**The cost.** Familiar names invite the assumption that every behaviour is familiar too, and
|
|
41
|
+
the places where it is not are exactly the subtle ones. That is why each is stated positively
|
|
42
|
+
in the specification where the operator is defined, rather than left as a difference the reader
|
|
43
|
+
has to know to look for.
|
|
44
|
+
|
|
45
|
+
## The engine refuses; the text parser never does
|
|
46
|
+
|
|
47
|
+
**The decision.** `compile` throws on anything it does not understand — an unknown operator, an
|
|
48
|
+
`undefined` value, a `RegExp` object, a flag it does not know. `parseText` never throws.
|
|
49
|
+
|
|
50
|
+
**Why.** A query that looks restrictive and is not is the failure this family of projects
|
|
51
|
+
exists to prevent: a misspelt `$gtt` that is ignored makes a filter that lets everything
|
|
52
|
+
through, and nothing about the result says so. JSON is written by a program that can be told
|
|
53
|
+
it is wrong. A search box is written by a person halfway through typing, for whom an exception
|
|
54
|
+
on every keystroke would be useless.
|
|
55
|
+
|
|
56
|
+
**The cost.** A service accepting JSON queries has to handle refusals — which it should, and
|
|
57
|
+
`validate` makes that one line.
|
|
58
|
+
|
|
59
|
+
## `undefined` is an error, not "no condition"
|
|
60
|
+
|
|
61
|
+
**The decision.** `{ id: user.id }` where `user.id` is `undefined` is refused.
|
|
62
|
+
|
|
63
|
+
**Why.** Dropping the key — what most JavaScript query libraries do, because `JSON.stringify`
|
|
64
|
+
would — turns a condition on nothing into a query that matches everything. It is the single
|
|
65
|
+
most common way a filter silently stops filtering.
|
|
66
|
+
|
|
67
|
+
**The cost.** Code that builds queries from optional values has to leave the key out
|
|
68
|
+
deliberately.
|
|
69
|
+
|
|
70
|
+
## Compiled to closures, not to generated code
|
|
71
|
+
|
|
72
|
+
**The decision.** A query compiles to a tree of small closures. There is no `new Function`.
|
|
73
|
+
|
|
74
|
+
**Why.** Generated code would be faster still. It would also be refused by any page with a
|
|
75
|
+
Content-Security-Policy worth having — including this project's own dashboards, whose build
|
|
76
|
+
fails on a `new Function` — and it would put a code generator on the path of every query that
|
|
77
|
+
arrives from outside.
|
|
78
|
+
|
|
79
|
+
**The cost.** Closures shared by every query make some call sites megamorphic, so a nested path
|
|
80
|
+
costs about 1.2× a hand-written loop rather than 1×. [Performance](performance.md) has the
|
|
81
|
+
numbers.
|
|
82
|
+
|
|
83
|
+
## No implicit cache
|
|
84
|
+
|
|
85
|
+
**The decision.** The helpers compile the query they are given on every call. There is no cache
|
|
86
|
+
keyed on the query object.
|
|
87
|
+
|
|
88
|
+
**Why.** A cache keyed on identity serves a stale predicate the moment somebody edits a query
|
|
89
|
+
object they have already used — a filter that looks updated and is not. A cache keyed on the
|
|
90
|
+
serialised query costs a `JSON.stringify` per call, which is most of what compiling costs.
|
|
91
|
+
Compiling a small query takes well under a microsecond.
|
|
92
|
+
|
|
93
|
+
**The cost.** Calling a helper with a fresh query literal in a tight loop over tiny arrays pays
|
|
94
|
+
for compiling each time. `compile` once is the answer, and the performance page says so.
|
|
95
|
+
|
|
96
|
+
## The array methods are opt-in
|
|
97
|
+
|
|
98
|
+
**The decision.** `Array.prototype.jqlSearch` and the rest exist only after
|
|
99
|
+
`import "@osqd/jql/global"`, and their types come from the same import.
|
|
100
|
+
|
|
101
|
+
**Why.** Patching a built-in is the application's decision, never a library's. A package that
|
|
102
|
+
depends on JQL must not change every array in the program that imports it. And if the types
|
|
103
|
+
arrived without the methods, the compiler would promise something that fails at run time.
|
|
104
|
+
|
|
105
|
+
**The cost.** One extra import line in each application that wants them.
|
|
106
|
+
|
|
107
|
+
## A `Map` is read by value
|
|
108
|
+
|
|
109
|
+
**The decision.** Every helper treats a `Map` as a collection of its values. `filterMap` and
|
|
110
|
+
`findEntry` keep the keys.
|
|
111
|
+
|
|
112
|
+
**Why.** "The user whose name is Ada" is a question about the users, not about the keys they are
|
|
113
|
+
filed under. Treating a `Map` as the `[key, value]` pairs its iterator yields would make every
|
|
114
|
+
query on one start with `"1."`.
|
|
115
|
+
|
|
116
|
+
**The cost.** `find(map, …)` does not say which key it found; `findEntry` does.
|
|
117
|
+
|
|
118
|
+
## Case-insensitive means lower-casing, not regex folding
|
|
119
|
+
|
|
120
|
+
**The decision.** "Ignoring case" is defined as comparing after Unicode default lower-case
|
|
121
|
+
mapping, with no locale.
|
|
122
|
+
|
|
123
|
+
**Why.** It is the definition every language has a function for, so another implementation can
|
|
124
|
+
match it exactly. A regex `i` flag folds case differently between engines and between modes of
|
|
125
|
+
one engine.
|
|
126
|
+
|
|
127
|
+
**The cost.** Lower-casing allocates a string for mixed-case input. A regex would not.
|
|
128
|
+
|
|
129
|
+
## A reference, not an expression language
|
|
130
|
+
|
|
131
|
+
**The decision.** Comparing one field with another is `{ "$field": "path" }` — a reference and
|
|
132
|
+
nothing else. There is no arithmetic, no function, no `$expr`.
|
|
133
|
+
|
|
134
|
+
**Why.** "Bytes out greater than bytes in" is the question people actually have, and it needs
|
|
135
|
+
a path rather than a language. An expression language would be the end of queries being data
|
|
136
|
+
you can reason about: it would need its own grammar, its own evaluation rules, its own limits
|
|
137
|
+
for untrusted callers, and every one of those is a place for a query to do something its
|
|
138
|
+
reader did not expect.
|
|
139
|
+
|
|
140
|
+
**The cost.** Questions that genuinely need arithmetic — "more than twice as many" — cannot be
|
|
141
|
+
asked. A computed field in a vocabulary answers them at the cost of being defined in code
|
|
142
|
+
rather than in the query.
|
|
143
|
+
|
|
144
|
+
## Relative dates resolve when the query compiles
|
|
145
|
+
|
|
146
|
+
**The decision.** `{ "$date": { "$ago": "1h" } }` is resolved once, against a clock the caller
|
|
147
|
+
may supply, not per comparison.
|
|
148
|
+
|
|
149
|
+
**Why.** A window that moved during a scan would judge two items a second apart against
|
|
150
|
+
different hours, and no result could be explained afterwards. Supplying the clock is also what
|
|
151
|
+
makes a relative filter testable without waiting an hour.
|
|
152
|
+
|
|
153
|
+
**The cost.** A compiled query keeps the instant it was compiled at, so a long-lived matcher
|
|
154
|
+
goes stale; moving the window means compiling again, which costs well under a microsecond. A
|
|
155
|
+
streaming rule that must always mean "the last five minutes" has to compile per batch, and the
|
|
156
|
+
documentation says so.
|
|
157
|
+
|
|
158
|
+
## Grouping is a function, not a request key
|
|
159
|
+
|
|
160
|
+
**The decision.** `group()` counts the matches by a field. The request envelope —
|
|
161
|
+
`where`, `sort`, `skip`, `limit`, `fields`, `omit` — does not gain a `group` key.
|
|
162
|
+
|
|
163
|
+
**Why.** Every other request returns items; a grouped one returns rows of a different shape,
|
|
164
|
+
and putting it in the envelope would mean every caller of `search` has to know which shape
|
|
165
|
+
came back. The counting itself was being rewritten in each dashboard, so that is what moved.
|
|
166
|
+
|
|
167
|
+
**The cost.** A dashboard asking for both a page and its groups makes two calls over the same
|
|
168
|
+
data. Both take a compiled predicate, so the query is written and compiled once.
|
|
169
|
+
|
|
170
|
+
## Added operators carry a prefix
|
|
171
|
+
|
|
172
|
+
**The decision.** A project may add operators, and their names must begin `$x` and a capital.
|
|
173
|
+
No standard name will ever start that way.
|
|
174
|
+
|
|
175
|
+
**Why.** A stored filter outlives the process that wrote it. `{ "$cidr": … }` in a saved
|
|
176
|
+
filter looks like JQL and is not, and the reader who finds out is the one whose engine refused
|
|
177
|
+
it. `$xCidr` says what it is on sight, and leaves the language free to add `$cidr` one day
|
|
178
|
+
without colliding.
|
|
179
|
+
|
|
180
|
+
**The cost.** The names are uglier, and a project that adds many of them is writing a dialect —
|
|
181
|
+
which the prefix at least makes obvious.
|
|
182
|
+
|
|
183
|
+
## Canonical, not equivalent
|
|
184
|
+
|
|
185
|
+
**The decision.** `canonical` rewrites a query into one shape per *spelling*; it does not
|
|
186
|
+
decide whether two queries are equivalent.
|
|
187
|
+
|
|
188
|
+
**Why.** Equivalence has no cheap answer — `{ "$gt": 3 }` and `{ "$gte": 4 }` agree on every
|
|
189
|
+
integer and differ at 3.5 — and an answer that is right most of the time is worse than none
|
|
190
|
+
for deduplicating the things people save.
|
|
191
|
+
|
|
192
|
+
**The cost.** Two saved filters that ask the same question in genuinely different ways are
|
|
193
|
+
still two filters. The documentation says exactly what equal and unequal fingerprints mean.
|
|
194
|
+
|
|
195
|
+
## Related
|
|
196
|
+
|
|
197
|
+
- [Performance](performance.md) — what the engine's own choices cost, measured
|
|
198
|
+
- [The specification](../reference/specification.md) — the language these decisions produced
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Performance
|
|
2
|
+
|
|
3
|
+
How fast JQL is, how that is measured, and what makes it so.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The numbers
|
|
10
|
+
|
|
11
|
+
Every figure is a **ratio** against the loop somebody would have written by hand for the same
|
|
12
|
+
answer, measured in the same process seconds apart. A number of milliseconds is a statement
|
|
13
|
+
about the machine that produced it; a ratio holds still across machines and moves only when the
|
|
14
|
+
engine does. From `npm run bench`, 100 000 documents, median of seven calibrated rounds:
|
|
15
|
+
|
|
16
|
+
| Case | JQL ÷ hand-written |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `{ id: … }` — find by id, walking everything | 1.02–1.11× |
|
|
19
|
+
| `{ age: { $gte, $lt }, active: true }` | 1.30–1.47× |
|
|
20
|
+
| `{ "address.city": … }` | 1.07–1.21× |
|
|
21
|
+
| `{ id: { $in: [100 ids] } }` against a hand-built `Set` | 0.96–1.03× |
|
|
22
|
+
| `{ tags: "billing" }` — a value in an array | 1.14–1.26× |
|
|
23
|
+
| `{ name: { $contains, $options: "i" } }` | 1.12–1.18× |
|
|
24
|
+
| `$or` of a nested path and a range | 1.07–1.27× |
|
|
25
|
+
| `{ name: { $glob: "Person 1*" } }` against a hand-written `startsWith` | 0.78–0.91× |
|
|
26
|
+
| `{ notes: { $length: { $gt: 20 } } }` | 1.79–2.36× |
|
|
27
|
+
| `{ visits: { $gt: { $field: "purchases" } } }` | 2.15–2.86× |
|
|
28
|
+
| `$text` over the whole document, against checking four known fields | 2.29–2.43× |
|
|
29
|
+
| `search` top 10 by a field, against filter-sort-slice | 0.39–0.51× |
|
|
30
|
+
|
|
31
|
+
`$text` is the one that stays above 2×, because it does more than the hand-written loop: it
|
|
32
|
+
walks every value in the document rather than the four fields the loop already knows about.
|
|
33
|
+
Give it `$fields`, or a vocabulary with `text` fields, and it reads only those.
|
|
34
|
+
|
|
35
|
+
A `{ "$field": … }` comparison will not reach parity either: it reads two fields per item and
|
|
36
|
+
compares values whose types neither side knew when the query compiled, where the hand-written
|
|
37
|
+
`p.visits > p.purchases` is one machine comparison. It is still a filter over a million items
|
|
38
|
+
in a few milliseconds.
|
|
39
|
+
|
|
40
|
+
A glob is *faster* than the loop it replaces, because a pattern is taken apart once into the
|
|
41
|
+
narrowest matcher that fits it — `startsWith` for `prefix*`, `includes` for `*middle*`, a run
|
|
42
|
+
of `indexOf` calls for the rest — and only a pattern holding `?` walks character by
|
|
43
|
+
character.
|
|
44
|
+
|
|
45
|
+
For comparison, the same queries through the two most used query-document matchers for
|
|
46
|
+
JavaScript, measured once on the same machine (sift 17.1.3, mingo 7.2.4), took 2× to 130× as
|
|
47
|
+
long as JQL. The widest gap is `$in`, where JQL looks each item up in a set rather than
|
|
48
|
+
comparing it with every value in the list. These are not in the benchmark suite, because the
|
|
49
|
+
guard measures JQL against the code it replaces, not against other libraries.
|
|
50
|
+
|
|
51
|
+
## What makes it fast
|
|
52
|
+
|
|
53
|
+
- **Everything that depends only on the query happens once**, in `compile`: validation, field
|
|
54
|
+
resolution, path splitting, a `Set` for `$in`, lower-casing for `$options: "i"`, compiling
|
|
55
|
+
patterns. What runs per document is only the part that depends on the document.
|
|
56
|
+
- **The headline case is written out.** `{ id: 2 }` compiles to one closure with the read, the
|
|
57
|
+
comparison and the array fallback inline — the loop you would have written.
|
|
58
|
+
- **One read per field.** `{ age: { $gte: 30, $lt: 50 } }` reads `age` once and compares twice.
|
|
59
|
+
- **Nested paths are read straight** — two property reads for `address.city` — and walked with
|
|
60
|
+
the fan-out machinery only when an array is actually in the way.
|
|
61
|
+
- **Cheapest first.** The parts of an `$and` are reordered so equality runs before a pattern
|
|
62
|
+
and a pattern before a text walk; the first to fail decides the answer.
|
|
63
|
+
- **Early exits everywhere.** `find`, `some`, `every` and an unsorted `search` stop as soon as
|
|
64
|
+
the answer is known, including on generators, which are not consumed past it.
|
|
65
|
+
- **A bounded heap for top-k.** `search` with a sort and a limit keeps only the best
|
|
66
|
+
`skip + limit` items: O(n log k), and memory for k.
|
|
67
|
+
- **Arrays are walked by index**, not through the iterator protocol, which allocates per step.
|
|
68
|
+
|
|
69
|
+
## Compile once in a hot loop
|
|
70
|
+
|
|
71
|
+
Every helper compiles the query it is given. Compiling a small query takes well under a
|
|
72
|
+
microsecond, which is nothing against a large array and everything against a tiny one:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// Compiles a thousand times.
|
|
76
|
+
for (const batch of batches) batch.jqlFilter({ status: "open" });
|
|
77
|
+
|
|
78
|
+
// Compiles once.
|
|
79
|
+
const open = compile<Order>({ status: "open" });
|
|
80
|
+
for (const batch of batches) batch.filter(open);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
There is deliberately no cache that would do this for you; [Decisions](decisions.md#no-implicit-cache)
|
|
84
|
+
says why.
|
|
85
|
+
|
|
86
|
+
## The guard
|
|
87
|
+
|
|
88
|
+
`npm run bench:guard` fails when a case's ratio passes its budget. The budgets sit at about
|
|
89
|
+
twice the top of each measured range: tight enough to catch a real regression, loose enough
|
|
90
|
+
that noise never fires them, because a guard that fires on noise gets raised until it guards
|
|
91
|
+
nothing. When one fails, either something got slower, or the budget is wrong for a change that
|
|
92
|
+
was worth making — in which case raise it and say why in the commit.
|
|
93
|
+
|
|
94
|
+
The differential test (`tests/differential.test.ts`) is the other half: every fast path above is
|
|
95
|
+
a second implementation of something the specification already says, so thousands of random
|
|
96
|
+
documents and queries are run through the engine and through a deliberately naive reference,
|
|
97
|
+
and the two must agree.
|
|
98
|
+
|
|
99
|
+
## Related
|
|
100
|
+
|
|
101
|
+
- [Decisions](decisions.md) — the closures-not-codegen and no-cache trades
|
|
102
|
+
- [Library API](../reference/api.md) — `compile`, `search`
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Adopting JQL in a project
|
|
2
|
+
|
|
3
|
+
How a project in this family moves its filtering onto JQL.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
JQL exists so that every project here filters data the same way: one language for a
|
|
10
|
+
dashboard's search box, a service's query parameter, a saved filter and an export. Moving a
|
|
11
|
+
project onto it has three parts.
|
|
12
|
+
|
|
13
|
+
## 1. Write the vocabulary
|
|
14
|
+
|
|
15
|
+
The vocabulary is the one place that says what the project's data is called. BotHandler's feed
|
|
16
|
+
filter, for instance, becomes:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const ENTRY_VOCABULARY = defineVocabulary<DashboardEntry>()({
|
|
20
|
+
fields: {
|
|
21
|
+
path: { aliases: ["url"] },
|
|
22
|
+
actor: { aliases: ["ip"], kind: "word", get: (entry) => withLabel(entry.actor) },
|
|
23
|
+
ua: { path: "userAgent", aliases: ["useragent", "agent"] },
|
|
24
|
+
verdict: { kind: "exact", values: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"] },
|
|
25
|
+
class: { path: "botClass", aliases: ["botclass"] },
|
|
26
|
+
action: {},
|
|
27
|
+
outcome: { get: outcome, kind: "exact", values: ["allow", "mitigate", "deny", "pending"] },
|
|
28
|
+
rule: {},
|
|
29
|
+
detector: { get: (entry) => entry.evidence.map((item) => item.detector) },
|
|
30
|
+
identity: {},
|
|
31
|
+
method: { kind: "exact" },
|
|
32
|
+
certain: { kind: "boolean" },
|
|
33
|
+
bypass: {},
|
|
34
|
+
id: { path: "requestId", aliases: ["request"], kind: "word" },
|
|
35
|
+
score: { kind: "number" },
|
|
36
|
+
},
|
|
37
|
+
text: ["method", "path", "actor", "ua", "verdict", "class", "identity", "action", "rule", "id"],
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Computed fields (`outcome`, `detector`, an actor's name given after the fact) are `get`
|
|
42
|
+
functions, looked up when the query runs — which is what lets a name given to an actor reach
|
|
43
|
+
requests that arrived before it was given.
|
|
44
|
+
|
|
45
|
+
## 2. Swap the matcher
|
|
46
|
+
|
|
47
|
+
Where the project parsed text and matched it itself:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const filter = parseFilter(input);
|
|
51
|
+
rows.filter((row) => matches(filter, row, searchableText(row), labelOf(row.actor)));
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
it now parses into JQL and compiles:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const test = compile(parseText(input, { vocabulary: ENTRY_VOCABULARY }), { vocabulary: ENTRY_VOCABULARY });
|
|
58
|
+
rows.filter(test);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The text syntax is BotHandler's, and `tests/text.test.ts` in this repository is BotHandler's
|
|
62
|
+
feed-search suite run through JQL, so the answers are the same.
|
|
63
|
+
|
|
64
|
+
## 3. Take JSON wherever text was the only option
|
|
65
|
+
|
|
66
|
+
Anything that accepted a filter as text — an export endpoint, a saved filter, a URL — can now
|
|
67
|
+
also accept the JSON document. Store the JSON rather than the text when you can: the text is
|
|
68
|
+
for people typing, the JSON is what the query *is*, and a stored JSON query survives a change
|
|
69
|
+
to the text syntax.
|
|
70
|
+
|
|
71
|
+
## What to test
|
|
72
|
+
|
|
73
|
+
- Port the project's own filter cases and run them through `compile(parseText(…))`.
|
|
74
|
+
- Keep a case per computed field, because those are the ones the project wrote itself.
|
|
75
|
+
- If the project has a service that receives queries, test that an invalid one is answered
|
|
76
|
+
with a 400 and the refusal's sentence, not a 500.
|
|
77
|
+
|
|
78
|
+
## Related
|
|
79
|
+
|
|
80
|
+
- [Text syntax](../reference/text-syntax.md) — what people can type
|
|
81
|
+
- [TypeScript](typescript.md#vocabularies) — vocabularies in full
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Pushing a query into a store
|
|
2
|
+
|
|
3
|
+
Letting a backend answer the part of a query it can, and filtering the rest here.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
A store can usually answer part of a question — a field it has an index on, an operator it
|
|
10
|
+
has natively — and nothing of the rest. `plan` splits a query along that line:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { plan, filter } from "@osqd/jql";
|
|
14
|
+
|
|
15
|
+
const split = plan(query, capabilities);
|
|
16
|
+
const rows = await store.find(split.pushed ?? {}); // the store answers what it can
|
|
17
|
+
const answer = split.remaining === undefined ? rows : filter(rows, split.remaining);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## The contract
|
|
21
|
+
|
|
22
|
+
> `pushed` ∧ `remaining` ≡ the query
|
|
23
|
+
|
|
24
|
+
That is the whole feature, and it is why the split happens only on **conjunctions**. A
|
|
25
|
+
query's keys all have to hold, and so do the parts of an `$and`, so each one can go to
|
|
26
|
+
either side independently. Anything else — a branch of an `$or`, one operator inside a
|
|
27
|
+
condition — cannot be split without changing what is asked, so a conjunct is pushed whole or
|
|
28
|
+
kept whole.
|
|
29
|
+
|
|
30
|
+
Two consequences worth knowing:
|
|
31
|
+
|
|
32
|
+
- **The pushed half is never narrower than the query.** A caller that ran only `pushed` would
|
|
33
|
+
get too many rows, never too few. Too many can be filtered down; too few cannot be
|
|
34
|
+
recovered.
|
|
35
|
+
- **`complete` says when the second pass can be skipped**, and it is the only thing worth
|
|
36
|
+
checking before doing so.
|
|
37
|
+
|
|
38
|
+
`kept` says, per clause, why it stayed — "the store cannot filter on `total`", "the store
|
|
39
|
+
cannot answer a negation". That is what to print when somebody asks why a query is slow.
|
|
40
|
+
|
|
41
|
+
## Capabilities
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const capabilities = {
|
|
45
|
+
fields: ["status", "customer.country"], // or "all"
|
|
46
|
+
operators: ["$eq", "$in", "$gt", "$lt"], // JQL's names, or "all"
|
|
47
|
+
or: true, // default false
|
|
48
|
+
not: false, // default false
|
|
49
|
+
// A last word, for a store whose limits depend on the values rather than the operators.
|
|
50
|
+
accepts: (field, condition) => !("$regex" in condition),
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Fields are named as **the store** names them: a vocabulary alias is resolved to its path
|
|
55
|
+
first. A vocabulary field that is *computed* is never pushed whatever the capabilities say —
|
|
56
|
+
the store has no way to run a function that lives here.
|
|
57
|
+
|
|
58
|
+
**What is pushed stands on its own.** Vocabulary names are rewritten to the paths the store
|
|
59
|
+
knows, and relative dates are resolved to instants, so a target needs no vocabulary and both
|
|
60
|
+
halves of the split are judged against the same moment. What stays here keeps the names it
|
|
61
|
+
was written with, because this engine understands them.
|
|
62
|
+
|
|
63
|
+
## MongoDB, the first target
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { plan } from "@osqd/jql";
|
|
67
|
+
import { MONGO_CAPABILITIES, toMongoFilter } from "@osqd/jql/mongo";
|
|
68
|
+
|
|
69
|
+
const split = plan(query, MONGO_CAPABILITIES);
|
|
70
|
+
const filter = toMongoFilter(split.pushed);
|
|
71
|
+
const rows = await collection.find(filter).toArray();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The operator names line up for the most part, so most of the translation is a copy. The
|
|
75
|
+
interesting part is the handful that do not:
|
|
76
|
+
|
|
77
|
+
| JQL | MongoDB |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `$contains`, `$startsWith`, `$endsWith`, `$glob` | an escaped, anchored `RegExp` |
|
|
80
|
+
| `$eq`/`$in` with `$options: "i"` | an anchored case-insensitive `RegExp` |
|
|
81
|
+
| `{ "$date": … }`, including `{ "$ago": … }` | a `Date` |
|
|
82
|
+
| `$type: "number"` | `$type: ["double", "int", "long", "decimal"]` |
|
|
83
|
+
| `$not` over a query | `$nor: [ … ]` |
|
|
84
|
+
| `$word`, `$length`, `{ "$field": … }`, `$text` | **not in `MONGO_CAPABILITIES`** |
|
|
85
|
+
|
|
86
|
+
Some of MongoDB's own operators answer exactly for some values and not for others, which a
|
|
87
|
+
list of names cannot say — so the capabilities carry an `accepts` function as well, and it
|
|
88
|
+
keeps back: equality against an embedded document (MongoDB compares its keys in order, JQL
|
|
89
|
+
does not), the case-insensitive form of any comparison whose operand is not a single string
|
|
90
|
+
(a pattern stands in for one string and nothing deeper), `$type: "integer"` (JQL means "a
|
|
91
|
+
number with no fractional part", MongoDB means the `int` storage type), `$type: "bigint"` (a
|
|
92
|
+
run-time type JSON cannot carry, where a driver hands back an ordinary number for a stored
|
|
93
|
+
`long`), and `$regex` carrying a flag MongoDB does not take.
|
|
94
|
+
|
|
95
|
+
One difference is not about operators at all, and no capability can express it: **an array
|
|
96
|
+
held directly inside another array**. A path segment in MongoDB applies to an array's
|
|
97
|
+
elements but not to the elements of *those* arrays, while JQL sees an array through at every
|
|
98
|
+
level (specification §3). For such a document the two answer differently, in either
|
|
99
|
+
direction, and `plan` cannot know — the shape is in the data, not in the query. If a
|
|
100
|
+
collection stores arrays of arrays of documents, do not treat `complete` as permission to
|
|
101
|
+
skip the second pass over those fields.
|
|
102
|
+
|
|
103
|
+
The last row is the point. Those four have no exact MongoDB filter, so they are not in the
|
|
104
|
+
capabilities, so `plan` keeps them here and `toMongoFilter` never sees them. A translation
|
|
105
|
+
that was *nearly* right would be the worst of both: fewer rows than the query asked for, from
|
|
106
|
+
a store that looked like it had answered.
|
|
107
|
+
|
|
108
|
+
## What a target is promising
|
|
109
|
+
|
|
110
|
+
`pushed ∧ remaining ≡ query` holds only if the store answers `pushed` **the way JQL would**.
|
|
111
|
+
That is an assumption about the store, not something this library can check, and it is where
|
|
112
|
+
a target goes quietly wrong.
|
|
113
|
+
|
|
114
|
+
This target is the easy case, because so many of the operators mean the same thing on both
|
|
115
|
+
sides: the translation was checked against an independent implementation of that store's
|
|
116
|
+
matching rules over 100 000 comparisons on generated queries — the string operators, globs, case-insensitive equality
|
|
117
|
+
and membership, `$not`, and the type names — with no disagreement.
|
|
118
|
+
|
|
119
|
+
Two places to be careful in any target, found while doing that:
|
|
120
|
+
|
|
121
|
+
- **Coercion.** JQL compares like with like: `$mod` against a boolean or a `null` matches
|
|
122
|
+
nothing. A store that reads `false` as `0` will answer a pushed `$mod` with rows JQL would
|
|
123
|
+
not have returned. If a store coerces, leave the operator out of its capabilities.
|
|
124
|
+
- **Missing values under a path that crosses an array.** JQL says an empty array reaches
|
|
125
|
+
*missing*, so `{ "items.sku": null }` matches a document whose `items` is `[]`. Stores
|
|
126
|
+
differ here, and the difference is invisible until somebody's page is short a row.
|
|
127
|
+
|
|
128
|
+
Where a store's answer might differ at all, the operator does not belong in its
|
|
129
|
+
capabilities. A clause kept here costs a second pass; a clause pushed to a store that reads
|
|
130
|
+
it differently costs the right answer.
|
|
131
|
+
|
|
132
|
+
## Writing another target
|
|
133
|
+
|
|
134
|
+
1. Write the capabilities: the fields and operators it can answer **exactly**, with the
|
|
135
|
+
semantics above. Leave out anything you would have to approximate, and anything whose
|
|
136
|
+
behaviour on an odd value — a boolean where a number is expected, a missing field under an
|
|
137
|
+
array — you have not checked.
|
|
138
|
+
2. Write the translation for those operators, and throw for anything else — it should never
|
|
139
|
+
arrive, and if it does you want to know.
|
|
140
|
+
3. Test the contract on data: for a spread of queries and capability sets, the store's filter
|
|
141
|
+
and the remaining predicate together must match exactly what the whole query matches.
|
|
142
|
+
`tests/plan.test.ts` does this and is worth copying.
|
|
143
|
+
|
|
144
|
+
## Related
|
|
145
|
+
|
|
146
|
+
- [Library API](../reference/api.md) — `plan`, and the collection helpers for the second pass
|
|
147
|
+
- [The specification](../reference/specification.md#14-splitting-a-query) — the rule that makes the split safe
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# TypeScript
|
|
2
|
+
|
|
3
|
+
Typed queries, the array methods, and vocabularies.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Typed queries
|
|
10
|
+
|
|
11
|
+
`Query<T>` knows the shape of `T`. Paths are checked, values are checked, and an operator is
|
|
12
|
+
offered only where it makes sense:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import type { Query } from "@osqd/jql";
|
|
16
|
+
|
|
17
|
+
interface User {
|
|
18
|
+
id: number;
|
|
19
|
+
name: string;
|
|
20
|
+
joined: Date;
|
|
21
|
+
roles: ("admin" | "editor")[];
|
|
22
|
+
address: { city: string };
|
|
23
|
+
sessions: { device: string; minutes: number }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ok: Query<User> = {
|
|
27
|
+
"address.city": "London", // paths into objects
|
|
28
|
+
"sessions.device": "mobile", // paths through arrays
|
|
29
|
+
roles: "admin", // a value matches any element
|
|
30
|
+
joined: { $gte: { $date: "2026-01-01" } }, // dates, the JSON way
|
|
31
|
+
sessions: { $elemMatch: { minutes: { $gt: 30 } } },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const wrong: Query<User> = {
|
|
35
|
+
nme: "Ada", // error: no such field
|
|
36
|
+
id: "1", // error: id is a number
|
|
37
|
+
"address.cty": "London", // error: no such path
|
|
38
|
+
name: { $size: 3 }, // error: $size is for arrays
|
|
39
|
+
roles: "owner", // error: not a role
|
|
40
|
+
};
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Every one of those errors is a query that would otherwise have compiled, run, and silently
|
|
44
|
+
matched nothing. Paths are enumerated five levels deep; deeper ones still work at run time but
|
|
45
|
+
are not offered.
|
|
46
|
+
|
|
47
|
+
For data whose shape is not known — `unknown`, `any`, parsed JSON — `Query<unknown>` accepts any
|
|
48
|
+
field. The engine still validates the query itself when it compiles it.
|
|
49
|
+
|
|
50
|
+
## The array methods infer the type
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import "@osqd/jql/global";
|
|
54
|
+
|
|
55
|
+
users.jqlSearch({ "address.city": "London" }); // User | undefined
|
|
56
|
+
Array.jqlSearch(users, { id: 1 }); // the same, from any iterable
|
|
57
|
+
users.jqlSearch({ missing: 1 }); // error: User has no field `missing`
|
|
58
|
+
[1, 2, 3].jqlFilter({ $gt: 1 }); // a condition on the number itself
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The methods and their types come from one import, `@osqd/jql/global`, on purpose: if the main
|
|
62
|
+
entry added `jqlSearch` to the type of every array without adding it to every array, the
|
|
63
|
+
compiler would promise a method that fails at run time.
|
|
64
|
+
|
|
65
|
+
## Queries from outside the types
|
|
66
|
+
|
|
67
|
+
A query typed into a search box or read from a URL has no static type. `parseText` returns an
|
|
68
|
+
`UntypedQuery`, and `untyped(json)` marks any other one, so it can be passed where a typed
|
|
69
|
+
query is expected:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { untyped } from "@osqd/jql";
|
|
73
|
+
import { parseText } from "@osqd/jql/text";
|
|
74
|
+
|
|
75
|
+
users.jqlFilter(parseText(searchBox.value));
|
|
76
|
+
users.jqlFilter(untyped(JSON.parse(request.body)));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It is a brand rather than a loose object type because a loose type would also accept every
|
|
80
|
+
object literal, and `users.jqlSearch({ nme: "Ada" })` would stop being an error. What checks an
|
|
81
|
+
untyped query is the engine, when it compiles it — see
|
|
82
|
+
[Queries from outside](untrusted-input.md).
|
|
83
|
+
|
|
84
|
+
## Vocabularies
|
|
85
|
+
|
|
86
|
+
A vocabulary gives a collection the names people use, and fields that are computed rather than
|
|
87
|
+
stored:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { defineVocabulary, type Query } from "@osqd/jql";
|
|
91
|
+
|
|
92
|
+
const vocabulary = defineVocabulary<User>()({
|
|
93
|
+
fields: {
|
|
94
|
+
city: { path: "address.city", kind: "exact" },
|
|
95
|
+
minutes: { get: (user) => user.sessions.reduce((sum, s) => sum + s.minutes, 0), kind: "number" },
|
|
96
|
+
},
|
|
97
|
+
text: ["city"],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
users.jqlFilter({ city: "London", minutes: { $gt: 60 } }, { vocabulary });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The field names type-check: `defineVocabulary` is curried so that `T` is given and the fields
|
|
104
|
+
are inferred, and passing the vocabulary in the options adds its names to what the query may
|
|
105
|
+
use, with the type of each computed field. Aliases are for the text syntax and untyped queries;
|
|
106
|
+
the types know the fields.
|
|
107
|
+
|
|
108
|
+
The same vocabulary drives the [text syntax](../reference/text-syntax.md#field-kinds), which is
|
|
109
|
+
the reason it exists: one list of names for the search box and for the JSON, so the two cannot
|
|
110
|
+
drift apart.
|
|
111
|
+
|
|
112
|
+
## Related
|
|
113
|
+
|
|
114
|
+
- [Library API](../reference/api.md) — every export
|
|
115
|
+
- [The specification](../reference/specification.md) — what the queries mean
|