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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view, written down.
|
|
3
|
+
*
|
|
4
|
+
* The composer builds views by clicking, and a view built by clicking is UI
|
|
5
|
+
* state: it can be saved and loaded, and that is all. Everything else in
|
|
6
|
+
* tablewalk holds that the *query text* is the single source of truth — it is
|
|
7
|
+
* why filters compose into the bar, why sorting edits the query, why a link
|
|
8
|
+
* carries the whole walk. A view that cannot be written down cannot be
|
|
9
|
+
* hand-edited, diffed in a config file, pasted into a conversation, or
|
|
10
|
+
* explained to someone who does not have the UI open.
|
|
11
|
+
*
|
|
12
|
+
* So a view has a text form, and it is the same language:
|
|
13
|
+
*
|
|
14
|
+
* work_order
|
|
15
|
+
* show reference, total_cost, customer_id.name as customer
|
|
16
|
+
* count work_order_part via work_order_id as parts
|
|
17
|
+
* sum work_order_part.quantity via work_order_id as units
|
|
18
|
+
* where customer_id.country_code = AU and total_cost > 500
|
|
19
|
+
* sort total_cost desc
|
|
20
|
+
* limit 20
|
|
21
|
+
*
|
|
22
|
+
* Only two things here are new: the base table on the front, and the
|
|
23
|
+
* aggregate clauses. Paths, conditions, sort and limit are parsed by
|
|
24
|
+
* `parseViewQuery`, which the composer's filter box already uses — one
|
|
25
|
+
* grammar, not two that drift.
|
|
26
|
+
*
|
|
27
|
+
* Aggregates name the child table and the foreign key column rather than the
|
|
28
|
+
* constraint, because a constraint name is a database artefact nobody reads
|
|
29
|
+
* (`work_order_part_work_order_id_fkey`) and `work_order_part via
|
|
30
|
+
* work_order_id` says the same thing in words that appear in the schema. The
|
|
31
|
+
* pair is unambiguous: two foreign keys from one child to one parent must sit
|
|
32
|
+
* on different columns.
|
|
33
|
+
*/
|
|
34
|
+
import { backrefsTo, parseViewQuery, tableAtPrefix, } from './view.js';
|
|
35
|
+
import { findTable } from './schema.js';
|
|
36
|
+
const AGGREGATE_FNS = ['count', 'sum', 'min', 'max', 'avg'];
|
|
37
|
+
export function viewToText(view, nameConstraint) {
|
|
38
|
+
const lines = [view.base];
|
|
39
|
+
if (view.columns.length) {
|
|
40
|
+
const shown = view.columns.map((c) => (c.alias && c.alias !== c.path ? `${c.path} as ${c.alias}` : c.path));
|
|
41
|
+
lines.push(` show ${shown.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
for (const aggregate of view.aggregates ?? []) {
|
|
44
|
+
lines.push(` ${aggregateToText(aggregate, nameConstraint)}`);
|
|
45
|
+
}
|
|
46
|
+
const where = filterToText(view);
|
|
47
|
+
if (where)
|
|
48
|
+
lines.push(` where ${where}`);
|
|
49
|
+
if (view.orderBy?.length) {
|
|
50
|
+
const parts = view.orderBy.map((o) => (o.direction === 'desc' ? `${o.path} desc` : o.path));
|
|
51
|
+
lines.push(` sort ${parts.join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
if (view.limit !== undefined)
|
|
54
|
+
lines.push(` limit ${view.limit}`);
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
function aggregateToText(aggregate, nameConstraint) {
|
|
58
|
+
/* `via` on the model is a constraint name; the text form names the child
|
|
59
|
+
table and its foreign key column instead. The name is recoverable from
|
|
60
|
+
the pair, and the pair is what a person can read.
|
|
61
|
+
|
|
62
|
+
With no way to resolve it, the constraint name is emitted as-is rather
|
|
63
|
+
than a table and column being invented — wrong-but-readable is worse
|
|
64
|
+
than opaque-but-true. */
|
|
65
|
+
const [table, column] = nameConstraint?.(aggregate.via) ?? [aggregate.via, ''];
|
|
66
|
+
const target = aggregate.fn === 'count' || !aggregate.column ? table : `${table}.${aggregate.column}`;
|
|
67
|
+
const on = aggregate.on ? ` on ${aggregate.on}` : '';
|
|
68
|
+
const alias = aggregate.alias ? ` as ${aggregate.alias}` : '';
|
|
69
|
+
return `${aggregate.fn} ${target} via ${column}${on}${alias}`;
|
|
70
|
+
}
|
|
71
|
+
/** A namer backed by a schema: the form callers with a connection should use. */
|
|
72
|
+
export function constraintNamer(schema, view) {
|
|
73
|
+
return (via) => {
|
|
74
|
+
/* An aggregate can hang off a path, not just the base, so every prefix
|
|
75
|
+
the view mentions is a candidate parent. */
|
|
76
|
+
const prefixes = new Set(['', ...(view.aggregates ?? []).map((a) => a.on ?? '')]);
|
|
77
|
+
for (const prefix of prefixes) {
|
|
78
|
+
const parent = tableAtPrefix(schema, view.base, prefix);
|
|
79
|
+
if (!parent)
|
|
80
|
+
continue;
|
|
81
|
+
const hit = backrefsTo(schema, parent.id).find((b) => b.fk.name === via);
|
|
82
|
+
if (hit)
|
|
83
|
+
return [hit.fk.from.table, hit.fk.from.columns[0]];
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Render with a schema, so aggregates name real tables and columns. */
|
|
89
|
+
export function viewToTextWithSchema(schema, view) {
|
|
90
|
+
return viewToText(view, constraintNamer(schema, view));
|
|
91
|
+
}
|
|
92
|
+
function filterToText(view) {
|
|
93
|
+
const groups = view.filter?.groups ?? [];
|
|
94
|
+
if (!groups.length)
|
|
95
|
+
return '';
|
|
96
|
+
const rendered = groups.map((group) => group.map(conditionToText).join(' and '));
|
|
97
|
+
// A single group needs no parentheses; several are OR-ed and each is
|
|
98
|
+
// bracketed, matching how the parser reads them back.
|
|
99
|
+
return rendered.length === 1 ? rendered[0] : rendered.map((g) => `(${g})`).join(' or ');
|
|
100
|
+
}
|
|
101
|
+
function conditionToText(condition) {
|
|
102
|
+
const { path, op, value } = condition;
|
|
103
|
+
switch (op) {
|
|
104
|
+
case 'isNull':
|
|
105
|
+
return `${path} is empty`;
|
|
106
|
+
case 'isNotNull':
|
|
107
|
+
return `${path} is not empty`;
|
|
108
|
+
case 'contains':
|
|
109
|
+
return `${path} contains ${quote(value)}`;
|
|
110
|
+
case 'startsWith':
|
|
111
|
+
return `${path} startswith ${quote(value)}`;
|
|
112
|
+
case 'endsWith':
|
|
113
|
+
return `${path} endswith ${quote(value)}`;
|
|
114
|
+
case 'in': {
|
|
115
|
+
const list = Array.isArray(value) ? value : [value];
|
|
116
|
+
return `${path} in (${list.map(quote).join(', ')})`;
|
|
117
|
+
}
|
|
118
|
+
default:
|
|
119
|
+
return `${path} ${op} ${quote(value)}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Quote only when the grammar needs it, so the common case stays readable. */
|
|
123
|
+
function quote(value) {
|
|
124
|
+
if (value === null || value === undefined)
|
|
125
|
+
return '""';
|
|
126
|
+
const text = String(value);
|
|
127
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
128
|
+
return text;
|
|
129
|
+
return /^[\w.-]+$/.test(text) ? text : `"${text.replace(/"/g, '')}"`;
|
|
130
|
+
}
|
|
131
|
+
/* ---------- reading ---------- */
|
|
132
|
+
/**
|
|
133
|
+
* An aggregate clause: `count work_order_part via work_order_id as parts`.
|
|
134
|
+
*
|
|
135
|
+
* The function word is only treated as a clause when it is followed by
|
|
136
|
+
* something-then-`via`. A table with a column called `count` or `sum` is not
|
|
137
|
+
* unusual, and eating the word would turn `sum = 3` into a broken aggregate
|
|
138
|
+
* rather than a filter.
|
|
139
|
+
*/
|
|
140
|
+
const AGGREGATE_RE = new RegExp(String.raw `\b(${AGGREGATE_FNS.join('|')})\s+([\w.$]+)\s+via\s+([\w$]+)` +
|
|
141
|
+
String.raw `(?:\s+on\s+([\w.$]+))?(?:\s+as\s+([\w$]+))?`, 'gi');
|
|
142
|
+
/**
|
|
143
|
+
* Parse a written view.
|
|
144
|
+
*
|
|
145
|
+
* The base table and the aggregates are handled here; everything else is
|
|
146
|
+
* handed to `parseViewQuery`, which the composer's filter box already uses.
|
|
147
|
+
* Two parsers for one language is how a language starts disagreeing with
|
|
148
|
+
* itself.
|
|
149
|
+
*/
|
|
150
|
+
export function textToView(schema, text, options = {}) {
|
|
151
|
+
const errors = [];
|
|
152
|
+
const body = String(text ?? '').trim();
|
|
153
|
+
if (!body)
|
|
154
|
+
return { errors: [{ message: 'Start with a table name.' }] };
|
|
155
|
+
const baseName = body.split(/\s+/)[0];
|
|
156
|
+
const base = findTable(schema, baseName) ?? schema.tables.find((t) => t.name === baseName);
|
|
157
|
+
if (!base) {
|
|
158
|
+
return { errors: [{ message: `No table called "${baseName}" in this database.`, at: 0 }] };
|
|
159
|
+
}
|
|
160
|
+
const rest = body.slice(baseName.length);
|
|
161
|
+
/* Aggregates are lifted out before the rest is parsed, because
|
|
162
|
+
`parseViewQuery` knows nothing about them and would read `count` as the
|
|
163
|
+
start of a condition. */
|
|
164
|
+
const aggregates = [];
|
|
165
|
+
const remainder = rest.replace(AGGREGATE_RE, (_match, fn, target, viaColumn, on, alias) => {
|
|
166
|
+
/* `app.invoice_line` is one qualified table; `work_order_part.quantity`
|
|
167
|
+
is a table and a column; `app.invoice_line.quantity` is both. A dot
|
|
168
|
+
cannot tell them apart, so the schema does: if the whole target names a
|
|
169
|
+
table there is no column, otherwise the last dot separates them.
|
|
170
|
+
|
|
171
|
+
Splitting on the first dot — the obvious implementation — turned
|
|
172
|
+
`app.invoice_line` into table `app`, and the error it produced named a
|
|
173
|
+
table that does not exist. Postgres schemas are the common case for
|
|
174
|
+
this, which is exactly why it survived until the compiler was pointed
|
|
175
|
+
at a real one. */
|
|
176
|
+
const targetText = String(target);
|
|
177
|
+
const namesTable = (id) => Boolean(findTable(schema, id)) || schema.tables.some((t) => t.name === id);
|
|
178
|
+
let childTable = targetText;
|
|
179
|
+
let childColumn;
|
|
180
|
+
if (!namesTable(targetText)) {
|
|
181
|
+
const lastDot = targetText.lastIndexOf('.');
|
|
182
|
+
if (lastDot > 0) {
|
|
183
|
+
childTable = targetText.slice(0, lastDot);
|
|
184
|
+
childColumn = targetText.slice(lastDot + 1);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const parent = tableAtPrefix(schema, base.id, on ?? '');
|
|
188
|
+
if (!parent) {
|
|
189
|
+
errors.push({ message: `"${on}" is not a reference path on ${base.name}.` });
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
const backref = backrefsTo(schema, parent.id).find((b) => b.fk.from.table === childTable && b.fk.from.columns.includes(String(viaColumn)));
|
|
193
|
+
if (!backref) {
|
|
194
|
+
errors.push({
|
|
195
|
+
message: `Nothing in ${childTable}.${viaColumn} points at ${parent.name}. ` +
|
|
196
|
+
`Try: ${describeBackrefs(schema, parent.id)}`,
|
|
197
|
+
});
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
if (fn !== 'count' && !childColumn) {
|
|
201
|
+
errors.push({ message: `${fn} needs a column, e.g. ${fn} ${childTable}.amount via ${viaColumn}` });
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
if (childColumn) {
|
|
205
|
+
const childTableMeta = findTable(schema, backref.fk.from.table);
|
|
206
|
+
if (childTableMeta && !childTableMeta.columns.some((c) => c.name === childColumn)) {
|
|
207
|
+
errors.push({ message: `"${childColumn}" is not a column on ${childTable}.` });
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
aggregates.push({
|
|
212
|
+
fn: fn.toLowerCase(),
|
|
213
|
+
via: backref.fk.name,
|
|
214
|
+
on: on || undefined,
|
|
215
|
+
column: childColumn,
|
|
216
|
+
alias: alias || undefined,
|
|
217
|
+
});
|
|
218
|
+
return '';
|
|
219
|
+
});
|
|
220
|
+
/* Aliases are lifted out of the `show` clause before it is parsed.
|
|
221
|
+
`parseViewQuery` validates each entry as a path, and
|
|
222
|
+
`customer_id.name as customer` is not one — the alias is presentation,
|
|
223
|
+
not addressing, and only this layer knows about it. */
|
|
224
|
+
const { text: withoutAliases, aliases } = stripAliases(remainder);
|
|
225
|
+
const parsed = parseViewQuery(schema, base.id, withoutAliases, options.now, {
|
|
226
|
+
/* So `sort invoices desc` parses after `… as invoices` — the alias joins
|
|
227
|
+
the synthetic table for the sort clause, and only the sort clause. */
|
|
228
|
+
aggregateAliases: aggregates.map((a) => a.alias ?? '').filter(Boolean),
|
|
229
|
+
});
|
|
230
|
+
errors.push(...parsed.errors);
|
|
231
|
+
const view = {
|
|
232
|
+
id: options.id ?? 'text',
|
|
233
|
+
name: options.name ?? '',
|
|
234
|
+
base: base.id,
|
|
235
|
+
columns: parsed.columns.map((path) => {
|
|
236
|
+
const alias = aliases.get(path);
|
|
237
|
+
return alias ? { path, alias } : { path };
|
|
238
|
+
}),
|
|
239
|
+
aggregates: aggregates.length ? aggregates : undefined,
|
|
240
|
+
filter: parsed.filter.groups.length ? parsed.filter : undefined,
|
|
241
|
+
orderBy: parsed.orderBy.length ? parsed.orderBy : undefined,
|
|
242
|
+
limit: parsed.limit,
|
|
243
|
+
};
|
|
244
|
+
return { view, errors };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Remove ` as <alias>` from every entry of a `show` clause, returning the
|
|
248
|
+
* cleaned text and a path → alias map.
|
|
249
|
+
*
|
|
250
|
+
* Only the show clause: `as` means something else nowhere in this grammar
|
|
251
|
+
* today, but rewriting the whole string would break the moment it does.
|
|
252
|
+
*/
|
|
253
|
+
function stripAliases(text) {
|
|
254
|
+
const aliases = new Map();
|
|
255
|
+
const cleaned = text.replace(/\b(show|select)\s+([^]*?)(?=\s+\b(?:where|filter|sort|order\s+by|limit)\b|$)/i, (_match, keyword, list) => {
|
|
256
|
+
const entries = list.split(',').map((entry) => {
|
|
257
|
+
const aliased = entry.trim().match(/^(.*?)\s+as\s+([\w$]+)$/i);
|
|
258
|
+
if (!aliased)
|
|
259
|
+
return entry.trim();
|
|
260
|
+
aliases.set(aliased[1].trim(), aliased[2]);
|
|
261
|
+
return aliased[1].trim();
|
|
262
|
+
});
|
|
263
|
+
return `${keyword} ${entries.join(', ')}`;
|
|
264
|
+
});
|
|
265
|
+
return { text: cleaned, aliases };
|
|
266
|
+
}
|
|
267
|
+
/** A short list of what *would* have worked, for the error to name. */
|
|
268
|
+
function describeBackrefs(schema, parentId) {
|
|
269
|
+
const options = backrefsTo(schema, parentId)
|
|
270
|
+
.slice(0, 3)
|
|
271
|
+
.map((b) => `${b.fk.from.table} via ${b.fk.from.columns[0]}`);
|
|
272
|
+
return options.length ? options.join(', ') : 'nothing points at this table';
|
|
273
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The values a column is allowed to hold, when the database says so.
|
|
3
|
+
*
|
|
4
|
+
* A column called `status` typed `text` tells you nothing. A `CHECK (status
|
|
5
|
+
* IN ('draft','open','paid'))` tells you everything — the states the system
|
|
6
|
+
* has, which is often the single most useful fact about a table and the one
|
|
7
|
+
* an agent is most likely to guess wrong. Guessing produces `status =
|
|
8
|
+
* 'OPEN'`, which returns no rows and looks like an empty table rather than a
|
|
9
|
+
* spelling mistake.
|
|
10
|
+
*
|
|
11
|
+
* Every dialect writes the same constraint differently, so the parsing lives
|
|
12
|
+
* here and the adapters bring their own constraint text. This is deliberately
|
|
13
|
+
* a *recogniser*, not a SQL expression parser: it understands the handful of
|
|
14
|
+
* shapes that mean "one of these" and returns nothing for anything else. A
|
|
15
|
+
* partial understanding of `CHECK (total > 0 AND status IN (...))` would be
|
|
16
|
+
* worse than none, because the vocabulary it reported would be presented as
|
|
17
|
+
* the whole rule.
|
|
18
|
+
*/
|
|
19
|
+
/** A quoted or bare identifier, as any dialect might write the column. */
|
|
20
|
+
function sameColumn(written, column) {
|
|
21
|
+
const bare = written.trim().replace(/^["`[]|["`\]]$/g, '');
|
|
22
|
+
return bare.toLowerCase() === column.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
/** `'a'`, `'it''s'`, `'a'::text` — the string literals inside a list. */
|
|
25
|
+
function literals(list) {
|
|
26
|
+
const values = [];
|
|
27
|
+
const pattern = /'((?:[^']|'')*)'(?:::[A-Za-z_" ]+(?:\[\])?)?\s*(,|$)/gy;
|
|
28
|
+
pattern.lastIndex = 0;
|
|
29
|
+
let rest = list.trim();
|
|
30
|
+
let at = 0;
|
|
31
|
+
while (at < rest.length) {
|
|
32
|
+
pattern.lastIndex = at;
|
|
33
|
+
const m = pattern.exec(rest);
|
|
34
|
+
if (!m)
|
|
35
|
+
return undefined;
|
|
36
|
+
values.push(m[1].replace(/''/g, "'"));
|
|
37
|
+
at = pattern.lastIndex;
|
|
38
|
+
while (rest[at] === ' ')
|
|
39
|
+
at += 1;
|
|
40
|
+
}
|
|
41
|
+
return values.length ? values : undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Remove `::type` casts, which Postgres sprays through every stored
|
|
45
|
+
* expression — `(status)::text = ANY ((ARRAY['a'::character varying])::text[])`
|
|
46
|
+
* is `status = ANY (ARRAY['a'])` with six casts in it. Quoted strings are
|
|
47
|
+
* stepped over, so a value that happens to contain `::` survives.
|
|
48
|
+
*/
|
|
49
|
+
function stripCasts(text) {
|
|
50
|
+
let out = '';
|
|
51
|
+
let quoted = null;
|
|
52
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
53
|
+
const ch = text[i];
|
|
54
|
+
if (quoted) {
|
|
55
|
+
out += ch;
|
|
56
|
+
if (ch === quoted)
|
|
57
|
+
quoted = null;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === "'" || ch === '"') {
|
|
61
|
+
quoted = ch;
|
|
62
|
+
out += ch;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === ':' && text[i + 1] === ':') {
|
|
66
|
+
i += 2;
|
|
67
|
+
while (i < text.length && /[A-Za-z0-9_ "]/.test(text[i]))
|
|
68
|
+
i += 1;
|
|
69
|
+
if (text[i] === '[' && text[i + 1] === ']')
|
|
70
|
+
i += 1;
|
|
71
|
+
else
|
|
72
|
+
i -= 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out += ch;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The vocabulary a single CHECK expression pins for one column.
|
|
81
|
+
*
|
|
82
|
+
* Handles the two shapes that mean the same thing:
|
|
83
|
+
* `status IN ('a','b')` — every dialect
|
|
84
|
+
* `((status)::text = ANY ((ARRAY['a'::text, 'b'::text])))` — what Postgres
|
|
85
|
+
* stores instead
|
|
86
|
+
*/
|
|
87
|
+
export function checkVocabulary(expression, column) {
|
|
88
|
+
const text = stripCasts(expression).trim();
|
|
89
|
+
/* Postgres rewrites `IN` to `= ANY (ARRAY[...])` before storing it, so the
|
|
90
|
+
constraint you wrote is never the constraint you read back. */
|
|
91
|
+
const any = /^[(\s]*([^\s()]+)[)\s]*=\s*ANY\s*[(\s]*ARRAY\s*\[(.*)\][\s)]*$/is.exec(text);
|
|
92
|
+
if (any && sameColumn(any[1], column))
|
|
93
|
+
return literals(any[2]);
|
|
94
|
+
const list = /^[(\s]*([^\s()]+)\s+IN\s*\((.*)\)[\s)]*$/is.exec(text);
|
|
95
|
+
if (list && sameColumn(list[1], column))
|
|
96
|
+
return literals(list[2]);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The vocabulary written into a column's type, as MySQL does it:
|
|
101
|
+
* `enum('draft','open')`, `set('a','b')`.
|
|
102
|
+
*/
|
|
103
|
+
export function typeVocabulary(type) {
|
|
104
|
+
const m = /^\s*(?:enum|set)\s*\((.*)\)\s*$/is.exec(type);
|
|
105
|
+
return m ? literals(m[1]) : undefined;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Every CHECK clause in a `CREATE TABLE` statement, for dialects that keep
|
|
109
|
+
* the statement rather than the constraints — SQLite.
|
|
110
|
+
*
|
|
111
|
+
* Scanned with a depth counter rather than a regex because a check body
|
|
112
|
+
* contains parentheses of its own, and matching to the first `)` truncates
|
|
113
|
+
* `CHECK (status IN ('a','b'))` to `status IN ('a'`.
|
|
114
|
+
*/
|
|
115
|
+
export function checksIn(ddl) {
|
|
116
|
+
const found = [];
|
|
117
|
+
const pattern = /\bCHECK\s*\(/gi;
|
|
118
|
+
let match;
|
|
119
|
+
while ((match = pattern.exec(ddl))) {
|
|
120
|
+
let depth = 1;
|
|
121
|
+
let i = match.index + match[0].length;
|
|
122
|
+
let quoted = null;
|
|
123
|
+
const start = i;
|
|
124
|
+
for (; i < ddl.length && depth > 0; i += 1) {
|
|
125
|
+
const ch = ddl[i];
|
|
126
|
+
if (quoted) {
|
|
127
|
+
if (ch === quoted)
|
|
128
|
+
quoted = null;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === "'" || ch === '"')
|
|
132
|
+
quoted = ch;
|
|
133
|
+
else if (ch === '(')
|
|
134
|
+
depth += 1;
|
|
135
|
+
else if (ch === ')')
|
|
136
|
+
depth -= 1;
|
|
137
|
+
}
|
|
138
|
+
if (depth === 0)
|
|
139
|
+
found.push(ddl.slice(start, i - 1));
|
|
140
|
+
pattern.lastIndex = i;
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The allowed values for each column of a table, read out of its DDL.
|
|
146
|
+
*
|
|
147
|
+
* Column-level (`status TEXT CHECK (status IN (...))`) and table-level
|
|
148
|
+
* (`CHECK (status IN (...))`) checks are the same text by the time they get
|
|
149
|
+
* here, which is why this asks each check about each column rather than
|
|
150
|
+
* trying to work out which column a check was attached to.
|
|
151
|
+
*/
|
|
152
|
+
export function vocabularyFromDDL(ddl, columns) {
|
|
153
|
+
const found = new Map();
|
|
154
|
+
for (const check of checksIn(ddl)) {
|
|
155
|
+
for (const column of columns) {
|
|
156
|
+
if (found.has(column))
|
|
157
|
+
continue;
|
|
158
|
+
const values = checkVocabulary(check, column);
|
|
159
|
+
if (values)
|
|
160
|
+
found.set(column, values);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tablewalk",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Follow the data. A database browser built around the walk: a foreign key is a place you can go.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Baljinder Randhawa",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/rbaljinder/tablewalk.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/rbaljinder/tablewalk#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/rbaljinder/tablewalk/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24.0.0"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"tablewalk": "dist/server/index.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"database",
|
|
29
|
+
"sqlite",
|
|
30
|
+
"postgres",
|
|
31
|
+
"schema",
|
|
32
|
+
"browser",
|
|
33
|
+
"cli"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "tsx watch src/server/index.ts",
|
|
37
|
+
"start": "tsx src/server/index.ts",
|
|
38
|
+
"test": "tsc --noEmit && node test/check-client-imports.mjs && node test/check-client-types.mjs && tsx --test --test-timeout=120000 test/run.ts 'test/*.test.ts'",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/client/. dist/client/ && test -f dist/client/index.html",
|
|
41
|
+
"prepack": "npm run build",
|
|
42
|
+
"prepublishOnly": "npm test",
|
|
43
|
+
"demo": "tsx test/make-demo-db.ts .demo/demo.db --scale 3000 && tsx src/server/index.ts --db .demo/demo.db --open",
|
|
44
|
+
"e2e": "playwright test",
|
|
45
|
+
"seed:live": "tsx test/seed-live.ts"
|
|
46
|
+
},
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"mysql2": "^3.23.4",
|
|
49
|
+
"pg": "^8.23.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@playwright/test": "^1.62.1",
|
|
53
|
+
"@types/node": "^22.10.0",
|
|
54
|
+
"tsx": "^4.23.12",
|
|
55
|
+
"typescript": "^5.7.3"
|
|
56
|
+
}
|
|
57
|
+
}
|