@lovrozagar/oat 0.10.0 → 0.11.0

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 (278) hide show
  1. package/README.md +25 -2006
  2. package/dist/cli.js +283 -59
  3. package/dist/config/define-config.d.ts +8 -0
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/validate.d.ts +12 -0
  7. package/dist/config/validate.d.ts.map +1 -0
  8. package/dist/config/validate.js +86 -0
  9. package/dist/config/validate.js.map +1 -0
  10. package/dist/conformance/effects.d.ts +9 -0
  11. package/dist/conformance/effects.d.ts.map +1 -0
  12. package/dist/conformance/effects.js +276 -0
  13. package/dist/conformance/effects.js.map +1 -0
  14. package/dist/conformance/expected-failures.d.ts +18 -0
  15. package/dist/conformance/expected-failures.d.ts.map +1 -0
  16. package/dist/conformance/expected-failures.js +48 -0
  17. package/dist/conformance/expected-failures.js.map +1 -0
  18. package/dist/conformance/feature-gate.d.ts +7 -0
  19. package/dist/conformance/feature-gate.d.ts.map +1 -0
  20. package/dist/conformance/feature-gate.js +428 -0
  21. package/dist/conformance/feature-gate.js.map +1 -0
  22. package/dist/conformance/fuzz.d.ts +108 -0
  23. package/dist/conformance/fuzz.d.ts.map +1 -0
  24. package/dist/conformance/fuzz.js +246 -0
  25. package/dist/conformance/fuzz.js.map +1 -0
  26. package/dist/conformance/leaks.d.ts +10 -0
  27. package/dist/conformance/leaks.d.ts.map +1 -0
  28. package/dist/conformance/leaks.js +23 -0
  29. package/dist/conformance/leaks.js.map +1 -0
  30. package/dist/conformance/pool.d.ts +49 -0
  31. package/dist/conformance/pool.d.ts.map +1 -0
  32. package/dist/conformance/pool.js +75 -0
  33. package/dist/conformance/pool.js.map +1 -0
  34. package/dist/conformance/rate-limit.d.ts +7 -0
  35. package/dist/conformance/rate-limit.d.ts.map +1 -0
  36. package/dist/conformance/rate-limit.js +372 -0
  37. package/dist/conformance/rate-limit.js.map +1 -0
  38. package/dist/conformance/scope.d.ts +15 -0
  39. package/dist/conformance/scope.d.ts.map +1 -0
  40. package/dist/conformance/scope.js +78 -0
  41. package/dist/conformance/scope.js.map +1 -0
  42. package/dist/conformance/seed-contract.d.ts +11 -0
  43. package/dist/conformance/seed-contract.d.ts.map +1 -0
  44. package/dist/conformance/seed-contract.js +680 -0
  45. package/dist/conformance/seed-contract.js.map +1 -0
  46. package/dist/conformance/shapes.d.ts +35 -0
  47. package/dist/conformance/shapes.d.ts.map +1 -0
  48. package/dist/conformance/shapes.js +162 -0
  49. package/dist/conformance/shapes.js.map +1 -0
  50. package/dist/conformance/specs.d.ts +21 -0
  51. package/dist/conformance/specs.d.ts.map +1 -0
  52. package/dist/conformance/specs.js +306 -0
  53. package/dist/conformance/specs.js.map +1 -0
  54. package/dist/conformance/suite.d.ts +209 -0
  55. package/dist/conformance/suite.d.ts.map +1 -0
  56. package/dist/conformance/suite.js +1538 -0
  57. package/dist/conformance/suite.js.map +1 -0
  58. package/dist/conformance/unique.d.ts +7 -0
  59. package/dist/conformance/unique.d.ts.map +1 -0
  60. package/dist/conformance/unique.js +586 -0
  61. package/dist/conformance/unique.js.map +1 -0
  62. package/dist/conformance/worker.d.ts +3 -0
  63. package/dist/conformance/worker.d.ts.map +1 -0
  64. package/dist/conformance/worker.js +27 -0
  65. package/dist/conformance/worker.js.map +1 -0
  66. package/dist/index.d.ts +8 -3
  67. package/dist/index.js +5 -2
  68. package/dist/reference/defects.d.ts +71 -1
  69. package/dist/reference/defects.d.ts.map +1 -1
  70. package/dist/reference/defects.js +74 -8
  71. package/dist/reference/defects.js.map +1 -1
  72. package/dist/reference/dialect.d.ts +6 -4
  73. package/dist/reference/dialect.d.ts.map +1 -1
  74. package/dist/reference/dialect.js +1 -0
  75. package/dist/reference/dialect.js.map +1 -1
  76. package/dist/reference/http.d.ts +17 -25
  77. package/dist/reference/http.d.ts.map +1 -1
  78. package/dist/reference/http.js +574 -224
  79. package/dist/reference/http.js.map +1 -1
  80. package/dist/reference/listing.d.ts +51 -0
  81. package/dist/reference/listing.d.ts.map +1 -0
  82. package/dist/reference/listing.js +256 -0
  83. package/dist/reference/listing.js.map +1 -0
  84. package/dist/reference/model.d.ts +35 -1
  85. package/dist/reference/model.d.ts.map +1 -1
  86. package/dist/reference/model.js +42 -0
  87. package/dist/reference/model.js.map +1 -1
  88. package/dist/reference/query.d.ts +132 -43
  89. package/dist/reference/query.d.ts.map +1 -1
  90. package/dist/reference/query.js +397 -315
  91. package/dist/reference/query.js.map +1 -1
  92. package/dist/reference/shapes.d.ts +60 -0
  93. package/dist/reference/shapes.d.ts.map +1 -0
  94. package/dist/reference/shapes.js +142 -0
  95. package/dist/reference/shapes.js.map +1 -0
  96. package/dist/reference/spec.d.ts +9 -2
  97. package/dist/reference/spec.d.ts.map +1 -1
  98. package/dist/reference/spec.js +291 -71
  99. package/dist/reference/spec.js.map +1 -1
  100. package/dist/reference/store-api.d.ts +14 -43
  101. package/dist/reference/store-api.d.ts.map +1 -1
  102. package/dist/reference/store-api.js +8 -58
  103. package/dist/reference/store-api.js.map +1 -1
  104. package/dist/reference/stores/memory.d.ts +12 -15
  105. package/dist/reference/stores/memory.d.ts.map +1 -1
  106. package/dist/reference/stores/memory.js +22 -70
  107. package/dist/reference/stores/memory.js.map +1 -1
  108. package/dist/reference/stores/postgres.d.ts +11 -23
  109. package/dist/reference/stores/postgres.d.ts.map +1 -1
  110. package/dist/reference/stores/postgres.js +53 -334
  111. package/dist/reference/stores/postgres.js.map +1 -1
  112. package/dist/reference/stores/sql-compile.d.ts +36 -0
  113. package/dist/reference/stores/sql-compile.d.ts.map +1 -0
  114. package/dist/reference/stores/sql-compile.js +95 -0
  115. package/dist/reference/stores/sql-compile.js.map +1 -0
  116. package/dist/reference/stores/sqlite.d.ts +12 -27
  117. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  118. package/dist/reference/stores/sqlite.js +54 -355
  119. package/dist/reference/stores/sqlite.js.map +1 -1
  120. package/dist/report/console.d.ts.map +1 -1
  121. package/dist/report/console.js +23 -9
  122. package/dist/report/console.js.map +1 -1
  123. package/dist/report/matrix.d.ts +2 -0
  124. package/dist/report/matrix.d.ts.map +1 -1
  125. package/dist/report/matrix.js +63 -10
  126. package/dist/report/matrix.js.map +1 -1
  127. package/dist/report/render.d.ts +10 -1
  128. package/dist/report/render.d.ts.map +1 -1
  129. package/dist/report/render.js +75 -13
  130. package/dist/report/render.js.map +1 -1
  131. package/dist/runtime/async.d.ts +16 -3
  132. package/dist/runtime/async.d.ts.map +1 -1
  133. package/dist/runtime/async.js +24 -19
  134. package/dist/runtime/async.js.map +1 -1
  135. package/dist/runtime/auth.d.ts +2 -9
  136. package/dist/runtime/auth.d.ts.map +1 -1
  137. package/dist/runtime/auth.js +38 -62
  138. package/dist/runtime/auth.js.map +1 -1
  139. package/dist/runtime/body.d.ts +15 -3
  140. package/dist/runtime/body.d.ts.map +1 -1
  141. package/dist/runtime/body.js +61 -12
  142. package/dist/runtime/body.js.map +1 -1
  143. package/dist/runtime/check-costs.d.ts +15 -0
  144. package/dist/runtime/check-costs.d.ts.map +1 -0
  145. package/dist/runtime/check-costs.js +114 -0
  146. package/dist/runtime/check-costs.js.map +1 -0
  147. package/dist/runtime/check-ids.d.ts +15 -0
  148. package/dist/runtime/check-ids.d.ts.map +1 -0
  149. package/dist/runtime/check-ids.js +140 -0
  150. package/dist/runtime/check-ids.js.map +1 -0
  151. package/dist/runtime/checks.d.ts +83 -20
  152. package/dist/runtime/checks.d.ts.map +1 -1
  153. package/dist/runtime/checks.js +3285 -1947
  154. package/dist/runtime/checks.js.map +1 -1
  155. package/dist/runtime/client.d.ts +112 -0
  156. package/dist/runtime/client.d.ts.map +1 -1
  157. package/dist/runtime/client.js +271 -39
  158. package/dist/runtime/client.js.map +1 -1
  159. package/dist/runtime/cookies.d.ts +16 -2
  160. package/dist/runtime/cookies.d.ts.map +1 -1
  161. package/dist/runtime/cookies.js +83 -22
  162. package/dist/runtime/cookies.js.map +1 -1
  163. package/dist/runtime/exchanges.d.ts +4 -5
  164. package/dist/runtime/exchanges.d.ts.map +1 -1
  165. package/dist/runtime/exchanges.js +33 -55
  166. package/dist/runtime/exchanges.js.map +1 -1
  167. package/dist/runtime/exit.d.ts +40 -0
  168. package/dist/runtime/exit.d.ts.map +1 -0
  169. package/dist/runtime/exit.js +48 -0
  170. package/dist/runtime/exit.js.map +1 -0
  171. package/dist/runtime/feature-gate.d.ts +0 -9
  172. package/dist/runtime/feature-gate.d.ts.map +1 -1
  173. package/dist/runtime/feature-gate.js +0 -21
  174. package/dist/runtime/feature-gate.js.map +1 -1
  175. package/dist/runtime/finding.d.ts +49 -8
  176. package/dist/runtime/finding.d.ts.map +1 -1
  177. package/dist/runtime/finding.js +51 -8
  178. package/dist/runtime/finding.js.map +1 -1
  179. package/dist/runtime/fixture.d.ts +35 -28
  180. package/dist/runtime/fixture.d.ts.map +1 -1
  181. package/dist/runtime/fixture.js +92 -731
  182. package/dist/runtime/fixture.js.map +1 -1
  183. package/dist/runtime/generate.d.ts +63 -0
  184. package/dist/runtime/generate.d.ts.map +1 -0
  185. package/dist/runtime/generate.js +635 -0
  186. package/dist/runtime/generate.js.map +1 -0
  187. package/dist/runtime/headers.d.ts +6 -0
  188. package/dist/runtime/headers.d.ts.map +1 -0
  189. package/dist/runtime/headers.js +16 -0
  190. package/dist/runtime/headers.js.map +1 -0
  191. package/dist/runtime/path.d.ts +7 -0
  192. package/dist/runtime/path.d.ts.map +1 -0
  193. package/dist/runtime/path.js +44 -0
  194. package/dist/runtime/path.js.map +1 -0
  195. package/dist/runtime/payloads.d.ts +2 -0
  196. package/dist/runtime/payloads.d.ts.map +1 -1
  197. package/dist/runtime/payloads.js +2 -1
  198. package/dist/runtime/payloads.js.map +1 -1
  199. package/dist/runtime/rate-limit.d.ts +6 -4
  200. package/dist/runtime/rate-limit.d.ts.map +1 -1
  201. package/dist/runtime/rate-limit.js +22 -33
  202. package/dist/runtime/rate-limit.js.map +1 -1
  203. package/dist/runtime/redact.d.ts +24 -0
  204. package/dist/runtime/redact.d.ts.map +1 -0
  205. package/dist/runtime/redact.js +98 -0
  206. package/dist/runtime/redact.js.map +1 -0
  207. package/dist/runtime/run.d.ts +34 -2
  208. package/dist/runtime/run.d.ts.map +1 -1
  209. package/dist/runtime/run.js +346 -138
  210. package/dist/runtime/run.js.map +1 -1
  211. package/dist/runtime/runs.d.ts +5 -0
  212. package/dist/runtime/runs.d.ts.map +1 -1
  213. package/dist/runtime/runs.js +7 -1
  214. package/dist/runtime/runs.js.map +1 -1
  215. package/dist/runtime/teardown.d.ts +17 -4
  216. package/dist/runtime/teardown.d.ts.map +1 -1
  217. package/dist/runtime/teardown.js +49 -16
  218. package/dist/runtime/teardown.js.map +1 -1
  219. package/dist/runtime/transcript.d.ts +34 -2
  220. package/dist/runtime/transcript.d.ts.map +1 -1
  221. package/dist/runtime/transcript.js +119 -24
  222. package/dist/runtime/transcript.js.map +1 -1
  223. package/dist/runtime/unique.d.ts +2 -11
  224. package/dist/runtime/unique.d.ts.map +1 -1
  225. package/dist/runtime/unique.js +27 -53
  226. package/dist/runtime/unique.js.map +1 -1
  227. package/dist/runtime/validate.d.ts +13 -2
  228. package/dist/runtime/validate.d.ts.map +1 -1
  229. package/dist/runtime/validate.js +51 -31
  230. package/dist/runtime/validate.js.map +1 -1
  231. package/dist/runtime/wait.d.ts +2 -1
  232. package/dist/runtime/wait.d.ts.map +1 -1
  233. package/dist/runtime/wait.js +2 -36
  234. package/dist/runtime/wait.js.map +1 -1
  235. package/dist/runtime/world.d.ts +24 -1
  236. package/dist/runtime/world.d.ts.map +1 -1
  237. package/dist/runtime/world.js +101 -26
  238. package/dist/runtime/world.js.map +1 -1
  239. package/dist/spec/collection.d.ts +8 -2
  240. package/dist/spec/collection.d.ts.map +1 -1
  241. package/dist/spec/collection.js +47 -7
  242. package/dist/spec/collection.js.map +1 -1
  243. package/dist/spec/extensions.d.ts +5 -1
  244. package/dist/spec/extensions.d.ts.map +1 -1
  245. package/dist/spec/extensions.js +9 -2
  246. package/dist/spec/extensions.js.map +1 -1
  247. package/dist/spec/graph.d.ts +46 -0
  248. package/dist/spec/graph.d.ts.map +1 -1
  249. package/dist/spec/graph.js +175 -11
  250. package/dist/spec/graph.js.map +1 -1
  251. package/dist/spec/load.d.ts +25 -5
  252. package/dist/spec/load.d.ts.map +1 -1
  253. package/dist/spec/load.js +160 -59
  254. package/dist/spec/load.js.map +1 -1
  255. package/dist/spec/query-capabilities.d.ts +4 -0
  256. package/dist/spec/query-capabilities.d.ts.map +1 -1
  257. package/dist/spec/query-capabilities.js +6 -0
  258. package/dist/spec/query-capabilities.js.map +1 -1
  259. package/dist/spec/schema.d.ts +20 -0
  260. package/dist/spec/schema.d.ts.map +1 -0
  261. package/dist/spec/schema.js +118 -0
  262. package/dist/spec/schema.js.map +1 -0
  263. package/dist/spec/tag-schema.d.ts +12 -0
  264. package/dist/spec/tag-schema.d.ts.map +1 -0
  265. package/dist/spec/tag-schema.js +143 -0
  266. package/dist/spec/tag-schema.js.map +1 -0
  267. package/dist/spec/types.js +17 -1
  268. package/dist/spec/types.js.map +1 -1
  269. package/docs/api.md +59 -0
  270. package/docs/assumptions.md +99 -0
  271. package/docs/checks.md +183 -0
  272. package/docs/commands.md +214 -0
  273. package/docs/configuration.md +650 -0
  274. package/docs/model.md +184 -0
  275. package/docs/reference-defects.md +94 -0
  276. package/docs/reports.md +211 -0
  277. package/docs/tags.md +400 -0
  278. package/package.json +3 -1
@@ -1,39 +1,31 @@
1
1
  /**
2
- * PostgREST-shaped query engine for the reference backend.
2
+ * The reference backend's query language, parsed once.
3
3
  *
4
- * Deliberately simple and readable — its job is to be *obviously* correct so that any finding
5
- * oat reports against the clean baseline is oat's fault, not the reference's. Defects are
6
- * applied as explicit, local deviations rather than woven into the logic.
4
+ * Filter, sort, select and search arrive as text in the canonical grammar (`field.op.value`,
5
+ * `and(...)`, `field.desc`). They are parsed here into an AST that every store consumes: the
6
+ * in-memory store evaluates it, the SQL stores compile it. Defects that change what a predicate
7
+ * *means* are applied here as rewrites of the tree, so a defect is defined exactly once and every
8
+ * engine exhibits the same bug.
9
+ *
10
+ * Values are coerced by the field's declared type, never by what the text looks like. A string
11
+ * field filtered on `123` compares against the string "123"; a numeric field filtered on `abc`
12
+ * matches nothing (or is rejected with a 400 when the backend is strictly typed).
13
+ *
14
+ * Null semantics, decided once and held by every store:
15
+ * - `eq`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `ilike` never match a null value.
16
+ * - `neq` and `nin` do match a null value: null is not equal to, and not a member of, anything.
17
+ * - `is.null` / `is.notnull` test for null explicitly. `eq.null` is not a null test: on a
18
+ * text field it compares against the text "null", as PostgREST does.
7
19
  */
20
+ import { DefectSet } from "./defects.js";
21
+ import { OVERCLAIMED_FIELD, fieldsWhere } from "./model.js";
8
22
  import { SqlError } from "./store-api.js";
9
- /* One error type across every store, so the HTTP layer has a single thing to catch. Two
10
- * separate classes meant a rejected filter surfaced as 400 on the SQL stores and 500 here — the
11
- * conformance suite reported it as a defect, correctly. */
12
- export { SqlError as QueryError } from "./store-api.js";
13
- const COMPARATORS = new Set([
14
- "eq",
15
- "ne",
16
- "neq",
17
- "gt",
18
- "gte",
19
- "lt",
20
- "lte",
21
- "in",
22
- "nin",
23
- "like",
24
- "ilike",
25
- "is",
26
- "contains",
27
- ]);
28
- function countFilterTerms(expression) {
29
- const group = /^(and|or)\((.*)\)$/s.exec(expression.trim());
30
- if (group?.[2] !== undefined) {
31
- return splitTopLevel(group[2]).reduce((sum, part) => sum + countFilterTerms(part), 0);
32
- }
33
- return 1;
34
- }
35
- /** Splits on commas that sit at paren depth zero. */
36
- function splitTopLevel(input) {
23
+ export const SEARCH_MODES = ["contains", "prefix"];
24
+ export const DEFAULT_CONVENTIONS = { collation: "binary", nulls: "first", strictTypes: false };
25
+ /* ------------------------------------------------------------------ parsing */
26
+ const OPERATORS = new Set(["eq", "ne", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is", "contains"]);
27
+ /** Splits on commas at paren depth zero — shared by the filter and order grammars. */
28
+ export function splitTopLevel(input) {
37
29
  const parts = [];
38
30
  let depth = 0;
39
31
  let start = 0;
@@ -51,353 +43,443 @@ function splitTopLevel(input) {
51
43
  parts.push(input.slice(start));
52
44
  return parts.map((p) => p.trim()).filter((p) => p.length > 0);
53
45
  }
54
- function parseFilter(expression, options, defects) {
55
- if (options.maxFilterConditions !== undefined && countFilterTerms(expression) > options.maxFilterConditions) {
56
- throw new SqlError("invalid_filter", `more than ${options.maxFilterConditions} filter conditions`);
46
+ function stripParens(value) {
47
+ return value.startsWith("(") && value.endsWith(")") ? value.slice(1, -1) : value;
48
+ }
49
+ function countTerms(expression) {
50
+ const group = /^(and|or)\((.*)\)$/s.exec(expression.trim());
51
+ if (group?.[2] !== undefined)
52
+ return splitTopLevel(group[2]).reduce((sum, part) => sum + countTerms(part), 0);
53
+ return 1;
54
+ }
55
+ function fieldOf(entity, name) {
56
+ return entity.fields.find((field) => field.name === name);
57
+ }
58
+ function filterableFields(entity, defects) {
59
+ const declared = fieldsWhere(entity, "filterable");
60
+ /* Under the overclaim the backend refuses a field the document still declares filterable. The
61
+ * backend is not wrong to refuse — the document is wrong to promise. */
62
+ return defects.has("SPEC_OVERCLAIMS_FILTERABLE") ? declared.filter((f) => f !== OVERCLAIMED_FIELD) : declared;
63
+ }
64
+ function sortableFields(entity, defects) {
65
+ const declared = fieldsWhere(entity, "sortable");
66
+ return defects.has("SPEC_OVERCLAIMS_SORTABLE") ? declared.filter((f) => f !== OVERCLAIMED_FIELD) : declared;
67
+ }
68
+ function isNumeric(field) {
69
+ return field?.type === "integer" || field?.type === "number";
70
+ }
71
+ /**
72
+ * The value a filter term compares against, read as the field's declared type.
73
+ *
74
+ * `undefined` means the text does not denote a value of that type at all. A lenient backend turns
75
+ * that into a predicate matching nothing; a strict one rejects the request.
76
+ */
77
+ function coerce(raw, field) {
78
+ if (field.type === "integer" || field.type === "number") {
79
+ if (raw.trim() === "")
80
+ return undefined;
81
+ const n = Number(raw);
82
+ if (!Number.isFinite(n))
83
+ return undefined;
84
+ if (field.type === "integer" && !Number.isInteger(n))
85
+ return undefined;
86
+ return n;
87
+ }
88
+ if (field.type === "boolean") {
89
+ if (raw === "true")
90
+ return true;
91
+ if (raw === "false")
92
+ return false;
93
+ return undefined;
57
94
  }
58
- return parseFilterInner(expression, options, defects);
95
+ return raw;
59
96
  }
60
- function parseFilterInner(expression, options, defects) {
97
+ function likePattern(raw, unescaped) {
98
+ const segments = [];
99
+ let text = "";
100
+ const flush = () => {
101
+ if (text !== "")
102
+ segments.push({ kind: "text", text });
103
+ text = "";
104
+ };
105
+ for (const ch of raw) {
106
+ /* `*` is the grammar's wildcard. `%` and `_` are literals — unless the backend interpolates
107
+ * the value into a LIKE pattern without escaping it, which is the LIKE_UNESCAPED defect. */
108
+ if (ch === "*" || (unescaped && ch === "%")) {
109
+ flush();
110
+ segments.push({ kind: "any" });
111
+ }
112
+ else if (unescaped && ch === "_") {
113
+ flush();
114
+ segments.push({ kind: "one" });
115
+ }
116
+ else {
117
+ text += ch;
118
+ }
119
+ }
120
+ flush();
121
+ return segments;
122
+ }
123
+ /** Parses a canonical filter expression. Throws `SqlError` for anything a correct backend rejects. */
124
+ export function parseFilter(expression, ctx) {
125
+ const catalog = ctx.entity.filterCatalog;
126
+ if (catalog?.maxFilterConditions !== undefined &&
127
+ countTerms(expression) > catalog.maxFilterConditions &&
128
+ !ctx.defects.has("FILTER_CONDITION_CAP_IGNORED")) {
129
+ throw new SqlError("invalid_filter", `more than ${catalog.maxFilterConditions} filter conditions`);
130
+ }
131
+ return parseNode(expression, ctx);
132
+ }
133
+ function parseNode(expression, ctx) {
134
+ const { defects, entity } = ctx;
61
135
  const trimmed = expression.trim();
62
136
  const group = /^(and|or)\((.*)\)$/s.exec(trimmed);
63
- if (group?.[1] && group[2] !== undefined) {
64
- const combinator = group[1];
65
- const children = splitTopLevel(group[2]).map((part) => parseFilterInner(part, options, defects));
66
- if (children.length === 0) {
67
- throw new SqlError("invalid_filter", `empty ${combinator}() group`);
137
+ if (group?.[1] !== undefined && group[2] !== undefined) {
138
+ const children = splitTopLevel(group[2]).map((part) => parseNode(part, ctx));
139
+ if (children.length === 0)
140
+ throw new SqlError("invalid_filter", `empty ${group[1]}() group`);
141
+ /* The joiner is swapped, not the parse: the expression is read correctly and evaluated with
142
+ * the other combinator's semantics. */
143
+ const named = group[1];
144
+ let kind = defects.has("FILTER_GROUP_COMBINATOR_SWAPPED") ? (named === "and" ? "or" : "and") : named;
145
+ /* One shared "current joiner": the last nested group to set it decides how this group joins. */
146
+ if (defects.has("FILTER_NESTED_COMBINATOR_LEAKS")) {
147
+ const nested = children.filter((child) => child.kind === "and" || child.kind === "or").at(-1);
148
+ if (nested !== undefined && (nested.kind === "and" || nested.kind === "or"))
149
+ kind = nested.kind;
68
150
  }
69
- /* The joiner is swapped, not the parse: the expression is still read correctly, it is just
70
- * evaluated with the other combinator's semantics. */
71
- const effective = defects.has("FILTER_GROUP_COMBINATOR_SWAPPED")
72
- ? combinator === "and"
73
- ? "or"
74
- : "and"
75
- : combinator;
76
- return effective === "and"
77
- ? (row) => children.every((child) => child(row))
78
- : (row) => children.some((child) => child(row));
151
+ return { children, kind };
79
152
  }
80
153
  const segments = trimmed.split(".");
81
- if (segments.length < 3) {
154
+ if (segments.length < 3)
82
155
  throw new SqlError("invalid_filter", `malformed filter term "${trimmed}"`);
156
+ const [name, rawOp] = segments;
157
+ const raw = segments.slice(2).join(".");
158
+ if (!OPERATORS.has(rawOp)) {
159
+ if (defects.has("FILTER_ILLEGAL_OP_IGNORED"))
160
+ return { kind: "const", value: true };
161
+ throw new SqlError("invalid_filter", `unknown operator "${rawOp}"`);
83
162
  }
84
- const [field, op] = segments;
85
- const rawValue = segments.slice(2).join(".");
86
- const canonical = op === "ne" ? "neq" : op;
87
- const allowed = options.allowedOpsByField?.[field];
88
- if (!COMPARATORS.has(op) || (allowed !== undefined && !allowed.includes(op) && !allowed.includes(canonical))) {
163
+ const aliases = entity.filterCatalog?.aliases;
164
+ const alias = aliases !== undefined && Object.hasOwn(aliases, rawOp) ? aliases[rawOp] : undefined;
165
+ /* The alias table maps `ne` to the wrong operator. */
166
+ const mapped = alias !== undefined && defects.has("FILTER_ALIAS_MISMAPPED") ? "eq" : alias;
167
+ const op = mapped ?? (rawOp === "ne" ? "neq" : rawOp);
168
+ const opsByField = entity.filterCatalog?.opsByField;
169
+ const allowed = opsByField !== undefined && Object.hasOwn(opsByField, name) ? opsByField[name] : undefined;
170
+ if (allowed !== undefined && !allowed.includes(rawOp) && !allowed.includes(op)) {
89
171
  if (defects.has("FILTER_ILLEGAL_OP_IGNORED"))
90
- return () => true;
91
- throw new SqlError("invalid_filter", `unknown operator "${op}"`);
172
+ return { kind: "const", value: true };
173
+ throw new SqlError("invalid_filter", `operator "${rawOp}" is not allowed on "${name}"`);
92
174
  }
93
- if (!options.filterable.includes(field)) {
175
+ /* The document lists `like` on `slug`; under the overclaim the backend no longer supports it. */
176
+ if (defects.has("SPEC_OVERCLAIMS_FILTER_OP") && name === "slug" && op === "like") {
177
+ throw new SqlError("invalid_filter", `operator "like" is not supported on "slug"`);
178
+ }
179
+ if (!filterableFields(entity, defects).includes(name)) {
94
180
  /* Correct behaviour is to reject. The defect drops the term instead — the single most
95
181
  * common real-world filter bug, and invisible to schema validation. */
96
182
  if (defects.has("FILTER_IGNORED"))
97
- return () => true;
98
- throw new SqlError("invalid_filter", `field "${field}" is not filterable`);
183
+ return { kind: "const", value: true };
184
+ throw new SqlError("invalid_filter", `field "${name}" is not filterable`);
99
185
  }
100
- return buildComparator(field, canonical, rawValue, defects, options);
186
+ const field = fieldOf(entity, name);
187
+ return buildTerm(field, op, raw, ctx);
101
188
  }
102
- function buildComparator(field, op, rawValue, defects, options) {
103
- const value = coerce(rawValue);
189
+ function buildTerm(field, op, raw, ctx) {
190
+ const { defects, conventions } = ctx;
191
+ const asText = isNumeric(field) && defects.has("NUMERIC_COMPARED_AS_TEXT");
192
+ /* A value that is not of the field's type: lenient backends match nothing, strict ones say so. */
193
+ const typed = (text) => {
194
+ const value = coerce(text, field);
195
+ if (value === undefined && conventions.strictTypes) {
196
+ throw new SqlError("invalid_filter", `"${text}" is not a valid ${field.type} for "${field.name}"`);
197
+ }
198
+ return value;
199
+ };
200
+ const never = { kind: "const", value: false };
201
+ /* An array is filtered by membership and by nothing else. */
202
+ if (field.type === "array" || op === "contains") {
203
+ if (field.type !== "array" || op !== "contains") {
204
+ throw new SqlError("invalid_filter", `operator "${op}" is not supported on "${field.name}"`);
205
+ }
206
+ return { field: field.name, kind: "contains", substring: defects.has("CONTAINS_MATCHES_SUBSTRING"), value: raw };
207
+ }
104
208
  switch (op) {
105
- case "eq":
209
+ case "eq": {
106
210
  if (defects.has("FILTER_EQ_NOT_APPLIED"))
107
- return () => true;
108
- return (row) => looseEqual(row[field], value);
109
- case "ne":
110
- case "neq":
111
- /* Three-valued logic leaking through: in SQL, `col <> x` is NULL — not true — when col
112
- * is NULL, so those rows silently vanish unless the query says `OR col IS NULL`. */
113
- if (defects.has("NEQ_DROPS_NULLS")) {
114
- return (row) => row[field] !== null && row[field] !== undefined && !looseEqual(row[field], value);
211
+ return { kind: "const", value: true };
212
+ const value = typed(raw);
213
+ return value === undefined ? never : { asText, field: field.name, kind: "cmp", nullsMatch: false, op, value };
214
+ }
215
+ case "neq": {
216
+ const value = typed(raw);
217
+ /* Three-valued logic leaking through: in SQL `col <> x` is NULL — not true — when col is
218
+ * NULL, so null rows silently vanish unless the query also tests `col IS NULL`. */
219
+ const nullsMatch = !defects.has("NEQ_DROPS_NULLS");
220
+ if (value === undefined) {
221
+ /* Nothing equals a value of the wrong type, so everything — nulls included — differs. */
222
+ return nullsMatch ? { kind: "const", value: true } : { field: field.name, kind: "isnull", negate: true };
115
223
  }
116
- return (row) => !looseEqual(row[field], value);
224
+ return { asText, field: field.name, kind: "cmp", nullsMatch, op, value };
225
+ }
117
226
  case "gt":
118
- return (row) => compare(row[field], value) > 0;
119
227
  case "gte":
120
- if (defects.has("FILTER_GTE_IS_GT"))
121
- return (row) => compare(row[field], value) > 0;
122
- return (row) => compare(row[field], value) >= 0;
123
228
  case "lt":
124
- return (row) => compare(row[field], value) < 0;
125
- case "lte":
126
- return (row) => compare(row[field], value) <= 0;
229
+ case "lte": {
230
+ const value = typed(raw);
231
+ if (value === undefined)
232
+ return never;
233
+ const effective = op === "gte" && defects.has("FILTER_GTE_IS_GT")
234
+ ? "gt"
235
+ : op === "lte" && defects.has("FILTER_LTE_IS_LT")
236
+ ? "lt"
237
+ : op === "lt" && defects.has("FILTER_LT_IS_LTE")
238
+ ? "lte"
239
+ : op;
240
+ return { asText, field: field.name, kind: "cmp", nullsMatch: false, op: effective, value };
241
+ }
127
242
  case "in":
128
243
  case "nin": {
129
- const members = stripParens(rawValue)
244
+ const members = stripParens(raw)
130
245
  .split(",")
131
246
  .map((s) => s.trim())
132
- .filter((s) => s.length > 0)
133
- .map((s) => coerce(s));
247
+ .filter((s) => s.length > 0);
134
248
  if (members.length === 0) {
135
- if (options.emptyIn === "reject")
136
- throw new SqlError("invalid_filter", "empty in() is not allowed");
137
- return op === "in" ? () => false : () => true;
249
+ if (ctx.entity.filterCatalog?.emptyIn === "reject")
250
+ throw new SqlError("invalid_filter", "empty in()");
251
+ return { kind: "const", value: op === "nin" };
138
252
  }
139
- if (options.maxInValues !== undefined && members.length > options.maxInValues) {
140
- throw new SqlError("invalid_filter", `in() exceeds maxInValues=${options.maxInValues}`);
253
+ const max = ctx.entity.filterCatalog?.maxInValues;
254
+ if (max !== undefined && members.length > max && !defects.has("FILTER_IN_CAP_IGNORED")) {
255
+ throw new SqlError("invalid_filter", `in() exceeds maxInValues=${max}`);
141
256
  }
142
- const effective = defects.has("FILTER_IN_FIRST_ONLY") && op === "in" && members[0] !== undefined ? [members[0]] : members;
143
- return (row) => {
144
- const hit = effective.some((member) => looseEqual(row[field], member));
145
- return op === "in" ? hit : !hit;
146
- };
257
+ const values = members.map(typed).filter((value) => value !== undefined);
258
+ const firstOnly = (defects.has("FILTER_IN_FIRST_ONLY") && op === "in") || (defects.has("FILTER_NIN_FIRST_ONLY") && op === "nin");
259
+ const effective = firstOnly ? values.slice(0, 1) : values;
260
+ return { asText, field: field.name, kind: "in", negate: op === "nin", values: effective };
147
261
  }
148
262
  case "is": {
149
- if (rawValue === "null") {
263
+ if (raw === "null") {
150
264
  if (defects.has("FILTER_IS_NULL_MATCHES_ALL"))
151
- return () => true;
152
- return (row) => row[field] === null || row[field] === undefined;
265
+ return { kind: "const", value: true };
266
+ return { field: field.name, kind: "isnull", negate: false };
153
267
  }
154
- if (rawValue === "notnull" || rawValue === "not.null") {
155
- return (row) => row[field] !== null && row[field] !== undefined;
268
+ if (raw === "notnull" || raw === "not.null")
269
+ return { field: field.name, kind: "isnull", negate: true };
270
+ if ((raw === "true" || raw === "false") && field.type === "boolean") {
271
+ return { asText: false, field: field.name, kind: "cmp", nullsMatch: false, op: "eq", value: raw === "true" };
156
272
  }
157
- if (rawValue === "true")
158
- return (row) => row[field] === true;
159
- if (rawValue === "false")
160
- return (row) => row[field] === false;
161
- throw new SqlError("invalid_filter", `is.${rawValue} is not a recognised predicate`);
273
+ throw new SqlError("invalid_filter", `is.${raw} is not a recognised predicate`);
162
274
  }
163
- case "contains":
164
- return (row) => Array.isArray(row[field]) && row[field].some((v) => looseEqual(v, value));
165
275
  case "like":
166
276
  case "ilike": {
167
- const insensitive = op === "ilike" && !defects.has("FILTER_ILIKE_IS_LIKE");
168
- const pattern = likeToRegExp(rawValue, insensitive, defects);
169
- return (row) => typeof row[field] === "string" && pattern.test(row[field]);
277
+ if (field.type !== "string")
278
+ throw new SqlError("invalid_filter", `${op} needs a text field`);
279
+ return {
280
+ field: field.name,
281
+ insensitive: op === "ilike" && !defects.has("FILTER_ILIKE_IS_LIKE"),
282
+ kind: "like",
283
+ pattern: likePattern(raw, defects.has("LIKE_UNESCAPED")),
284
+ };
170
285
  }
171
286
  default:
172
287
  throw new SqlError("invalid_filter", `unhandled operator "${op}"`);
173
288
  }
174
289
  }
175
290
  /**
176
- * `*` is the wildcard. Everything else — notably `%` and `_`, which users type constantly —
177
- * must be escaped so it matches literally. Forgetting to is a real bug class: a value
178
- * containing `%` then matches every row.
291
+ * Parses a canonical order expression (`field.desc.nullslast,other`).
292
+ *
293
+ * `trusted` is for the server's own default order: it is not a request, so the request's
294
+ * validation — and the defects that corrupt it — do not apply to it.
179
295
  */
180
- function likeToRegExp(pattern, insensitive, defects) {
181
- const escaped = defects.has("LIKE_UNESCAPED")
182
- ? pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/[%_]/g, ".*")
183
- : pattern.replace(/[.+^${}()|[\]\\%_]/g, "\\$&");
184
- return new RegExp(`^${escaped.replace(/\*/g, ".*")}$`, insensitive ? "i" : "");
185
- }
186
- function stripParens(value) {
187
- return value.startsWith("(") && value.endsWith(")") ? value.slice(1, -1) : value;
296
+ export function parseOrder(expression, ctx, trusted = false) {
297
+ const { entity, conventions } = ctx;
298
+ const defects = trusted ? new DefectSet() : ctx.defects;
299
+ const sortable = trusted ? fieldsWhere(entity, "sortable") : sortableFields(entity, defects);
300
+ const terms = splitTopLevel(expression).flatMap((term) => {
301
+ const [name, ...parsed] = term.split(".");
302
+ if (name === undefined || !sortable.includes(name)) {
303
+ /* Correct is to reject; the defect drops a term naming a field that does not exist. */
304
+ const exists = entity.fields.some((field) => field.name === name);
305
+ if (defects.has("SORT_UNKNOWN_FIELD_IGNORED") && !exists)
306
+ return [];
307
+ throw new SqlError("invalid_order", `field "${name ?? ""}" is not sortable`);
308
+ }
309
+ const modifiers = defects.has("SORT_NULLS_MODIFIER_IGNORED")
310
+ ? parsed.filter((modifier) => modifier !== "nullsfirst" && modifier !== "nullslast")
311
+ : parsed;
312
+ for (const modifier of modifiers) {
313
+ if (!["asc", "desc", "nullsfirst", "nullslast"].includes(modifier)) {
314
+ throw new SqlError("invalid_order", `unknown sort modifier "${modifier}"`);
315
+ }
316
+ }
317
+ const descending = modifiers.includes("desc");
318
+ /* The default placement is stated for ascending order; descending reverses it, so a
319
+ * descending sort is exactly the ascending one read backwards. */
320
+ const defaultFirst = conventions.nulls === "first" ? !descending : descending;
321
+ const numeric = isNumeric(fieldOf(entity, name));
322
+ return [
323
+ {
324
+ asText: numeric && (defects.has("SORT_NUMERIC_AS_TEXT") || defects.has("NUMERIC_COMPARED_AS_TEXT")),
325
+ descending,
326
+ field: name,
327
+ nullsFirst: modifiers.includes("nullsfirst") ? true : modifiers.includes("nullslast") ? false : defaultFirst,
328
+ },
329
+ ];
330
+ });
331
+ return defects.has("SORT_MULTI_KEY_IGNORED") ? terms.slice(0, 1) : terms;
188
332
  }
189
- function coerce(raw) {
190
- if (raw === "null")
333
+ /**
334
+ * Search tokens: every token must occur, case-insensitively, in some searchable field — as a
335
+ * substring under `contains`, at the start of the value under `prefix`. A blank query matches
336
+ * everything.
337
+ */
338
+ export function parseSearch(q, entity, defects, mode) {
339
+ const split = q
340
+ .split(/\s+/)
341
+ .map((token) => token.trim())
342
+ .filter((token) => token.length > 0);
343
+ const fields = fieldsWhere(entity, "searchable");
344
+ if (split.length === 0)
345
+ return defects.has("SEARCH_EMPTY_MATCHES_NONE") ? { kind: "const", value: false } : null;
346
+ if (fields.length === 0)
191
347
  return null;
192
- if (raw === "true")
193
- return true;
194
- if (raw === "false")
195
- return false;
196
- if (raw !== "" && !Number.isNaN(Number(raw)))
197
- return Number(raw);
198
- return raw;
348
+ const tokens = defects.has("SEARCH_ONLY_FIRST_TOKEN") ? split.slice(0, 1) : split;
349
+ const caseSensitive = defects.has("SEARCH_CASE_SENSITIVE");
350
+ return {
351
+ children: tokens.map((token) => ({ caseSensitive, fields, kind: "search", mode, token })),
352
+ kind: "and",
353
+ };
354
+ }
355
+ export function and(...nodes) {
356
+ const children = nodes.filter((node) => node !== null);
357
+ if (children.length === 0)
358
+ return { kind: "const", value: true };
359
+ if (children.length === 1)
360
+ return children[0];
361
+ return { children, kind: "and" };
362
+ }
363
+ /** Fields a filter expression names, for the tombstone rule (an explicit mention lifts it). */
364
+ export function fieldsIn(node) {
365
+ switch (node.kind) {
366
+ case "and":
367
+ case "or":
368
+ return node.children.flatMap(fieldsIn);
369
+ case "const":
370
+ return [];
371
+ case "search":
372
+ return node.fields;
373
+ default:
374
+ return [node.field];
375
+ }
199
376
  }
200
- function looseEqual(a, b) {
201
- if (a === b)
202
- return true;
203
- if (a === null || a === undefined || b === null || b === undefined)
204
- return false;
205
- return String(a) === String(b);
377
+ function isNull(value) {
378
+ return value === null || value === undefined;
206
379
  }
207
- function compare(a, b, asText = false) {
208
- if (a === null || a === undefined)
209
- return -1;
210
- if (b === null || b === undefined)
211
- return 1;
380
+ function compareScalar(a, b, asText) {
212
381
  if (!asText && typeof a === "number" && typeof b === "number")
213
382
  return a - b;
383
+ if (!asText && typeof a === "boolean" && typeof b === "boolean")
384
+ return Number(a) - Number(b);
214
385
  const as = String(a);
215
386
  const bs = String(b);
216
387
  return as < bs ? -1 : as > bs ? 1 : 0;
217
388
  }
218
- function parseOrder(expression, options, defects) {
219
- const terms = splitTopLevel(expression).map((term) => {
220
- const [field, ...modifiers] = term.split(".");
221
- if (field === undefined || !options.sortable.includes(field)) {
222
- throw new SqlError("invalid_order", `field "${field ?? ""}" is not sortable`);
223
- }
224
- const descending = modifiers.includes("desc");
225
- return {
226
- descending,
227
- field,
228
- /* Postgres default: NULLs sort last ascending, first descending. */
229
- nullsFirst: modifiers.includes("nullsfirst") ? true : modifiers.includes("nullslast") ? false : descending,
230
- };
231
- });
232
- return defects.has("SORT_MULTI_KEY_IGNORED") ? terms.slice(0, 1) : terms;
389
+ function equal(a, b, asText) {
390
+ if (asText)
391
+ return String(a) === String(b);
392
+ return a === b;
393
+ }
394
+ export function likeToRegExp(pattern, insensitive) {
395
+ const source = pattern
396
+ .map((segment) => segment.kind === "any"
397
+ ? "[\\s\\S]*"
398
+ : segment.kind === "one"
399
+ ? "[\\s\\S]"
400
+ : segment.text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
401
+ .join("");
402
+ return new RegExp(`^${source}$`, insensitive ? "iu" : "u");
233
403
  }
234
- /** Advances per query so tied rows land differently each time — see UNSTABLE_SORT below. */
235
- let unstableRotation = 0;
236
- function applyOrder(rows, terms, options, defects) {
237
- /* A sort without a total order is not a sort — equal keys may come back in any order, and
238
- * keyset pagination built on it silently loses rows. The tiebreak is what makes paging sound.
239
- *
240
- * Array.prototype.sort is stable, so simply dropping the tiebreak would still yield a
241
- * deterministic order and the defect would be unobservable. A real database without a
242
- * tiebreak returns tied rows in whatever order the plan produced, varying between queries —
243
- * so the defect rotates the input to reproduce that. */
244
- const effective = defects.has("UNSTABLE_SORT")
245
- ? terms
246
- : [...terms, { descending: false, field: options.identity, nullsFirst: false }];
247
- let input = [...rows];
248
- if (defects.has("UNSTABLE_SORT") && input.length > 1) {
249
- unstableRotation = (unstableRotation + 1) % input.length;
250
- input = [...input.slice(unstableRotation), ...input.slice(0, unstableRotation)];
404
+ /** The in-memory evaluation of a filter — the reference semantics the SQL compilers match. */
405
+ export function evaluate(node, row) {
406
+ switch (node.kind) {
407
+ case "const":
408
+ return node.value;
409
+ case "and":
410
+ return node.children.every((child) => evaluate(child, row));
411
+ case "or":
412
+ return node.children.some((child) => evaluate(child, row));
413
+ case "isnull":
414
+ return isNull(row[node.field]) !== node.negate;
415
+ case "cmp": {
416
+ const value = row[node.field];
417
+ if (isNull(value))
418
+ return node.nullsMatch;
419
+ if (node.op === "eq")
420
+ return equal(value, node.value, node.asText);
421
+ if (node.op === "neq")
422
+ return !equal(value, node.value, node.asText);
423
+ const delta = compareScalar(value, node.value, node.asText);
424
+ if (node.op === "gt")
425
+ return delta > 0;
426
+ if (node.op === "gte")
427
+ return delta >= 0;
428
+ if (node.op === "lt")
429
+ return delta < 0;
430
+ return delta <= 0;
431
+ }
432
+ case "in": {
433
+ const value = row[node.field];
434
+ if (isNull(value))
435
+ return node.negate;
436
+ const hit = node.values.some((member) => equal(value, member, node.asText));
437
+ return hit !== node.negate;
438
+ }
439
+ case "like": {
440
+ const value = row[node.field];
441
+ return typeof value === "string" && likeToRegExp(node.pattern, node.insensitive).test(value);
442
+ }
443
+ case "contains": {
444
+ const value = row[node.field];
445
+ if (!Array.isArray(value))
446
+ return false;
447
+ return node.substring
448
+ ? value.some((element) => String(element).includes(node.value))
449
+ : value.some((element) => String(element) === node.value);
450
+ }
451
+ case "search": {
452
+ const fold = (text) => (node.caseSensitive ? text : text.toLowerCase());
453
+ const needle = fold(node.token);
454
+ return node.fields.some((field) => {
455
+ const value = row[field];
456
+ if (isNull(value))
457
+ return false;
458
+ const text = fold(String(value));
459
+ return node.mode === "prefix" ? text.startsWith(needle) : text.includes(needle);
460
+ });
461
+ }
251
462
  }
252
- return input.sort((left, right) => {
253
- for (const term of effective) {
463
+ }
464
+ /** Orders rows under the plan. Ties the plan leaves open keep their input order. */
465
+ export function compareRows(order, collation) {
466
+ return (left, right) => {
467
+ for (const term of order) {
254
468
  const a = left[term.field];
255
469
  const b = right[term.field];
256
- const aNull = a === null || a === undefined;
257
- const bNull = b === null || b === undefined;
470
+ const aNull = isNull(a);
471
+ const bNull = isNull(b);
258
472
  if (aNull && bNull)
259
473
  continue;
260
- if (aNull || bNull) {
261
- const nullRank = aNull ? -1 : 1;
262
- return term.nullsFirst ? nullRank : -nullRank;
263
- }
264
- const delta = compare(a, b, defects.has("SORT_NUMERIC_AS_TEXT"));
474
+ if (aNull || bNull)
475
+ return aNull === term.nullsFirst ? -1 : 1;
476
+ const delta = collation === "insensitive" && typeof a === "string" && typeof b === "string"
477
+ ? compareScalar(a.toLowerCase(), b.toLowerCase(), true)
478
+ : compareScalar(a, b, term.asText);
265
479
  if (delta !== 0)
266
480
  return term.descending ? -delta : delta;
267
481
  }
268
482
  return 0;
269
- });
270
- }
271
- /* ------------------------------------------------------------------ execution */
272
- export function runQuery(source, params, options, defects,
273
- /** Applied before the sparse fieldset, so a derived value cannot reintroduce a deselected
274
- * column. Mirrors the SQL stores so every backend projects identically. */
275
- transform) {
276
- let rows = [...source];
277
- if (options.softDeleteField !== undefined && !defects.has("SOFT_DELETE_LEAK")) {
278
- const field = options.softDeleteField;
279
- const mentionsTombstones = params.filter?.includes(field) === true;
280
- if (!mentionsTombstones)
281
- rows = rows.filter((row) => row[field] === null || row[field] === undefined);
282
- }
283
- const unfilteredCount = rows.length;
284
- if (params.filter !== undefined && params.filter !== "") {
285
- let predicate;
286
- try {
287
- predicate = parseFilter(params.filter, options, defects);
288
- }
289
- catch (error) {
290
- /* Correct behaviour is a 400. The defect lets the parser's own exception escape,
291
- * which the transport surfaces as a 500 — validation confused with crashing. */
292
- if (defects.has("ERROR_500_ON_BAD_FILTER")) {
293
- throw new Error(`unhandled filter parse failure: ${String(error)}`);
294
- }
295
- throw error;
296
- }
297
- const filtered = rows.filter(predicate);
298
- /* The "no results must mean the query was wrong" guard — falls back to the unfiltered set
299
- * and makes an empty result impossible to express. */
300
- rows = defects.has("EMPTY_RESULT_RETURNS_ALL") && filtered.length === 0 ? rows : filtered;
301
- }
302
- if (params.q !== undefined && params.q.trim() === "") {
303
- /* empty q is a no-op here; reject is the store's job when the catalog says so. */
304
- }
305
- else if (params.q !== undefined && params.q !== "" && !defects.has("SEARCH_IGNORED")) {
306
- const tokens = params.q
307
- .toLowerCase()
308
- .split(/\s+/)
309
- .filter((token) => token.length > 0);
310
- rows = rows.filter((row) => tokens.every((needle) => options.searchable.some((field) => String(row[field] ?? "")
311
- .toLowerCase()
312
- .includes(needle))));
313
- }
314
- const terms = params.order === undefined || params.order === "" ? [] : parseOrder(params.order, options, defects);
315
- if (defects.has("SORT_DESC_DROPS_NULLS")) {
316
- for (const term of terms) {
317
- if (!term.descending)
318
- continue;
319
- rows = rows.filter((row) => row[term.field] !== null && row[term.field] !== undefined);
320
- }
321
- }
322
- rows = applyOrder(rows, defects.has("ORDER_IGNORED") ? [] : terms, options, defects);
323
- const requestedLimit = params.limit ?? options.defaultLimit;
324
- const limit = defects.has("LIMIT_EXCEEDS_MAX") ? requestedLimit : Math.min(requestedLimit, options.maxLimit);
325
- const total = defects.has("COUNT_ALWAYS_ZERO")
326
- ? 0
327
- : defects.has("COUNT_IGNORES_FILTER")
328
- ? unfilteredCount
329
- : rows.length;
330
- let offset;
331
- let page;
332
- if (params.cursor !== undefined && params.cursor !== "") {
333
- const afterId = decodeCursor(params.cursor);
334
- const index = rows.findIndex((row) => String(row[options.identity]) === afterId);
335
- /* Under CURSOR_DRIFT the cursor resolves one row early, so the cursor walk and the
336
- * offset walk disagree — each looks self-consistent in isolation. */
337
- offset = index === -1 ? 0 : index + (defects.has("CURSOR_DRIFT") ? 0 : 1);
338
- page = null;
339
- }
340
- else {
341
- const requested = Math.max(params.page ?? 1, 1);
342
- offset = (requested - 1) * limit + (defects.has("OFF_BY_ONE_PAGE") && requested > 1 ? 1 : 0);
343
- page = requested;
344
- }
345
- /* LIMIT_IGNORED serves the whole set regardless of the requested page size — the caller's
346
- * paging loop then appears to complete in one request. */
347
- const window = defects.has("LIMIT_IGNORED") ? rows.slice(offset) : rows.slice(offset, offset + limit);
348
- const last = window.at(-1);
349
- const hasMore = defects.has("HASMORE_ALWAYS_FALSE") ? false : offset + window.length < rows.length;
350
- const projected = window
351
- .map((row) => (transform === undefined ? row : transform(row)))
352
- .map((row) => project(row, params.select, defects, options.excludedSelect ?? [], options.identity, options.selectUnknown));
353
- return {
354
- count: total,
355
- hasMore,
356
- items: projected,
357
- limit,
358
- nextCursor: hasMore && last !== undefined ? encodeCursor(String(last[options.identity])) : null,
359
- page,
360
483
  };
361
484
  }
362
- function project(row, select, defects, excluded = [], identity = "id", selectUnknown) {
363
- if (select === undefined || select === "" || select === "*")
364
- return { ...row };
365
- if (defects.has("SELECT_IGNORED"))
366
- return { ...row };
367
- const fields = select
368
- .split(",")
369
- .map((s) => s.trim())
370
- .filter(Boolean);
371
- const rejected = fields.find((f) => excluded.includes(f));
372
- if (rejected !== undefined) {
373
- throw new SqlError("invalid_select", `field "${rejected}" is not selectable`);
374
- }
375
- if (selectUnknown === "reject") {
376
- const bogus = fields.find((field) => !Object.hasOwn(row, field) && !field.includes("("));
377
- if (bogus !== undefined)
378
- throw new SqlError("invalid_select", `field "${bogus}" is not selectable`);
379
- }
380
- const out = {};
381
- for (const field of fields) {
382
- if (defects.has("SELECT_FIELD_MISSING") && field !== identity && Object.hasOwn(row, field))
383
- continue;
384
- if (Object.hasOwn(row, field))
385
- out[field] = row[field];
386
- }
387
- return out;
388
- }
389
- export function encodeCursor(id) {
390
- return Buffer.from(JSON.stringify({ id }), "utf8").toString("base64url");
391
- }
392
- export function decodeCursor(cursor) {
393
- try {
394
- const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
395
- if (typeof parsed.id !== "string")
396
- throw new Error("missing id");
397
- return parsed.id;
398
- }
399
- catch {
400
- throw new SqlError("invalid_cursor", "cursor is not a value produced by this API");
401
- }
402
- }
403
485
  //# sourceMappingURL=query.js.map