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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- 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
|
+
}
|