vastgrid 0.1.0 → 0.2.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 (312) hide show
  1. package/README.md +175 -13
  2. package/dist/canvas/geometry.d.ts +92 -0
  3. package/dist/canvas/geometry.d.ts.map +1 -0
  4. package/dist/canvas/geometry.js +197 -0
  5. package/dist/canvas/geometry.js.map +1 -0
  6. package/dist/canvas/grid-canvas.d.ts +161 -0
  7. package/dist/canvas/grid-canvas.d.ts.map +1 -0
  8. package/dist/canvas/grid-canvas.js +1146 -0
  9. package/dist/canvas/grid-canvas.js.map +1 -0
  10. package/dist/canvas/hooks.d.ts +61 -0
  11. package/dist/canvas/hooks.d.ts.map +1 -0
  12. package/dist/canvas/hooks.js +2 -0
  13. package/dist/canvas/hooks.js.map +1 -0
  14. package/dist/canvas/keys.d.ts +26 -0
  15. package/dist/canvas/keys.d.ts.map +1 -0
  16. package/dist/canvas/keys.js +133 -0
  17. package/dist/canvas/keys.js.map +1 -0
  18. package/dist/canvas/layout.d.ts +26 -0
  19. package/dist/canvas/layout.d.ts.map +1 -0
  20. package/dist/canvas/layout.js +73 -0
  21. package/dist/canvas/layout.js.map +1 -0
  22. package/dist/canvas/scroll.d.ts +31 -0
  23. package/dist/canvas/scroll.d.ts.map +1 -0
  24. package/dist/canvas/scroll.js +111 -0
  25. package/dist/canvas/scroll.js.map +1 -0
  26. package/dist/canvas/text.d.ts +23 -0
  27. package/dist/canvas/text.d.ts.map +1 -0
  28. package/dist/canvas/text.js +142 -0
  29. package/dist/canvas/text.js.map +1 -0
  30. package/dist/{theme.d.ts → canvas/theme.d.ts} +8 -0
  31. package/dist/canvas/theme.d.ts.map +1 -0
  32. package/dist/canvas/theme.js +62 -0
  33. package/dist/canvas/theme.js.map +1 -0
  34. package/dist/core/cache.d.ts +48 -0
  35. package/dist/core/cache.d.ts.map +1 -0
  36. package/dist/core/cache.js +222 -0
  37. package/dist/core/cache.js.map +1 -0
  38. package/dist/core/checked.d.ts +20 -0
  39. package/dist/core/checked.d.ts.map +1 -0
  40. package/dist/core/checked.js +61 -0
  41. package/dist/core/checked.js.map +1 -0
  42. package/dist/core/client.d.ts +33 -0
  43. package/dist/core/client.d.ts.map +1 -0
  44. package/dist/core/client.js +103 -0
  45. package/dist/core/client.js.map +1 -0
  46. package/dist/core/conditions.d.ts +48 -0
  47. package/dist/core/conditions.d.ts.map +1 -0
  48. package/dist/core/conditions.js +105 -0
  49. package/dist/core/conditions.js.map +1 -0
  50. package/dist/core/evaluate.d.ts +20 -0
  51. package/dist/core/evaluate.d.ts.map +1 -0
  52. package/dist/core/evaluate.js +210 -0
  53. package/dist/core/evaluate.js.map +1 -0
  54. package/dist/core/filters.d.ts +51 -0
  55. package/dist/core/filters.d.ts.map +1 -0
  56. package/dist/core/filters.js +303 -0
  57. package/dist/core/filters.js.map +1 -0
  58. package/dist/core/format.d.ts +14 -0
  59. package/dist/core/format.d.ts.map +1 -0
  60. package/dist/core/format.js +303 -0
  61. package/dist/core/format.js.map +1 -0
  62. package/dist/core/measures.d.ts +26 -0
  63. package/dist/core/measures.d.ts.map +1 -0
  64. package/dist/core/measures.js +105 -0
  65. package/dist/core/measures.js.map +1 -0
  66. package/dist/core/request-ids.d.ts +20 -0
  67. package/dist/core/request-ids.d.ts.map +1 -0
  68. package/dist/core/request-ids.js +55 -0
  69. package/dist/core/request-ids.js.map +1 -0
  70. package/dist/core/rows.d.ts +79 -0
  71. package/dist/core/rows.d.ts.map +1 -0
  72. package/dist/core/rows.js +173 -0
  73. package/dist/core/rows.js.map +1 -0
  74. package/dist/core/store.d.ts +233 -0
  75. package/dist/core/store.d.ts.map +1 -0
  76. package/dist/core/store.js +1162 -0
  77. package/dist/core/store.js.map +1 -0
  78. package/dist/core/totals.d.ts +24 -0
  79. package/dist/core/totals.d.ts.map +1 -0
  80. package/dist/core/totals.js +49 -0
  81. package/dist/core/totals.js.map +1 -0
  82. package/dist/core/trace.d.ts +7 -0
  83. package/dist/core/trace.d.ts.map +1 -0
  84. package/dist/core/trace.js +26 -0
  85. package/dist/core/trace.js.map +1 -0
  86. package/dist/core/transports.d.ts +50 -0
  87. package/dist/core/transports.d.ts.map +1 -0
  88. package/dist/core/transports.js +160 -0
  89. package/dist/core/transports.js.map +1 -0
  90. package/dist/core/types.d.ts +150 -0
  91. package/dist/core/types.d.ts.map +1 -0
  92. package/dist/core/types.js +45 -0
  93. package/dist/core/types.js.map +1 -0
  94. package/dist/core.d.ts +12 -0
  95. package/dist/core.d.ts.map +1 -0
  96. package/dist/core.js +11 -0
  97. package/dist/core.js.map +1 -0
  98. package/dist/index.d.ts +15 -6
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +13 -7
  101. package/dist/index.js.map +1 -1
  102. package/dist/react/accessible-rows.d.ts +13 -0
  103. package/dist/react/accessible-rows.d.ts.map +1 -0
  104. package/dist/react/accessible-rows.js +320 -0
  105. package/dist/react/accessible-rows.js.map +1 -0
  106. package/dist/react/cell-editor.d.ts +22 -0
  107. package/dist/react/cell-editor.d.ts.map +1 -0
  108. package/dist/react/cell-editor.js +431 -0
  109. package/dist/react/cell-editor.js.map +1 -0
  110. package/dist/react/cell-menu.d.ts +18 -0
  111. package/dist/react/cell-menu.d.ts.map +1 -0
  112. package/dist/react/cell-menu.js +299 -0
  113. package/dist/react/cell-menu.js.map +1 -0
  114. package/dist/react/column-menu.d.ts +16 -0
  115. package/dist/react/column-menu.d.ts.map +1 -0
  116. package/dist/react/column-menu.js +1311 -0
  117. package/dist/react/column-menu.js.map +1 -0
  118. package/dist/react/columns-menu.d.ts +7 -0
  119. package/dist/react/columns-menu.d.ts.map +1 -0
  120. package/dist/react/columns-menu.js +249 -0
  121. package/dist/react/columns-menu.js.map +1 -0
  122. package/dist/react/conditions-menu.d.ts +10 -0
  123. package/dist/react/conditions-menu.d.ts.map +1 -0
  124. package/dist/react/conditions-menu.js +795 -0
  125. package/dist/react/conditions-menu.js.map +1 -0
  126. package/dist/react/context.d.ts +29 -0
  127. package/dist/react/context.d.ts.map +1 -0
  128. package/dist/react/context.js +18 -0
  129. package/dist/react/context.js.map +1 -0
  130. package/dist/react/data-grid.d.ts +79 -0
  131. package/dist/react/data-grid.d.ts.map +1 -0
  132. package/dist/react/data-grid.js +653 -0
  133. package/dist/react/data-grid.js.map +1 -0
  134. package/dist/react/detail-panel.d.ts +22 -0
  135. package/dist/react/detail-panel.d.ts.map +1 -0
  136. package/dist/react/detail-panel.js +242 -0
  137. package/dist/react/detail-panel.js.map +1 -0
  138. package/dist/react/export-menu.d.ts +11 -0
  139. package/dist/react/export-menu.d.ts.map +1 -0
  140. package/dist/react/export-menu.js +304 -0
  141. package/dist/react/export-menu.js.map +1 -0
  142. package/dist/react/find-bar.d.ts +11 -0
  143. package/dist/react/find-bar.d.ts.map +1 -0
  144. package/dist/react/find-bar.js +303 -0
  145. package/dist/react/find-bar.js.map +1 -0
  146. package/dist/react/format-form.d.ts +12 -0
  147. package/dist/react/format-form.d.ts.map +1 -0
  148. package/dist/react/format-form.js +863 -0
  149. package/dist/react/format-form.js.map +1 -0
  150. package/dist/react/formula-menu.d.ts +6 -0
  151. package/dist/react/formula-menu.d.ts.map +1 -0
  152. package/dist/react/formula-menu.js +343 -0
  153. package/dist/react/formula-menu.js.map +1 -0
  154. package/dist/react/grid-body.d.ts +33 -0
  155. package/dist/react/grid-body.d.ts.map +1 -0
  156. package/dist/react/grid-body.js +1389 -0
  157. package/dist/react/grid-body.js.map +1 -0
  158. package/dist/react/group-menu.d.ts +7 -0
  159. package/dist/react/group-menu.d.ts.map +1 -0
  160. package/dist/react/group-menu.js +508 -0
  161. package/dist/react/group-menu.js.map +1 -0
  162. package/dist/react/hooks.d.ts +33 -0
  163. package/dist/react/hooks.d.ts.map +1 -0
  164. package/dist/react/hooks.js +161 -0
  165. package/dist/react/hooks.js.map +1 -0
  166. package/dist/react/messages.d.ts +180 -0
  167. package/dist/react/messages.d.ts.map +1 -0
  168. package/dist/react/messages.js +414 -0
  169. package/dist/react/messages.js.map +1 -0
  170. package/dist/react/save.d.ts +9 -0
  171. package/dist/react/save.d.ts.map +1 -0
  172. package/dist/react/save.js +102 -0
  173. package/dist/react/save.js.map +1 -0
  174. package/dist/react/static-rows.d.ts +9 -0
  175. package/dist/react/static-rows.d.ts.map +1 -0
  176. package/dist/react/static-rows.js +176 -0
  177. package/dist/react/static-rows.js.map +1 -0
  178. package/dist/react/status-bar.d.ts +12 -0
  179. package/dist/react/status-bar.d.ts.map +1 -0
  180. package/dist/react/status-bar.js +413 -0
  181. package/dist/react/status-bar.js.map +1 -0
  182. package/dist/react/toolbar-popover.d.ts +14 -0
  183. package/dist/react/toolbar-popover.d.ts.map +1 -0
  184. package/dist/react/toolbar-popover.js +143 -0
  185. package/dist/react/toolbar-popover.js.map +1 -0
  186. package/dist/react/toolbar.d.ts +13 -0
  187. package/dist/react/toolbar.d.ts.map +1 -0
  188. package/dist/react/toolbar.js +344 -0
  189. package/dist/react/toolbar.js.map +1 -0
  190. package/dist/server.d.ts +22 -0
  191. package/dist/server.d.ts.map +1 -0
  192. package/dist/server.js +45 -0
  193. package/dist/server.js.map +1 -0
  194. package/dist/styles.css +496 -316
  195. package/package.json +43 -18
  196. package/src/canvas/geometry.ts +197 -0
  197. package/src/canvas/grid-canvas.ts +1222 -0
  198. package/src/canvas/hooks.ts +61 -0
  199. package/src/canvas/keys.ts +96 -0
  200. package/src/canvas/layout.ts +77 -0
  201. package/src/canvas/scroll.ts +120 -0
  202. package/src/canvas/text.ts +155 -0
  203. package/src/{theme.ts → canvas/theme.ts} +14 -1
  204. package/src/core/cache.ts +247 -0
  205. package/src/core/checked.ts +64 -0
  206. package/src/core/client.ts +103 -0
  207. package/src/core/conditions.ts +89 -0
  208. package/src/core/evaluate.ts +221 -0
  209. package/src/core/filters.ts +268 -0
  210. package/src/core/format.ts +309 -0
  211. package/src/core/measures.ts +99 -0
  212. package/src/core/request-ids.ts +54 -0
  213. package/src/core/rows.ts +213 -0
  214. package/src/core/store.ts +1122 -0
  215. package/src/core/totals.ts +59 -0
  216. package/src/core/trace.ts +44 -0
  217. package/src/core/transports.ts +177 -0
  218. package/src/core/types.ts +168 -0
  219. package/src/core.ts +39 -0
  220. package/src/index.ts +24 -12
  221. package/src/react/accessible-rows.tsx +242 -0
  222. package/src/react/cell-editor.tsx +216 -0
  223. package/src/react/cell-menu.tsx +166 -0
  224. package/src/react/column-menu.tsx +619 -0
  225. package/src/react/columns-menu.tsx +116 -0
  226. package/src/react/conditions-menu.tsx +289 -0
  227. package/src/react/context.ts +33 -0
  228. package/src/react/data-grid.tsx +286 -0
  229. package/src/react/detail-panel.tsx +92 -0
  230. package/src/react/export-menu.tsx +112 -0
  231. package/src/react/find-bar.tsx +112 -0
  232. package/src/react/format-form.tsx +421 -0
  233. package/src/react/formula-menu.tsx +163 -0
  234. package/src/react/grid-body.tsx +744 -0
  235. package/src/react/group-menu.tsx +214 -0
  236. package/src/react/hooks.ts +104 -0
  237. package/src/react/messages.ts +556 -0
  238. package/src/react/save.ts +42 -0
  239. package/src/react/static-rows.tsx +59 -0
  240. package/src/react/status-bar.tsx +175 -0
  241. package/src/react/toolbar-popover.tsx +69 -0
  242. package/src/react/toolbar.tsx +152 -0
  243. package/src/server.ts +58 -0
  244. package/src/styles.css +496 -316
  245. package/dist/components/bars.d.ts +0 -14
  246. package/dist/components/bars.d.ts.map +0 -1
  247. package/dist/components/bars.js +0 -67
  248. package/dist/components/bars.js.map +0 -1
  249. package/dist/components/cell-inspector.d.ts +0 -11
  250. package/dist/components/cell-inspector.d.ts.map +0 -1
  251. package/dist/components/cell-inspector.js +0 -37
  252. package/dist/components/cell-inspector.js.map +0 -1
  253. package/dist/components/columns-panel.d.ts +0 -12
  254. package/dist/components/columns-panel.d.ts.map +0 -1
  255. package/dist/components/columns-panel.js +0 -11
  256. package/dist/components/columns-panel.js.map +0 -1
  257. package/dist/components/filter-panel.d.ts +0 -17
  258. package/dist/components/filter-panel.d.ts.map +0 -1
  259. package/dist/components/filter-panel.js +0 -165
  260. package/dist/components/filter-panel.js.map +0 -1
  261. package/dist/components/find-bar.d.ts +0 -9
  262. package/dist/components/find-bar.d.ts.map +0 -1
  263. package/dist/components/find-bar.js +0 -46
  264. package/dist/components/find-bar.js.map +0 -1
  265. package/dist/components/popover.d.ts +0 -38
  266. package/dist/components/popover.d.ts.map +0 -1
  267. package/dist/components/popover.js +0 -104
  268. package/dist/components/popover.js.map +0 -1
  269. package/dist/components/stats-panel.d.ts +0 -12
  270. package/dist/components/stats-panel.d.ts.map +0 -1
  271. package/dist/components/stats-panel.js +0 -40
  272. package/dist/components/stats-panel.js.map +0 -1
  273. package/dist/controller.d.ts +0 -218
  274. package/dist/controller.d.ts.map +0 -1
  275. package/dist/controller.js +0 -788
  276. package/dist/controller.js.map +0 -1
  277. package/dist/data-grid.d.ts +0 -51
  278. package/dist/data-grid.d.ts.map +0 -1
  279. package/dist/data-grid.js +0 -698
  280. package/dist/data-grid.js.map +0 -1
  281. package/dist/labels.d.ts +0 -9
  282. package/dist/labels.d.ts.map +0 -1
  283. package/dist/labels.js +0 -38
  284. package/dist/labels.js.map +0 -1
  285. package/dist/messages.d.ts +0 -99
  286. package/dist/messages.d.ts.map +0 -1
  287. package/dist/messages.js +0 -276
  288. package/dist/messages.js.map +0 -1
  289. package/dist/renderer.d.ts +0 -52
  290. package/dist/renderer.d.ts.map +0 -1
  291. package/dist/renderer.js +0 -522
  292. package/dist/renderer.js.map +0 -1
  293. package/dist/text.d.ts +0 -14
  294. package/dist/text.d.ts.map +0 -1
  295. package/dist/text.js +0 -63
  296. package/dist/text.js.map +0 -1
  297. package/dist/theme.d.ts.map +0 -1
  298. package/dist/theme.js +0 -54
  299. package/dist/theme.js.map +0 -1
  300. package/src/components/bars.tsx +0 -179
  301. package/src/components/cell-inspector.tsx +0 -70
  302. package/src/components/columns-panel.tsx +0 -59
  303. package/src/components/filter-panel.tsx +0 -290
  304. package/src/components/find-bar.tsx +0 -104
  305. package/src/components/popover.tsx +0 -194
  306. package/src/components/stats-panel.tsx +0 -153
  307. package/src/controller.ts +0 -887
  308. package/src/data-grid.tsx +0 -1029
  309. package/src/labels.ts +0 -46
  310. package/src/messages.ts +0 -390
  311. package/src/renderer.ts +0 -535
  312. package/src/text.ts +0 -55
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Evaluates the `when` of a style rule (§5.1 of the specification) over a row of the view on screen, in the grid:
3
+ * the logic and comparisons that the menus write (§4.4: `and`, `or`, `not`, `coalesce`, comparisons, `in`,
4
+ * `is_null`, `is_blank`, `contains`, `starts_with` and `ends_with` over `lower` and `text`), with nulls as §3.6 has
5
+ * them. Other expressions are not evaluated here, and their rules are skipped.
6
+ */
7
+ import { type Column, columnsOf, type Expr, floatOf, type JsonValue, textOf } from "vastgrid-spec";
8
+
9
+ /** A value of an expression: a JSON value with its column's type, or a string or boolean that it made. */
10
+ interface Value {
11
+ value: JsonValue;
12
+ type: Column["type"] | "text";
13
+ }
14
+
15
+ /** Reads the value of a column of the row; undefined while it is not read. */
16
+ export type RowReader = (column: string) => JsonValue | undefined;
17
+
18
+ /** A compiled `when`: true, false or null for a row, or undefined while a value it needs is not read. */
19
+ export type Rule = (row: RowReader) => boolean | null | undefined;
20
+
21
+ const NUMERIC = new Set(["integer", "bigint", "decimal", "float"]);
22
+
23
+ /** Compares exact decimal texts (`-12.50`) by their digits. */
24
+ function compareDecimal(a: string, b: string): number {
25
+ const sign = (text: string) => (text.startsWith("-") ? -1 : 1);
26
+ const [ai, af = ""] = a.replace(/^[+-]/, "").split(".");
27
+ const [bi, bf = ""] = b.replace(/^[+-]/, "").split(".");
28
+ const isZero = (i: string, f: string) => /^0*$/.test(i) && /^0*$/.test(f);
29
+ const as = isZero(ai, af) ? 0 : sign(a);
30
+ const bs = isZero(bi, bf) ? 0 : sign(b);
31
+ if (as !== bs) return as - bs;
32
+ const whole = (x: string) => x.replace(/^0+(?=\d)/, "");
33
+ const [x, y] = [whole(ai), whole(bi)];
34
+ const width = Math.max(af.length, bf.length);
35
+ const left = `${x.padStart(Math.max(x.length, y.length), "0")}${af.padEnd(width, "0")}`;
36
+ const right = `${y.padStart(Math.max(x.length, y.length), "0")}${bf.padEnd(width, "0")}`;
37
+ return (left < right ? -1 : left > right ? 1 : 0) * as;
38
+ }
39
+
40
+ /** Orders two values of one kind (§3.1), or null when they do not compare. */
41
+ function compare(a: Value, b: Value): number | null {
42
+ if (a.value === null || b.value === null) return null;
43
+ if (NUMERIC.has(a.type) || NUMERIC.has(b.type) || (typeof a.value === "number" && typeof b.value === "number")) {
44
+ const float =
45
+ a.type === "float" || b.type === "float" || typeof a.value === "number" || typeof b.value === "number";
46
+ if (float && !(typeof a.value === "string" && typeof b.value === "string")) {
47
+ const x = floatOf(a.value);
48
+ const y = floatOf(b.value);
49
+ // NaN is the greatest number (§3.1).
50
+ if (Number.isNaN(x) || Number.isNaN(y)) return Number.isNaN(x) ? (Number.isNaN(y) ? 0 : 1) : -1;
51
+ return x < y ? -1 : x > y ? 1 : 0;
52
+ }
53
+ return compareDecimal(String(a.value), String(b.value));
54
+ }
55
+ if (typeof a.value === "boolean" && typeof b.value === "boolean") return Number(a.value) - Number(b.value);
56
+ if (typeof a.value === "string" && typeof b.value === "string") {
57
+ // Code point order.
58
+ const x = [...a.value];
59
+ const y = [...b.value];
60
+ for (let index = 0; index < Math.min(x.length, y.length); index++) {
61
+ const difference = (x[index].codePointAt(0) ?? 0) - (y[index].codePointAt(0) ?? 0);
62
+ if (difference !== 0) return difference;
63
+ }
64
+ return x.length - y.length;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** `lower` (§3.4): each code point by itself, kept when its lower case is not one code point. */
70
+ function lower(text: string): string {
71
+ let out = "";
72
+ for (const point of text) {
73
+ if (point === "İ") {
74
+ out += "i";
75
+ continue;
76
+ }
77
+ const mapped = point.toLowerCase();
78
+ out += [...mapped].length === 1 ? mapped : point;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ type Compiled = (row: RowReader) => Value | undefined;
84
+
85
+ const PENDING = Symbol("pending");
86
+
87
+ function compile(expr: Expr, columns: ReadonlyMap<string, Column>): Compiled | null {
88
+ if ("col" in expr) {
89
+ const column = columns.get(expr.col);
90
+ if (!column) return null;
91
+ return (row) => {
92
+ const value = row(column.name);
93
+ return value === undefined ? undefined : { value, type: column.type };
94
+ };
95
+ }
96
+ if ("lit" in expr) {
97
+ const value: Value = {
98
+ value: expr.lit,
99
+ type: expr.type ?? (typeof expr.lit === "number" ? (Number.isInteger(expr.lit) ? "bigint" : "float") : "text"),
100
+ };
101
+ return () => value;
102
+ }
103
+ if ("search" in expr) return null;
104
+ const args = expr.args.map((arg) => compile(arg, columns));
105
+ if (args.some((arg) => arg === null)) return null;
106
+ const parts = args as Compiled[];
107
+ const read = (row: RowReader) => {
108
+ const values: Value[] = [];
109
+ for (const part of parts) {
110
+ const value = part(row);
111
+ if (value === undefined) throw PENDING;
112
+ values.push(value);
113
+ }
114
+ return values;
115
+ };
116
+ const truth = (value: boolean | null): Value => ({ value, type: "boolean" });
117
+ const run = (body: (values: Value[]) => Value): Compiled => {
118
+ return (row) => {
119
+ try {
120
+ return body(read(row));
121
+ } catch (error) {
122
+ if (error === PENDING) return undefined;
123
+ throw error;
124
+ }
125
+ };
126
+ };
127
+ switch (expr.fn) {
128
+ case "and":
129
+ case "or": {
130
+ const stop = expr.fn === "or";
131
+ return run((values) => {
132
+ let unknown = false;
133
+ for (const { value } of values) {
134
+ if (value === stop) return truth(stop);
135
+ if (value === null) unknown = true;
136
+ }
137
+ return truth(unknown ? null : !stop);
138
+ });
139
+ }
140
+ case "not":
141
+ return run(([a]) => truth(a.value === null ? null : !a.value));
142
+ case "coalesce":
143
+ return run((values) => values.find((item) => item.value !== null) ?? values[values.length - 1]);
144
+ case "is_null":
145
+ return run(([a]) => truth(a.value === null));
146
+ case "is_blank":
147
+ return run(([a]) => truth(a.value === null || (a.type === "string" && a.value === "")));
148
+ case "eq":
149
+ case "ne":
150
+ case "lt":
151
+ case "le":
152
+ case "gt":
153
+ case "ge": {
154
+ const fn = expr.fn;
155
+ return run(([a, b]) => {
156
+ const order = compare(a, b);
157
+ if (order === null) return truth(null);
158
+ const result =
159
+ fn === "eq"
160
+ ? order === 0
161
+ : fn === "ne"
162
+ ? order !== 0
163
+ : fn === "lt"
164
+ ? order < 0
165
+ : fn === "le"
166
+ ? order <= 0
167
+ : fn === "gt"
168
+ ? order > 0
169
+ : order >= 0;
170
+ return truth(result);
171
+ });
172
+ }
173
+ case "in":
174
+ return run(([a, ...list]) => {
175
+ if (a.value === null) return truth(null);
176
+ return truth(list.some((item) => compare(a, item) === 0));
177
+ });
178
+ case "text":
179
+ return run(([a]) => ({
180
+ value: a.type === "text" ? a.value : textOf(a.value, { type: a.type } as Column),
181
+ type: "text",
182
+ }));
183
+ case "lower":
184
+ return run(([a]) => ({ value: typeof a.value === "string" ? lower(a.value) : null, type: "text" }));
185
+ case "contains":
186
+ case "starts_with":
187
+ case "ends_with": {
188
+ const fn = expr.fn;
189
+ return run(([a, b]) => {
190
+ if (typeof a.value !== "string" || typeof b.value !== "string") return truth(null);
191
+ return truth(
192
+ fn === "contains"
193
+ ? a.value.includes(b.value)
194
+ : fn === "starts_with"
195
+ ? a.value.startsWith(b.value)
196
+ : a.value.endsWith(b.value),
197
+ );
198
+ });
199
+ }
200
+ default:
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * A rule's `when` compiled over the columns of the view, or null when it names a column the view does not have or a
207
+ * function that the grid does not evaluate. `columns` lists the columns it reads.
208
+ */
209
+ export function compileRule(when: Expr, view: readonly Column[]): { rule: Rule; columns: string[] } | null {
210
+ const byName = new Map(view.map((column) => [column.name, column]));
211
+ const compiled = compile(when, byName);
212
+ if (!compiled) return null;
213
+ return {
214
+ rule: (row) => {
215
+ const result = compiled(row);
216
+ if (result === undefined) return undefined;
217
+ return typeof result.value === "boolean" ? result.value : null;
218
+ },
219
+ columns: [...columnsOf(when)],
220
+ };
221
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The filter of one column as the column menu edits it: values ticked in a list, or a condition. Each is written
3
+ * as the parts of `where` that `filter.column` sets (§6.2.1 of the specification), and read back from them.
4
+ */
5
+ import {
6
+ type Column,
7
+ columnsOf,
8
+ type Expr,
9
+ type FunctionName,
10
+ type JsonValue,
11
+ joinParts,
12
+ type Literal,
13
+ type LogicalType,
14
+ type Plan,
15
+ partsOf,
16
+ readCellText,
17
+ } from "vastgrid-spec";
18
+
19
+ export type FilterOperator =
20
+ | "eq"
21
+ | "ne"
22
+ | "lt"
23
+ | "le"
24
+ | "gt"
25
+ | "ge"
26
+ | "between"
27
+ | "contains"
28
+ | "notContains"
29
+ | "startsWith"
30
+ | "endsWith"
31
+ | "blank"
32
+ | "notBlank";
33
+
34
+ export type ColumnFilter =
35
+ /** Rows with one of `values`, or blank (§3.2) with `blank`; with `exclude`, the other rows. */
36
+ | { kind: "values"; values: JsonValue[]; blank: boolean; exclude: boolean }
37
+ /** A condition on typed text: `first`, and `second` for `between`; an empty text leaves that side out. */
38
+ | { kind: "condition"; operator: FilterOperator; first: string; second: string };
39
+
40
+ const ORDERED: FilterOperator[] = ["eq", "ne", "gt", "ge", "lt", "le", "between", "blank", "notBlank"];
41
+ const TEXT: FilterOperator[] = ["contains", "notContains", "startsWith", "endsWith", "eq", "ne", "blank", "notBlank"];
42
+
43
+ /** The operators offered for a column's type. */
44
+ export function operatorsFor(type: LogicalType): FilterOperator[] {
45
+ if (type === "string") return TEXT;
46
+ if (type === "boolean" || type === "binary" || type === "interval") return ["blank", "notBlank"];
47
+ if (type === "uuid") return ["eq", "ne", "blank", "notBlank"];
48
+ return ORDERED;
49
+ }
50
+
51
+ /** Whether an operator takes no text. */
52
+ export function takesNoValue(operator: FilterOperator) {
53
+ return operator === "blank" || operator === "notBlank";
54
+ }
55
+
56
+ /** A literal of a value of a column (§2.5): values that JSON writes as strings carry the column's type. */
57
+ function literalOf(value: JsonValue, column: Column): Literal {
58
+ if (typeof value === "string" && column.type !== "string") return { lit: value, type: column.type };
59
+ return { lit: value };
60
+ }
61
+
62
+ function call(fn: FunctionName, ...args: Expr[]): Expr {
63
+ return { fn, args };
64
+ }
65
+
66
+ /** Rows for which `expr` is not true: null counts as not matching, so `not` keeps the rows where it is null. */
67
+ function notMatching(expr: Expr): Expr {
68
+ return call("not", call("coalesce", expr, { lit: false }));
69
+ }
70
+
71
+ const TEXT_FUNCTIONS: Partial<Record<FilterOperator, FunctionName>> = {
72
+ contains: "contains",
73
+ notContains: "contains",
74
+ startsWith: "starts_with",
75
+ endsWith: "ends_with",
76
+ };
77
+
78
+ export class FilterTextError extends Error {
79
+ constructor(readonly side: "first" | "second") {
80
+ super(`The ${side} value is not a value of the column.`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The parts of `where` for a column's filter; none when it keeps every row. Text that is not a value of the column
86
+ * throws a `FilterTextError` (§7.3 reads it as a cell's text is read).
87
+ */
88
+ export function filterParts(filter: ColumnFilter, column: Column): Expr[] {
89
+ const c: Expr = { col: column.name };
90
+ if (filter.kind === "values") {
91
+ const parts: Expr[] = [];
92
+ if (filter.values.length > 0) parts.push(call("in", c, ...filter.values.map((value) => literalOf(value, column))));
93
+ if (filter.blank) parts.push(call("is_blank", c));
94
+ const matching = parts.length === 0 ? null : parts.length === 1 ? parts[0] : call("or", ...parts);
95
+ if (filter.exclude) return matching ? [notMatching(matching)] : [];
96
+ return [matching ?? { lit: false }];
97
+ }
98
+ const { operator } = filter;
99
+ if (operator === "blank") return [call("is_blank", c)];
100
+ if (operator === "notBlank") return [call("not", call("is_blank", c))];
101
+ const text = TEXT_FUNCTIONS[operator];
102
+ if (text) {
103
+ if (filter.first === "") return [];
104
+ const expr = call(text, call("lower", call("text", c)), call("lower", { lit: filter.first }));
105
+ return [operator === "notContains" ? notMatching(expr) : expr];
106
+ }
107
+ const read = (input: string, side: "first" | "second") => {
108
+ if (input.trim() === "") return null;
109
+ const result = readCellText(input, column, { grouping: true });
110
+ if (!result.ok || result.value === null) throw new FilterTextError(side);
111
+ return literalOf(result.value, column);
112
+ };
113
+ if (operator === "between") {
114
+ const low = read(filter.first, "first");
115
+ const high = read(filter.second, "second");
116
+ return [...(low ? [call("ge", c, low)] : []), ...(high ? [call("le", c, high)] : [])];
117
+ }
118
+ const value = read(filter.first, "first");
119
+ return value ? [call(operator as FunctionName, c, value)] : [];
120
+ }
121
+
122
+ function isColumn(expr: Expr | undefined, name: string) {
123
+ return expr !== undefined && "col" in expr && expr.col === name;
124
+ }
125
+
126
+ function isLiteral(expr: Expr | undefined): expr is Literal {
127
+ return expr !== undefined && "lit" in expr;
128
+ }
129
+
130
+ function isCall(expr: Expr | undefined, fn: FunctionName): expr is { fn: FunctionName; args: Expr[] } {
131
+ return expr !== undefined && "fn" in expr && expr.fn === fn;
132
+ }
133
+
134
+ /** The text of a literal as the menu shows it to edit. */
135
+ function literalText(literal: Literal): string {
136
+ return literal.lit === null ? "" : String(literal.lit);
137
+ }
138
+
139
+ /** `in(c, …)`, `is_blank(c)` or the `or` of both, as values. */
140
+ function readValues(expr: Expr, name: string): { values: JsonValue[]; blank: boolean } | null {
141
+ const parts = isCall(expr, "or") ? expr.args : [expr];
142
+ let values: JsonValue[] | null = null;
143
+ let blank = false;
144
+ for (const part of parts) {
145
+ if (isCall(part, "in") && isColumn(part.args[0], name) && values === null && part.args.slice(1).every(isLiteral)) {
146
+ values = (part.args.slice(1) as Literal[]).map((literal) => literal.lit);
147
+ } else if (isCall(part, "is_blank") && isColumn(part.args[0], name) && !blank) {
148
+ blank = true;
149
+ } else {
150
+ return null;
151
+ }
152
+ }
153
+ return { values: values ?? [], blank };
154
+ }
155
+
156
+ /** `contains(lower(text(c)), lower(lit))` and the like: the operator and the text. */
157
+ function readText(expr: Expr, name: string): { fn: FunctionName; text: string } | null {
158
+ if (!("fn" in expr) || !["contains", "starts_with", "ends_with"].includes(expr.fn)) return null;
159
+ const [haystack, needle] = expr.args;
160
+ if (!isCall(haystack, "lower") || !isCall(haystack.args[0], "text") || !isColumn(haystack.args[0].args[0], name)) {
161
+ return null;
162
+ }
163
+ if (!isCall(needle, "lower") || !isLiteral(needle.args[0]) || typeof needle.args[0].lit !== "string") return null;
164
+ return { fn: expr.fn, text: needle.args[0].lit };
165
+ }
166
+
167
+ const COMPARISONS: FilterOperator[] = ["eq", "ne", "lt", "le", "gt", "ge"];
168
+
169
+ /**
170
+ * The filter that the conditions of a column (§6.2.1) make, when the menu wrote them or they have the same shape;
171
+ * null when there are none, and `"other"` for conditions written otherwise (an agent, a saved view).
172
+ */
173
+ export function readFilter(parts: readonly Expr[], name: string): ColumnFilter | "other" | null {
174
+ if (parts.length === 0) return null;
175
+ if (parts.length === 2) {
176
+ const [low, high] = parts;
177
+ if (
178
+ isCall(low, "ge") &&
179
+ isCall(high, "le") &&
180
+ isColumn(low.args[0], name) &&
181
+ isColumn(high.args[0], name) &&
182
+ isLiteral(low.args[1]) &&
183
+ isLiteral(high.args[1])
184
+ ) {
185
+ return {
186
+ kind: "condition",
187
+ operator: "between",
188
+ first: literalText(low.args[1]),
189
+ second: literalText(high.args[1]),
190
+ };
191
+ }
192
+ return "other";
193
+ }
194
+ if (parts.length !== 1) return "other";
195
+ const [part] = parts;
196
+ if (isLiteral(part) && part.lit === false) return { kind: "values", values: [], blank: false, exclude: false };
197
+ if (isCall(part, "is_blank") && isColumn(part.args[0], name)) {
198
+ return { kind: "condition", operator: "blank", first: "", second: "" };
199
+ }
200
+ if (isCall(part, "not")) {
201
+ const inner = part.args[0];
202
+ if (isCall(inner, "is_blank") && isColumn(inner.args[0], name)) {
203
+ return { kind: "condition", operator: "notBlank", first: "", second: "" };
204
+ }
205
+ if (isCall(inner, "coalesce") && isLiteral(inner.args[1]) && inner.args[1].lit === false) {
206
+ const matching = inner.args[0];
207
+ const values = readValues(matching, name);
208
+ if (values) return { kind: "values", ...values, exclude: true };
209
+ const text = readText(matching, name);
210
+ if (text?.fn === "contains") return { kind: "condition", operator: "notContains", first: text.text, second: "" };
211
+ }
212
+ return "other";
213
+ }
214
+ const values = readValues(part, name);
215
+ if (values && values.values.length > 0) return { kind: "values", ...values, exclude: false };
216
+ const text = readText(part, name);
217
+ if (text) {
218
+ const operator = text.fn === "contains" ? "contains" : text.fn === "starts_with" ? "startsWith" : "endsWith";
219
+ return { kind: "condition", operator, first: text.text, second: "" };
220
+ }
221
+ const comparison = COMPARISONS.find((fn) => isCall(part, fn as FunctionName));
222
+ if (comparison && isCall(part, comparison as FunctionName)) {
223
+ const [left, right] = part.args;
224
+ // One side of a range is read as the comparison it is.
225
+ if (isColumn(left, name) && isLiteral(right)) {
226
+ return { kind: "condition", operator: comparison, first: literalText(right), second: "" };
227
+ }
228
+ }
229
+ return "other";
230
+ }
231
+
232
+ /** The conditions of a column (§6.2.1): the parts of `where` that are not the search and refer to it and no other. */
233
+ export function conditionsOf(where: Expr | undefined, name: string): Expr[] {
234
+ return partsOf(where).filter((part) => {
235
+ if ("search" in part) return false;
236
+ const names = columnsOf(part);
237
+ return names.size === 1 && names.has(name);
238
+ });
239
+ }
240
+
241
+ /**
242
+ * The plan whose rows are a column's values and how many rows have each, the most common first: the rows of
243
+ * `plan` without its conditions on the column (so that the list holds the values that other filters leave), and
244
+ * with those whose text contains `search`, ignoring letter case. Null when the plan groups rows.
245
+ */
246
+ export function valuesPlan(plan: Plan, name: string, search: string): { plan: Plan; count: string } | null {
247
+ if (plan.aggregate) return null;
248
+ const own = new Set(conditionsOf(plan.where, name));
249
+ const parts = partsOf(plan.where).filter((part) => !own.has(part));
250
+ if (search.trim() !== "") {
251
+ parts.push(call("contains", call("lower", call("text", { col: name })), call("lower", { lit: search })));
252
+ }
253
+ let count = "count";
254
+ for (let n = 2; count === name; n++) count = `count ${n}`;
255
+ const where = joinParts(parts);
256
+ return {
257
+ plan: {
258
+ ...(plan.derive ? { derive: plan.derive } : {}),
259
+ ...(where ? { where } : {}),
260
+ aggregate: { keys: [name], measures: [{ name: count, fn: "count" }] },
261
+ sort: [
262
+ { column: count, direction: "desc" },
263
+ { column: name, direction: "asc" },
264
+ ],
265
+ },
266
+ count,
267
+ };
268
+ }