tablewalk 0.0.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * Multi-table views, built out of **reference paths**.
3
+ *
4
+ * The `Filter` in adapter.ts addresses exactly one table and must keep doing
5
+ * so: it is the shape every read goes through, and widening it to carry joins
6
+ * would put join-building into every code path that filters anything. So the
7
+ * multi-table story is a separate idea rather than a bigger filter, and the
8
+ * idea is dot-walking:
9
+ *
10
+ * invoice customer_id.name contains Harbour
11
+ * work_order assigned_to.last_name = Petrova
12
+ * and customer_id.country_code.name = Australia
13
+ *
14
+ * A path is a chain of foreign keys starting at the base table, ending at a
15
+ * column. `customer_id.name` means "follow invoice.customer_id to customer,
16
+ * then take name". Nothing about it is guessed: the foreign key graph is
17
+ * already introspected, so every hop is derivable, checkable, and reversible
18
+ * into SQL — a path compiles to a LEFT JOIN chain and a qualified column.
19
+ *
20
+ * One mechanism answers three separate questions. Filtering across a long
21
+ * walk, showing a field from a related table, and the composer UI are all the
22
+ * same thing seen from three angles; the composer is a visual editor for the
23
+ * path set.
24
+ *
25
+ * Two properties are load-bearing and are pinned by tests:
26
+ *
27
+ * - **LEFT JOIN, never INNER.** A null foreign key must leave the base row
28
+ * in the result with nulls beside it. A composer that quietly drops rows
29
+ * is worse than no composer at all, because the number it shows you is
30
+ * wrong in a direction you cannot see.
31
+ * - **Every value is a bound parameter.** Only identifiers are ever quoted
32
+ * into the text, and they are quoted by the dialect's `SqlStyle`. The
33
+ * WHERE clause is still built by `buildWhere`, which now takes an
34
+ * identifier resolver so a path can become `"customer_1"."name"` without
35
+ * a second predicate builder existing to disagree with the first.
36
+ */
37
+ import { ANSI_STYLE, buildWhere, clampLimit, clampOffset, } from '../adapters/adapter.js';
38
+ import { findTable, referencesFrom, } from './schema.js';
39
+ import { parseQuery } from './query.js';
40
+ /**
41
+ * How many foreign keys a path may follow.
42
+ *
43
+ * A cap is not a nicety: `work_order.parent_id` points at `work_order`, so the
44
+ * path space is infinite and any breadth-first enumeration of it runs forever.
45
+ * Four hops is past the point where anyone can still read the result, and the
46
+ * limit is stated in the error rather than left to be discovered.
47
+ */
48
+ export const MAX_PATH_DEPTH = 4;
49
+ /* ---------- errors ---------- */
50
+ /**
51
+ * A path that does not resolve, with the segment that failed.
52
+ *
53
+ * The segment matters more than the path: `customer_id.country_code.nmae` is
54
+ * four words of which one is wrong, and an error that repeats the whole path
55
+ * makes you find the wrong one yourself.
56
+ */
57
+ export class PathError extends Error {
58
+ path;
59
+ segment;
60
+ index;
61
+ constructor(message, path, segment,
62
+ /** Index of the failing segment within the path, 0-based. */
63
+ index) {
64
+ super(message);
65
+ this.path = path;
66
+ this.segment = segment;
67
+ this.index = index;
68
+ this.name = 'PathError';
69
+ }
70
+ }
71
+ /** Levenshtein distance, capped. Only ever used to suggest a near miss. */
72
+ function distance(a, b) {
73
+ if (Math.abs(a.length - b.length) > 3)
74
+ return 99;
75
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
76
+ for (let i = 1; i <= a.length; i++) {
77
+ let last = prev[0];
78
+ prev[0] = i;
79
+ for (let j = 1; j <= b.length; j++) {
80
+ const tmp = prev[j];
81
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
82
+ last = tmp;
83
+ }
84
+ }
85
+ return prev[b.length];
86
+ }
87
+ function nearest(name, candidates) {
88
+ const near = candidates
89
+ .map((c) => ({ c, d: distance(name.toLowerCase(), c.toLowerCase()) }))
90
+ .sort((a, b) => a.d - b.d)[0];
91
+ return near && near.d <= 3 ? near.c : undefined;
92
+ }
93
+ /** A short list for an error message; long lists help nobody. */
94
+ function shortList(items) {
95
+ return items.length > 6 ? `${items.slice(0, 6).join(', ')}, …` : items.join(', ');
96
+ }
97
+ /* ---------- hops ---------- */
98
+ /**
99
+ * What a hop is called in a path.
100
+ *
101
+ * A single-column foreign key is named by its column, which is what anyone
102
+ * would write and what the composer shows. A composite key has no single
103
+ * column to name it, so its columns are joined with `+` — deterministic,
104
+ * derivable from the model alone, and impossible to confuse with a column
105
+ * name because `+` is not legal in one.
106
+ */
107
+ export function hopName(fk) {
108
+ return fk.from.columns.length === 1 ? fk.from.columns[0] : fk.from.columns.join('+');
109
+ }
110
+ /** Every forward hop available from a table, in a stable order. */
111
+ export function hopsFrom(schema, tableId) {
112
+ const table = findTable(schema, tableId);
113
+ return referencesFrom(schema, tableId).map((fk) => ({
114
+ name: hopName(fk),
115
+ fk,
116
+ target: fk.to.table,
117
+ optional: fk.from.columns.some((c) => table?.columns.find((col) => col.name === c)?.nullable !== false),
118
+ }));
119
+ }
120
+ /**
121
+ * Resolve a path against the schema, or explain precisely why it does not.
122
+ *
123
+ * Every hop is checked against the foreign key graph, so an unknown segment is
124
+ * an error here rather than a database exception later — or, far worse, a
125
+ * silently empty result that looks like a table with nothing in it.
126
+ */
127
+ export function resolvePath(schema, baseId, path) {
128
+ const raw = String(path ?? '').trim();
129
+ if (!raw)
130
+ throw new PathError('A path cannot be empty.', raw, '', 0);
131
+ const segments = raw.split('.');
132
+ const blank = segments.findIndex((s) => s.trim() === '');
133
+ if (blank !== -1) {
134
+ throw new PathError(`"${raw}" has an empty segment — two dots in a row, or a trailing dot.`, raw, '', blank);
135
+ }
136
+ const hopCount = segments.length - 1;
137
+ if (hopCount > MAX_PATH_DEPTH) {
138
+ throw new PathError(`"${raw}" follows ${hopCount} references; at most ${MAX_PATH_DEPTH} are allowed. ` +
139
+ 'Deeper walks are usually two views rather than one.', raw, segments[MAX_PATH_DEPTH], MAX_PATH_DEPTH);
140
+ }
141
+ let table = findTable(schema, baseId);
142
+ if (!table) {
143
+ throw new PathError(`No table called "${baseId}" in this database.`, raw, segments[0], 0);
144
+ }
145
+ const hops = [];
146
+ const hopNames = [];
147
+ for (let i = 0; i < segments.length - 1; i++) {
148
+ const segment = segments[i];
149
+ const available = hopsFrom(schema, table.id);
150
+ const matches = available.filter((h) => h.name === segment || h.fk.name === segment);
151
+ if (!matches.length) {
152
+ /* A real column that simply is not a reference is the commonest
153
+ mistake, and deserves its own sentence: "total is not a reference"
154
+ beats "total is not one of customer_id, sold_by". */
155
+ const isColumn = table.columns.some((c) => c.name === segment);
156
+ const names = available.map((h) => h.name);
157
+ const suggestion = nearest(segment, names);
158
+ const hint = suggestion
159
+ ? ` Did you mean "${suggestion}"?`
160
+ : names.length
161
+ ? ` ${table.name} references: ${shortList(names)}.`
162
+ : ` ${table.name} has no foreign keys.`;
163
+ throw new PathError(isColumn
164
+ ? `"${segment}" is a column on ${table.name}, not a reference, so nothing can follow it.${hint}`
165
+ : `"${segment}" is not a reference on ${table.name}.${hint}`, raw, segment, i);
166
+ }
167
+ if (matches.length > 1) {
168
+ throw new PathError(`"${segment}" is ambiguous on ${table.name} — ${matches.length} foreign keys match. ` +
169
+ `Name the constraint instead: ${shortList(matches.map((m) => m.fk.name))}.`, raw, segment, i);
170
+ }
171
+ const next = findTable(schema, matches[0].target);
172
+ if (!next) {
173
+ throw new PathError(`"${segment}" points at "${matches[0].target}", which is not in this schema.`, raw, segment, i);
174
+ }
175
+ hops.push(matches[0].fk);
176
+ hopNames.push(matches[0].name);
177
+ table = next;
178
+ }
179
+ const last = segments[segments.length - 1];
180
+ const column = table.columns.find((c) => c.name === last) ??
181
+ table.columns.find((c) => c.name.toLowerCase() === last.toLowerCase());
182
+ if (!column) {
183
+ const suggestion = nearest(last, table.columns.map((c) => c.name));
184
+ throw new PathError(`"${last}" is not a column on ${table.name}.${suggestion ? ` Did you mean "${suggestion}"?` : ''}`, raw, last, segments.length - 1);
185
+ }
186
+ return { path: raw, hops, hopNames, table, column };
187
+ }
188
+ /** Every foreign key pointing at a table: the "what refers to me" direction. */
189
+ export function backrefsTo(schema, tableId) {
190
+ return schema.foreignKeys
191
+ .filter((fk) => fk.to.table === tableId)
192
+ .map((fk) => ({ name: fk.name, fk, child: fk.from.table }));
193
+ }
194
+ const AGGREGATE_FNS = ['count', 'sum', 'min', 'max', 'avg'];
195
+ export function resolveAggregate(schema, baseId, aggregate) {
196
+ const on = (aggregate.on ?? '').trim();
197
+ const fail = (message, segment) => {
198
+ throw new PathError(message, aggregate.via, segment, 0);
199
+ };
200
+ if (!AGGREGATE_FNS.includes(aggregate.fn)) {
201
+ fail(`"${String(aggregate.fn)}" is not a summary. Use one of: ${AGGREGATE_FNS.join(', ')}.`, String(aggregate.fn));
202
+ }
203
+ /* The parent is the base table, or whatever a path prefix reaches. Resolved
204
+ by resolving a path *through* it — the same code, so a prefix that is not
205
+ a real chain of keys fails here exactly as it would anywhere else. */
206
+ const parent = tableAtPrefix(schema, baseId, on);
207
+ if (!parent) {
208
+ fail(on
209
+ ? `"${on}" is not a chain of references from ${baseId}.`
210
+ : `No table called "${baseId}" in this database.`, on || baseId);
211
+ }
212
+ const candidates = backrefsTo(schema, parent.id);
213
+ const match = candidates.find((b) => b.name === aggregate.via) ??
214
+ candidates.find((b) => `${b.child}.${b.fk.from.columns.join('+')}` === aggregate.via);
215
+ if (!match) {
216
+ fail(`Nothing called "${aggregate.via}" points at ${parent.name}.` +
217
+ (candidates.length
218
+ ? ` It is referred to by: ${shortList(candidates.map((c) => c.name))}.`
219
+ : ' Nothing refers to it.'), aggregate.via);
220
+ }
221
+ const child = findTable(schema, match.child);
222
+ if (!child)
223
+ fail(`"${match.child}" is not in this schema.`, aggregate.via);
224
+ let column;
225
+ if (aggregate.fn !== 'count') {
226
+ if (!aggregate.column) {
227
+ fail(`${aggregate.fn} needs a column on ${child.name} to summarise.`, aggregate.via);
228
+ }
229
+ column = child.columns.find((c) => c.name === aggregate.column);
230
+ if (!column) {
231
+ const suggestion = nearest(aggregate.column, child.columns.map((c) => c.name));
232
+ fail(`"${aggregate.column}" is not a column on ${child.name}.${suggestion ? ` Did you mean "${suggestion}"?` : ''}`, aggregate.column);
233
+ }
234
+ }
235
+ const subject = `${on ? `${on}.` : ''}${child.name}${column ? `.${column.name}` : ''}`;
236
+ return {
237
+ aggregate,
238
+ fk: match.fk,
239
+ child: child,
240
+ parent: parent,
241
+ on,
242
+ column,
243
+ name: aggregate.alias?.trim() || `${aggregate.fn}(${subject})`,
244
+ };
245
+ }
246
+ /** The table a chain of hop names lands on. Empty prefix is the base itself. */
247
+ export function tableAtPrefix(schema, baseId, prefix) {
248
+ let table = findTable(schema, baseId);
249
+ if (!prefix)
250
+ return table;
251
+ for (const segment of prefix.split('.')) {
252
+ if (!table)
253
+ return undefined;
254
+ const hop = hopsFrom(schema, table.id).find((h) => h.name === segment || h.fk.name === segment);
255
+ if (!hop)
256
+ return undefined;
257
+ table = findTable(schema, hop.target);
258
+ }
259
+ return table;
260
+ }
261
+ /** Resolve without throwing — for callers collecting several errors at once. */
262
+ export function tryResolvePath(schema, baseId, path) {
263
+ try {
264
+ return { resolved: resolvePath(schema, baseId, path) };
265
+ }
266
+ catch (err) {
267
+ if (err instanceof PathError)
268
+ return { error: err };
269
+ throw err;
270
+ }
271
+ }
272
+ /**
273
+ * Alias generation.
274
+ *
275
+ * Two requirements pull in opposite directions. Aliases must never collide —
276
+ * `work_order` in the demo has three separate references to `employee`, so a
277
+ * scheme keyed on the target table alone produces three joins all called
278
+ * `employee` and a statement that does not parse. And they must be stable, so
279
+ * the same view compiles to the same SQL every time and a diff of the explain
280
+ * panel means something.
281
+ *
282
+ * Both fall out of keying on the *path prefix* rather than the table: a prefix
283
+ * identifies a join uniquely, so the same path used twice reuses one join and
284
+ * two paths through the same table get two. The readable table name is kept as
285
+ * a prefix and made unique by an ordinal assigned in first-use order.
286
+ */
287
+ export class JoinPlan {
288
+ schema;
289
+ style;
290
+ joins = [];
291
+ byPrefix = new Map();
292
+ n = 0;
293
+ constructor(schema, base, style) {
294
+ this.schema = schema;
295
+ this.style = style;
296
+ this.byPrefix.set('', alias(base.name, 0));
297
+ }
298
+ get baseAlias() {
299
+ return this.byPrefix.get('');
300
+ }
301
+ /** The alias for a path prefix, creating the join chain if it is new. */
302
+ aliasFor(hops, hopNames) {
303
+ let prefix = '';
304
+ for (let i = 0; i < hops.length; i++) {
305
+ prefix = prefix ? `${prefix}.${hopNames[i]}` : hopNames[i];
306
+ /* Already planned: the same path used twice must reuse one join, not
307
+ add a second copy of the same table under a different alias. */
308
+ if (this.byPrefix.has(prefix))
309
+ continue;
310
+ const target = findTable(this.schema, hops[i].to.table);
311
+ this.byPrefix.set(prefix, alias(target.name, ++this.n));
312
+ this.joins.push({ alias: this.byPrefix.get(prefix), table: target.id, path: prefix });
313
+ }
314
+ return this.byPrefix.get(prefix) ?? this.baseAlias;
315
+ }
316
+ /** The fully qualified, quoted SQL for a resolved path's final column. */
317
+ qualify(resolved) {
318
+ const owner = this.aliasFor(resolved.hops, resolved.hopNames);
319
+ return `${this.style.quote(owner)}.${this.style.quote(resolved.column.name)}`;
320
+ }
321
+ /** The alias of a path prefix that has already been planned, or the base. */
322
+ aliasOfPrefix(prefix) {
323
+ return this.byPrefix.get(prefix) ?? this.baseAlias;
324
+ }
325
+ /**
326
+ * A fresh alias for something that is not a join — a subquery's table.
327
+ *
328
+ * It draws from the same counter as the joins, which is what guarantees a
329
+ * correlated subquery over `employee` cannot pick the alias a LEFT JOIN
330
+ * over `employee` is already using. Two `employee_3`s in one statement is
331
+ * a self-join nobody asked for, and the wrong answer rather than an error.
332
+ */
333
+ subAlias(name) {
334
+ return alias(name, ++this.n);
335
+ }
336
+ /**
337
+ * The JOIN clauses, in the order the aliases were created.
338
+ *
339
+ * Order matters for more than tidiness: a join may only reference an alias
340
+ * introduced before it, and creation order is exactly path order, so a
341
+ * nested hop always follows the hop it hangs off.
342
+ */
343
+ clauses() {
344
+ return this.joins.map((join) => {
345
+ const parts = join.path.split('.');
346
+ const parentPrefix = parts.slice(0, -1).join('.');
347
+ const parent = this.byPrefix.get(parentPrefix);
348
+ const fk = this.fkFor(join.path);
349
+ const target = findTable(this.schema, join.table);
350
+ /* Every column of the key, not just the first. A composite foreign key
351
+ joined on one column is a cross product wearing a join's clothes. */
352
+ const on = fk.to.columns
353
+ .map((toCol, i) => `${this.style.quote(join.alias)}.${this.style.quote(toCol)} = ` +
354
+ `${this.style.quote(parent)}.${this.style.quote(fk.from.columns[i] ?? fk.from.columns[0])}`)
355
+ .join(' AND ');
356
+ /* LEFT, always. An INNER JOIN here would drop every base row whose
357
+ foreign key is null — silently, and in a direction that looks like
358
+ the data simply is not there. */
359
+ return ` LEFT JOIN ${qualifiedTable(this.schema, target.id, this.style)} ${this.style.quote(join.alias)} ON ${on}`;
360
+ });
361
+ }
362
+ fkChain = new Map();
363
+ /** Remember which key produced each join, so `clauses` can rebuild the ON. */
364
+ record(path, fk) {
365
+ if (!this.fkChain.has(path))
366
+ this.fkChain.set(path, fk);
367
+ }
368
+ fkFor(path) {
369
+ return this.fkChain.get(path);
370
+ }
371
+ }
372
+ /** Alias text: the table's own name, made unique by an ordinal. */
373
+ function alias(name, n) {
374
+ /* Truncated because Postgres silently cuts identifiers at 63 bytes, and a
375
+ silent cut is exactly how two long table names become one alias. */
376
+ const safe = name.replace(/[^A-Za-z0-9_]/g, '_').slice(0, 40) || 't';
377
+ return `${safe}_${n}`;
378
+ }
379
+ /** A table reference: two identifiers when the dialect has schemas, one when not. */
380
+ export function qualifiedTable(schema, tableId, style) {
381
+ const table = findTable(schema, tableId);
382
+ if (!table)
383
+ return style.quote(tableId);
384
+ return table.schema ? `${style.quote(table.schema)}.${style.quote(table.name)}` : style.quote(table.name);
385
+ }
386
+ /**
387
+ * Compile a view to one parameterised SELECT.
388
+ *
389
+ * Order of work is deliberate and is what makes the output deterministic:
390
+ * columns, then the filter, then the sort. Aliases are handed out on first
391
+ * use, so the same view always produces the same statement.
392
+ */
393
+ export function compileView(schema, view, style, offset = 0) {
394
+ const base = findTable(schema, view.base);
395
+ if (!base) {
396
+ throw new PathError(`No table called "${view.base}" in this database, so the view has nothing to start from.`, view.base, view.base, 0);
397
+ }
398
+ const plan = new JoinPlan(schema, base, style);
399
+ /* Resolution and alias creation are one step, because a join only exists
400
+ once some path needs it. Recording the key alongside keeps `clauses` from
401
+ having to re-resolve anything. */
402
+ const qualify = (path) => {
403
+ const resolved = resolvePath(schema, view.base, path);
404
+ let prefix = '';
405
+ for (let i = 0; i < resolved.hops.length; i++) {
406
+ prefix = prefix ? `${prefix}.${resolved.hopNames[i]}` : resolved.hopNames[i];
407
+ plan.record(prefix, resolved.hops[i]);
408
+ }
409
+ return plan.qualify(resolved);
410
+ };
411
+ /* An empty column list means the base table's own columns. Expanding it
412
+ here rather than emitting `SELECT *` keeps the result's column list
413
+ knowable without running the statement, which is what the composer's
414
+ field checklist reads. */
415
+ const wanted = view.columns?.length
416
+ ? view.columns
417
+ : base.columns.map((c) => ({ path: c.name }));
418
+ const seen = new Map();
419
+ const columns = [];
420
+ const select = [];
421
+ for (const col of wanted) {
422
+ const resolved = resolvePath(schema, view.base, col.path);
423
+ const sql = qualify(col.path);
424
+ /* Two paths can legitimately end on the same name — `customer_id.name`
425
+ and `sold_by.name` are both "name" if aliased carelessly. The path is
426
+ already unique, so it is the default; an explicit collision gets a
427
+ numeric suffix rather than one column quietly overwriting the other. */
428
+ let name = col.alias?.trim() || resolved.path;
429
+ const count = seen.get(name) ?? 0;
430
+ seen.set(name, count + 1);
431
+ if (count)
432
+ name = `${name}_${count + 1}`;
433
+ columns.push({
434
+ path: resolved.path,
435
+ name,
436
+ table: resolved.table.id,
437
+ column: resolved.column.name,
438
+ type: resolved.column.type,
439
+ /* A column on a joined table is nullable in the result even when the
440
+ column itself is NOT NULL: the LEFT JOIN can produce no row at all.
441
+ Saying otherwise would have the UI render a real null as an error. */
442
+ nullable: resolved.column.nullable || resolved.hops.length > 0,
443
+ primaryKey: resolved.column.primaryKey,
444
+ hops: resolved.hops.length,
445
+ });
446
+ select.push(`${sql} AS ${style.quote(name)}`);
447
+ }
448
+ /* Aggregates come after the plain columns, and after them on purpose: a
449
+ summary hanging off a joined table needs that join's alias to exist, and
450
+ resolving columns first is what puts it there. */
451
+ for (const aggregate of view.aggregates ?? []) {
452
+ const resolved = resolveAggregate(schema, view.base, aggregate);
453
+ /* The parent side is a path prefix, so an aggregate can hang off a joined
454
+ table as easily as off the base — "invoice lines on the invoice this
455
+ shipment belongs to" is the same construction one hop along. */
456
+ if (resolved.on) {
457
+ /* Qualifying any column on the parent is what plans the joins leading
458
+ to it and records their keys. Doing it through the same `qualify`
459
+ the columns use is deliberate: two ways of planning a join is two
460
+ ways for the aliases to disagree. */
461
+ qualify(`${resolved.on}.${resolved.parent.columns[0].name}`);
462
+ }
463
+ const parentAlias = resolved.on ? plan.aliasOfPrefix(resolved.on) : plan.baseAlias;
464
+ const childAlias = plan.subAlias(resolved.child.name);
465
+ const on = resolved.fk.to.columns
466
+ .map((toCol, i) => `${style.quote(childAlias)}.${style.quote(resolved.fk.from.columns[i] ?? resolved.fk.from.columns[0])} = ` +
467
+ `${style.quote(parentAlias)}.${style.quote(toCol)}`)
468
+ .join(' AND ');
469
+ /* COUNT(*) rather than COUNT(column): a column count skips nulls, which
470
+ turns "how many parts" into "how many parts have a quantity" without
471
+ saying so. SUM over no rows is left as NULL rather than coerced to 0 —
472
+ a work order with no parts has no total, and 0 is a claim about money
473
+ that nobody made. */
474
+ const expression = resolved.aggregate.fn === 'count'
475
+ ? 'COUNT(*)'
476
+ : `${resolved.aggregate.fn.toUpperCase()}(${style.quote(childAlias)}.${style.quote(resolved.column.name)})`;
477
+ let name = resolved.name;
478
+ const count = seen.get(name) ?? 0;
479
+ seen.set(name, count + 1);
480
+ if (count)
481
+ name = `${name}_${count + 1}`;
482
+ select.push(`(SELECT ${expression} FROM ${qualifiedTable(schema, resolved.child.id, style)} ${style.quote(childAlias)}`
483
+ + ` WHERE ${on}) AS ${style.quote(name)}`);
484
+ columns.push({
485
+ path: resolved.name,
486
+ name,
487
+ table: resolved.child.id,
488
+ column: resolved.column?.name ?? '*',
489
+ type: resolved.aggregate.fn === 'count' ? 'integer' : (resolved.column?.type ?? 'integer'),
490
+ // COUNT is never null; every other summary is, over an empty child set.
491
+ nullable: resolved.aggregate.fn !== 'count',
492
+ primaryKey: false,
493
+ hops: resolved.on ? resolved.on.split('.').length : 0,
494
+ aggregate: resolved.aggregate.fn,
495
+ });
496
+ }
497
+ /* The filter is compiled by `buildWhere`, not beside it. One predicate
498
+ builder means one place where a value could stop being a parameter. */
499
+ const conditions = view.filter?.groups.length
500
+ ? {
501
+ groups: view.filter.groups.map((group) => group.map((c) => ({ column: c.path, op: c.op, value: c.value }))),
502
+ }
503
+ : undefined;
504
+ const where = buildWhere(conditions, style, 1, qualify);
505
+ const aliasNames = new Set((view.aggregates ?? []).map((a) => a.alias?.trim()).filter(Boolean));
506
+ const order = (view.orderBy ?? []).map((o) => {
507
+ if (o.alias) {
508
+ /* Refused by name rather than left to the engine: SQLite quietly
509
+ treats an unknown ORDER BY identifier as a new error anyway, but
510
+ "no such column: invoicez" would not say the fix is the alias two
511
+ words back. */
512
+ if (!aliasNames.has(o.path)) {
513
+ throw new PathError(`"${o.path}" is not an aggregate alias on this view.`, o.path, o.path, 0);
514
+ }
515
+ return `${style.quote(o.path)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`;
516
+ }
517
+ return `${qualify(o.path)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`;
518
+ });
519
+ const limit = clampLimit(view.limit);
520
+ const skip = clampOffset(offset);
521
+ /* Placeholder numbering continues across the whole statement, which is the
522
+ one thing Postgres needs and SQLite does not — and the exact place a join
523
+ builder gets it wrong, because the joins sit between the SELECT list and
524
+ the WHERE clause and are easy to count as if they carried parameters. */
525
+ const n = where.params.length;
526
+ const lines = [
527
+ `SELECT ${select.join(',\n ')}`,
528
+ ` FROM ${qualifiedTable(schema, base.id, style)} ${style.quote(plan.baseAlias)}`,
529
+ ...plan.clauses(),
530
+ ];
531
+ if (where.text)
532
+ lines.push(where.text.replace(/^ WHERE /, ' WHERE '));
533
+ if (order.length)
534
+ lines.push(` ORDER BY ${order.join(', ')}`);
535
+ lines.push(` LIMIT ${style.placeholder(n + 1)} OFFSET ${style.placeholder(n + 2)}`);
536
+ return {
537
+ text: lines.join('\n'),
538
+ params: [...where.params, limit, skip],
539
+ columns,
540
+ joins: plan.joins,
541
+ limit,
542
+ };
543
+ }
544
+ /* ---------- the query language, over paths ----------
545
+
546
+ The composer's filter box takes the same language as the bar at the top,
547
+ because two languages for one idea is one too many. It is parsed by the
548
+ same parser, too: `parseQuery` already handles and/or, parentheses, quoting,
549
+ relative dates and per-column value coercion, and a second implementation
550
+ of those would be a second set of bugs.
551
+
552
+ The trick that makes it work is a *synthetic* table. `parseQuery` resolves
553
+ every field against a table's column list, so the base table is handed to it
554
+ with one extra column per path mentioned in the text, each carrying the
555
+ declared type of the column the path actually ends on. Coercion, date
556
+ handling and "did you mean" then work over paths for free.
557
+
558
+ Paths are resolved on demand rather than enumerated: `work_order.parent_id`
559
+ points back at `work_order`, so the set of paths four hops deep is tens of
560
+ thousands of entries and building it per keystroke would be absurd. */
561
+ /* One segment of a path: a column name, or — for a composite key — the
562
+ columns of it joined with `+`, exactly as `hopName` writes them.
563
+
564
+ `+` has to be admitted here as well as in `parseQuery`'s FIELD, or the two
565
+ disagree about where a token begins: the scanner reads only `line_no.sku`
566
+ out of `invoice_id+line_no.sku`, builds the synthetic column for a path that
567
+ does not exist, and a composite hop becomes something you can show but never
568
+ filter. It is legal only *between* identifier characters, never leading —
569
+ a leading `+` would let a signed number start a path. */
570
+ const SEGMENT = String.raw `[A-Za-z_$][\w$]*(?:\+[A-Za-z_$][\w$]*)*`;
571
+ /* A dotted token. `@` and `+` are excluded from the lookbehind on purpose:
572
+ without `@`, `email = ops@harbour.example` reads its own value as a path and
573
+ reports a confident error about a table that was never mentioned; without
574
+ `+`, the tail of a composite hop matches on its own and reports the same
575
+ kind of confident error about half a key. */
576
+ const DOTTED_TOKEN = new RegExp(String.raw `(?<![\w.$@+])(${SEGMENT}(?:\.${SEGMENT})+)`, 'g');
577
+ /** What makes a token a *field* rather than a value: an operator after it. */
578
+ const OPERATOR_AFTER = /^\s*(?:!=|<=|>=|=|<|>|\bcontains\b|\bstartswith\b|\bendswith\b|\blike\b|\bnot\s+in\b|\bin\b|\bis\b)/i;
579
+ /** …or a word before it that can only be followed by a field. */
580
+ const FIELD_BEFORE = /(?:^|[\s(,])(?:and|or|where|filter|show|select|sort|by)\s*$|(?:^|,)\s*$/i;
581
+ /**
582
+ * Blank out quoted spans, keeping the length so offsets still line up.
583
+ *
584
+ * A quoted value is text by definition — `name = "Smith.Jones"` contains no
585
+ * path — and scanning into one produces errors about words the user never
586
+ * meant as fields.
587
+ */
588
+ function maskQuoted(text) {
589
+ let out = '';
590
+ let quote = null;
591
+ for (const ch of text) {
592
+ if (quote) {
593
+ out += ch === quote ? ((quote = null), ch) : ' ';
594
+ continue;
595
+ }
596
+ if (ch === '"' || ch === "'") {
597
+ quote = ch;
598
+ out += ch;
599
+ continue;
600
+ }
601
+ out += ch;
602
+ }
603
+ return out;
604
+ }
605
+ /**
606
+ * Parse the composer's filter/clause text against a base table.
607
+ *
608
+ * Returns errors rather than throwing, because this runs on every keystroke
609
+ * and a half-typed path is the normal state of the input, not an exception.
610
+ */
611
+ export function parseViewQuery(schema, baseId, text, now, options = {}) {
612
+ const empty = { filter: { groups: [] }, columns: [], orderBy: [], errors: [] };
613
+ const base = findTable(schema, baseId);
614
+ if (!base) {
615
+ return { ...empty, errors: [{ message: `No table called "${baseId}" in this database.` }] };
616
+ }
617
+ const body = String(text ?? '').trim();
618
+ if (!body)
619
+ return empty;
620
+ const errors = [];
621
+ const extra = [];
622
+ const added = new Set();
623
+ /* Paths already reported as broken. The synthetic table cannot contain
624
+ them, so the parser will reach them too and say "not a column on invoice"
625
+ — true, useless, and directly under a message that named the real
626
+ problem. Two errors for one mistake reads as two mistakes. */
627
+ const failed = new Set();
628
+ const scanned = maskQuoted(body);
629
+ for (const match of scanned.matchAll(DOTTED_TOKEN)) {
630
+ const path = match[1];
631
+ const start = match.index ?? 0;
632
+ /* Whether this token is being used as a field decides whether a failure
633
+ to resolve is worth reporting. In value position — `external_ref =
634
+ a.b` — a dotted word that is not a path is simply a value, and an error
635
+ about it would be noise on a query that is perfectly correct. */
636
+ const isField = OPERATOR_AFTER.test(scanned.slice(start + path.length)) ||
637
+ FIELD_BEFORE.test(scanned.slice(0, start));
638
+ if (added.has(path))
639
+ continue;
640
+ added.add(path);
641
+ const { resolved, error } = tryResolvePath(schema, baseId, path);
642
+ if (error) {
643
+ /* Located at the token so the composer can underline the right word.
644
+ The offset is into the filter text, and the caller shifts it. */
645
+ if (isField) {
646
+ errors.push({ message: error.message, at: start });
647
+ failed.add(path);
648
+ }
649
+ continue;
650
+ }
651
+ if (!resolved)
652
+ continue;
653
+ /* The synthetic column keeps the *declared* type of the real column, so
654
+ `signed_up = last 30 days` through a path behaves exactly as it does
655
+ on the base table. Nullability is widened because the join can miss. */
656
+ extra.push({
657
+ name: resolved.path,
658
+ type: resolved.column.type,
659
+ nullable: true,
660
+ primaryKey: false,
661
+ references: resolved.column.references,
662
+ });
663
+ }
664
+ /* Aggregate aliases join the synthetic table so `sort invoices desc`
665
+ parses — and only sort. A WHERE runs before the SELECT list exists, so
666
+ filtering by an alias is refused below with the reason, not with
667
+ "not a column", which is true and useless. */
668
+ const aliases = new Set(options.aggregateAliases ?? []);
669
+ for (const alias of aliases) {
670
+ if (!added.has(alias))
671
+ extra.push({ name: alias, type: 'NUMERIC', nullable: true, primaryKey: false });
672
+ }
673
+ const synthetic = {
674
+ ...schema,
675
+ tables: schema.tables.map((t) => t.id === base.id ? { ...t, columns: [...t.columns, ...extra] } : t),
676
+ };
677
+ /* The parser wants `<table> <clauses>`, which is also what the composer's
678
+ hint text shows, so the base name is prepended rather than the grammar
679
+ being special-cased. */
680
+ const offset = base.id.length + 1;
681
+ const parsed = parseQuery(`${base.id} ${body}`, { schema: synthetic, now });
682
+ for (const err of parsed.errors) {
683
+ if ([...failed].some((path) => err.message.includes(`"${path}"`)))
684
+ continue;
685
+ errors.push({ message: err.message, at: err.at === undefined ? undefined : Math.max(0, err.at - offset) });
686
+ }
687
+ const query = parsed.query;
688
+ if (!query)
689
+ return { ...empty, errors };
690
+ for (const group of query.filter.groups) {
691
+ for (const c of group) {
692
+ if (aliases.has(c.column)) {
693
+ errors.push({
694
+ message: `"${c.column}" is an aggregate result, and a filter runs before it exists. `
695
+ + 'Filter on the child table instead.',
696
+ });
697
+ }
698
+ }
699
+ }
700
+ for (const column of query.columns ?? []) {
701
+ if (aliases.has(column)) {
702
+ errors.push({ message: `"${column}" is already shown — the aggregate adds its own column.` });
703
+ }
704
+ }
705
+ return {
706
+ filter: {
707
+ groups: query.filter.groups.map((group) => group.map((c) => ({ path: c.column, op: c.op, value: c.value }))),
708
+ },
709
+ columns: query.columns,
710
+ orderBy: query.orderBy.map((o) => aliases.has(o.column)
711
+ ? { path: o.column, direction: o.direction, alias: true }
712
+ : { path: o.column, direction: o.direction }),
713
+ limit: query.limit,
714
+ errors,
715
+ };
716
+ }
717
+ /**
718
+ * How many rows the view matches, ignoring the page.
719
+ *
720
+ * A separate compilation rather than a wrapper around the other one, because
721
+ * the two differ in more than a keyword: the SELECT list, the ORDER BY and the
722
+ * page are all irrelevant here, and every aggregate in the view would be a
723
+ * correlated subquery evaluated per row for a number nobody reads.
724
+ *
725
+ * The count is exact rather than estimated, and it is *base rows* — which is
726
+ * only true because every join is to-one. That is the same property the whole
727
+ * design rests on: a view never multiplies, so counting it is counting the
728
+ * table it started from.
729
+ */
730
+ export function compileViewCount(schema, view, style) {
731
+ const base = findTable(schema, view.base);
732
+ if (!base) {
733
+ throw new PathError(`No table called "${view.base}" in this database, so the view has nothing to start from.`, view.base, view.base, 0);
734
+ }
735
+ const plan = new JoinPlan(schema, base, style);
736
+ const qualify = (path) => {
737
+ const resolved = resolvePath(schema, view.base, path);
738
+ let prefix = '';
739
+ for (let i = 0; i < resolved.hops.length; i++) {
740
+ prefix = prefix ? `${prefix}.${resolved.hopNames[i]}` : resolved.hopNames[i];
741
+ plan.record(prefix, resolved.hops[i]);
742
+ }
743
+ return plan.qualify(resolved);
744
+ };
745
+ const conditions = view.filter?.groups.length
746
+ ? {
747
+ groups: view.filter.groups.map((group) => group.map((c) => ({ column: c.path, op: c.op, value: c.value }))),
748
+ }
749
+ : undefined;
750
+ const where = buildWhere(conditions, style, 1, qualify);
751
+ /* The joins are emitted after the WHERE clause has been built, because
752
+ building it is what discovers which joins the filter needs. */
753
+ const text = `SELECT COUNT(*) AS n\n FROM ${qualifiedTable(schema, base.id, style)} ${style.quote(plan.baseAlias)}`
754
+ + (plan.clauses().length ? `\n${plan.clauses().join('\n')}` : '')
755
+ + where.text;
756
+ return { text, params: where.params };
757
+ }
758
+ /**
759
+ * Render a view as the SQL it will become, values inline.
760
+ *
761
+ * The mirror of `explain` in query.ts and used the same way: this string never
762
+ * reaches a database, and showing `"customer_1"."name" LIKE '%Harbour%'` next
763
+ * to the path that produced it is how someone learns what the composer is
764
+ * doing on their behalf.
765
+ */
766
+ export function explainView(schema, view, style) {
767
+ const compiled = compileView(schema, view, style);
768
+ let i = 0;
769
+ return compiled.text.replace(/\$\d+|\?/g, () => sqlLiteral(compiled.params[i++]));
770
+ }
771
+ /**
772
+ * One parameter, written the way SQL would have to spell it.
773
+ *
774
+ * Exported because a breakdown explains itself the same way a view does, and
775
+ * two spellings of `NULL` — or two different ideas about escaping a quote —
776
+ * would show up as a difference between two panes that claim to be the same
777
+ * statement.
778
+ */
779
+ export function sqlLiteral(value) {
780
+ if (value === null || value === undefined)
781
+ return 'NULL';
782
+ if (typeof value === 'number' || typeof value === 'boolean')
783
+ return String(value);
784
+ return `'${String(value).replace(/'/g, "''")}'`;
785
+ }
786
+ /* ---------- config file support ----------
787
+
788
+ Views can be predefined in `tablewalk.json` beside `connections`, and are
789
+ validated the same strict way: a malformed entry is an error naming the
790
+ entry, never a silent skip. Quietly dropping a view someone wrote is the
791
+ worst outcome — they see a shorter list and no reason for it. */
792
+ export function parseViewsConfig(raw, where) {
793
+ const views = raw?.views;
794
+ if (views === undefined)
795
+ return [];
796
+ if (!Array.isArray(views))
797
+ throw new Error(`${where}: "views" must be an array.`);
798
+ const seen = new Set();
799
+ return views.map((entry, i) => {
800
+ const at = `${where}: view ${i + 1}`;
801
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
802
+ throw new Error(`${at} is not an object.`);
803
+ }
804
+ const v = entry;
805
+ if (typeof v.name !== 'string' || !v.name.trim())
806
+ throw new Error(`${at} has no "name".`);
807
+ /* A view may be written down instead of built out of objects. The text
808
+ form needs a schema to resolve, which a config file does not have, so
809
+ it is carried through and expanded when a connection is open — and any
810
+ error it holds is reported against that view rather than swallowed. */
811
+ const text = v.text === undefined ? undefined : String(v.text).trim();
812
+ if (v.text !== undefined && (typeof v.text !== 'string' || !text)) {
813
+ throw new Error(`${at} ("${v.name}") has a "text" that is not a non-empty string.`);
814
+ }
815
+ if (text && v.base !== undefined) {
816
+ throw new Error(`${at} ("${v.name}") has both "text" and "base". The text form already names its table.`);
817
+ }
818
+ if (!text && (typeof v.base !== 'string' || !v.base.trim())) {
819
+ throw new Error(`${at} ("${v.name}") has no "base" table.`);
820
+ }
821
+ const id = v.id === undefined ? `config-${slug(v.name)}` : String(v.id);
822
+ if (v.id !== undefined && (typeof v.id !== 'string' || !v.id.trim())) {
823
+ throw new Error(`${at} ("${v.name}") has an "id" that is not a non-empty string.`);
824
+ }
825
+ if (seen.has(id))
826
+ throw new Error(`${at} ("${v.name}") repeats the id "${id}".`);
827
+ seen.add(id);
828
+ const columns = parseColumns(v.columns, at, String(v.name));
829
+ const aggregates = parseAggregates(v.aggregates, at, String(v.name));
830
+ const filter = parseFilter(v.filter, at, String(v.name));
831
+ const orderBy = parseOrder(v.orderBy, at, String(v.name));
832
+ if (v.limit !== undefined && (typeof v.limit !== 'number' || !Number.isFinite(v.limit))) {
833
+ throw new Error(`${at} ("${v.name}") has a "limit" that is not a number.`);
834
+ }
835
+ if (v.connection !== undefined && typeof v.connection !== 'string') {
836
+ throw new Error(`${at} ("${v.name}") has a "connection" that is not a string.`);
837
+ }
838
+ return {
839
+ id,
840
+ name: v.name.trim(),
841
+ base: text ? text.trim().split(/\s+/)[0] : v.base.trim(),
842
+ text,
843
+ columns,
844
+ aggregates,
845
+ filter,
846
+ orderBy,
847
+ limit: v.limit,
848
+ connection: v.connection,
849
+ source: 'config',
850
+ };
851
+ });
852
+ }
853
+ function parseColumns(raw, at, name) {
854
+ if (raw === undefined)
855
+ return [];
856
+ if (!Array.isArray(raw))
857
+ throw new Error(`${at} ("${name}") has "columns" that is not an array.`);
858
+ return raw.map((col, j) => {
859
+ const where = `${at} ("${name}"), column ${j + 1}`;
860
+ if (typeof col === 'string') {
861
+ if (!col.trim())
862
+ throw new Error(`${where} is empty.`);
863
+ return { path: col.trim() };
864
+ }
865
+ if (!col || typeof col !== 'object')
866
+ throw new Error(`${where} is not a string or an object.`);
867
+ const c = col;
868
+ if (typeof c.path !== 'string' || !c.path.trim())
869
+ throw new Error(`${where} has no "path".`);
870
+ if (c.alias !== undefined && (typeof c.alias !== 'string' || !c.alias.trim())) {
871
+ throw new Error(`${where} has an "alias" that is not a non-empty string.`);
872
+ }
873
+ return { path: c.path.trim(), alias: c.alias };
874
+ });
875
+ }
876
+ function parseAggregates(raw, at, name) {
877
+ if (raw === undefined)
878
+ return undefined;
879
+ if (!Array.isArray(raw))
880
+ throw new Error(`${at} ("${name}") has "aggregates" that is not an array.`);
881
+ return raw.map((entry, j) => {
882
+ const where = `${at} ("${name}"), aggregate ${j + 1}`;
883
+ if (!entry || typeof entry !== 'object')
884
+ throw new Error(`${where} is not an object.`);
885
+ const a = entry;
886
+ if (typeof a.fn !== 'string' || !AGGREGATE_FNS.includes(a.fn)) {
887
+ throw new Error(`${where} has an unknown "fn". Use one of: ${AGGREGATE_FNS.join(', ')}.`);
888
+ }
889
+ if (typeof a.via !== 'string' || !a.via.trim())
890
+ throw new Error(`${where} has no "via".`);
891
+ if (a.fn !== 'count' && (typeof a.column !== 'string' || !a.column.trim())) {
892
+ throw new Error(`${where} is a ${a.fn} and needs a "column" to summarise.`);
893
+ }
894
+ if (a.on !== undefined && typeof a.on !== 'string') {
895
+ throw new Error(`${where} has an "on" that is not a string.`);
896
+ }
897
+ if (a.alias !== undefined && (typeof a.alias !== 'string' || !a.alias.trim())) {
898
+ throw new Error(`${where} has an "alias" that is not a non-empty string.`);
899
+ }
900
+ return {
901
+ fn: a.fn,
902
+ via: a.via.trim(),
903
+ on: a.on,
904
+ column: typeof a.column === 'string' ? a.column.trim() : undefined,
905
+ alias: a.alias,
906
+ };
907
+ });
908
+ }
909
+ const OPS = [
910
+ '=', '!=', '>', '>=', '<', '<=', 'contains', 'startsWith', 'endsWith', 'like', 'in', 'isNull', 'isNotNull',
911
+ ];
912
+ function parseFilter(raw, at, name) {
913
+ if (raw === undefined)
914
+ return undefined;
915
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
916
+ throw new Error(`${at} ("${name}") has a "filter" that is not an object.`);
917
+ }
918
+ const groups = raw.groups;
919
+ if (!Array.isArray(groups))
920
+ throw new Error(`${at} ("${name}") has a "filter" with no "groups" array.`);
921
+ return {
922
+ groups: groups.map((group, g) => {
923
+ if (!Array.isArray(group))
924
+ throw new Error(`${at} ("${name}"), filter group ${g + 1} is not an array.`);
925
+ return group.map((cond, c) => {
926
+ const where = `${at} ("${name}"), filter group ${g + 1} condition ${c + 1}`;
927
+ if (!cond || typeof cond !== 'object')
928
+ throw new Error(`${where} is not an object.`);
929
+ const x = cond;
930
+ if (typeof x.path !== 'string' || !x.path.trim())
931
+ throw new Error(`${where} has no "path".`);
932
+ if (typeof x.op !== 'string' || !OPS.includes(x.op)) {
933
+ throw new Error(`${where} has an unknown operator "${String(x.op)}". Use one of: ${OPS.join(', ')}.`);
934
+ }
935
+ return { path: x.path.trim(), op: x.op, value: x.value };
936
+ });
937
+ }),
938
+ };
939
+ }
940
+ function parseOrder(raw, at, name) {
941
+ if (raw === undefined)
942
+ return undefined;
943
+ if (!Array.isArray(raw))
944
+ throw new Error(`${at} ("${name}") has an "orderBy" that is not an array.`);
945
+ return raw.map((entry, j) => {
946
+ const where = `${at} ("${name}"), orderBy ${j + 1}`;
947
+ if (typeof entry === 'string')
948
+ return { path: entry, direction: 'asc' };
949
+ if (!entry || typeof entry !== 'object')
950
+ throw new Error(`${where} is not a string or an object.`);
951
+ const o = entry;
952
+ if (typeof o.path !== 'string' || !o.path.trim())
953
+ throw new Error(`${where} has no "path".`);
954
+ const direction = o.direction === undefined ? 'asc' : o.direction;
955
+ if (direction !== 'asc' && direction !== 'desc') {
956
+ throw new Error(`${where} has a "direction" that is not "asc" or "desc".`);
957
+ }
958
+ return { path: o.path.trim(), direction };
959
+ });
960
+ }
961
+ function slug(name) {
962
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'view';
963
+ }
964
+ /**
965
+ * Check a view against a schema without running it.
966
+ *
967
+ * Used by `GET /api/views` so a config view naming a table that does not exist
968
+ * on this connection is listed *with its reason*, rather than either vanishing
969
+ * or blowing up the endpoint for every other view beside it.
970
+ */
971
+ export function validateView(schema, view) {
972
+ try {
973
+ /* Any style will do: this compiles to throw away the text and keep the
974
+ error, and no dialect refuses a view another accepts. */
975
+ compileView(schema, view, ANSI_STYLE);
976
+ return undefined;
977
+ }
978
+ catch (err) {
979
+ return err.message;
980
+ }
981
+ }