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