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,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counting things by something, on screen.
|
|
3
|
+
*
|
|
4
|
+
* The language grew `by` — `invoice count by status`, `sum total by month
|
|
5
|
+
* invoice_date` — and the query bar had nowhere to send it. Typing one got
|
|
6
|
+
* `"billed" is not a column on invoice`, an error about a column nobody
|
|
7
|
+
* claimed was one, with no hint that the thing being asked for exists. The
|
|
8
|
+
* agent tools could answer the sentence and the person looking at the same
|
|
9
|
+
* database could not.
|
|
10
|
+
*
|
|
11
|
+
* A breakdown is not a table view with a GROUP BY on it, and this module is
|
|
12
|
+
* separate from table.js for the same reason `BreakdownDef` is a sibling of
|
|
13
|
+
* `ViewDef` rather than a field on it: **the rows are not records.** Nothing
|
|
14
|
+
* that makes the grid a walking surface applies here — a group cannot be
|
|
15
|
+
* opened, has no primary key, has nothing pointing at it, and cannot be
|
|
16
|
+
* expanded, peeked at, pinned or edited. Building this on `resultTable` would
|
|
17
|
+
* have meant switching every one of those off and hoping none came back.
|
|
18
|
+
*
|
|
19
|
+
* What it does keep is the two things that are true of every result here: the
|
|
20
|
+
* keyboard cursor (`initGrid`, with no `onOpen` — the cells are readable and
|
|
21
|
+
* copyable, they just do not go anywhere), and sorting expressed by editing
|
|
22
|
+
* the query text rather than held as UI state. The second falls out of the
|
|
23
|
+
* grammar: `sort` on a breakdown names an output column, so a header click is
|
|
24
|
+
* the same text edit it is everywhere else in this tool.
|
|
25
|
+
*
|
|
26
|
+
* The parsing all happens on the server, as it does for the query bar and the
|
|
27
|
+
* composer's filter box. The one rule that has to exist twice is *whether a
|
|
28
|
+
* line is a breakdown at all*, because that decision is made before any
|
|
29
|
+
* request goes out — see `looksLikeBreakdown`, and the parity test in
|
|
30
|
+
* mirrors.test.ts that runs both copies over the same lines.
|
|
31
|
+
*/
|
|
32
|
+
import {
|
|
33
|
+
$, pageSize, api, disclosure, el, findTable, go, render, setExplain, showErrors, state, toast,
|
|
34
|
+
} from './core.js';
|
|
35
|
+
import { EXPORT_MAX_ROWS, download, exportFilename, toCsv, toJson } from './export.js';
|
|
36
|
+
import { explicitLimit, nextSort, sortDirection } from './clauses.js';
|
|
37
|
+
import { planColumn } from './columns.js';
|
|
38
|
+
import { valueNode } from './value.js';
|
|
39
|
+
import { initGrid } from './grid.js';
|
|
40
|
+
import { recordQuery } from './history.js';
|
|
41
|
+
import { gridSkeleton, placeholder } from './skeleton.js';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Is this line asking for a breakdown at all?
|
|
45
|
+
*
|
|
46
|
+
* The mirror of `looksLikeBreakdown` in shared/breakdowntext.ts, and one of
|
|
47
|
+
* the handful of rules this client has a second copy of — the browser cannot
|
|
48
|
+
* import the TypeScript the server runs on. It is here rather than on the
|
|
49
|
+
* server because it decides *which endpoint to call*, which is a decision that
|
|
50
|
+
* has to be made before there is anything to send.
|
|
51
|
+
*
|
|
52
|
+
* The `by` keyword standing alone and not the tail of `order by`. That is the
|
|
53
|
+
* whole tell: no other clause in the language uses it, which is why it was
|
|
54
|
+
* available to mean this.
|
|
55
|
+
*/
|
|
56
|
+
export function looksLikeBreakdown(text) {
|
|
57
|
+
return /(?<!\border\s)\bby\s+\S/i.test(String(text ?? ''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Same shape as table.js's, and for the same reason: skim two breakdowns and
|
|
61
|
+
the slower reply must not repaint over the newer one, or rewrite the SQL
|
|
62
|
+
pane and the error banner belonging to a question nobody is asking. */
|
|
63
|
+
let runToken = 0;
|
|
64
|
+
|
|
65
|
+
export async function renderBreakdown(entry, content) {
|
|
66
|
+
$('query').value = entry.query;
|
|
67
|
+
const body = el('div');
|
|
68
|
+
content.append(body);
|
|
69
|
+
|
|
70
|
+
const mine = ++runToken;
|
|
71
|
+
const asked = explicitLimit(entry.query);
|
|
72
|
+
/* Shaped like the grid about to replace it. Two columns is the usual size of
|
|
73
|
+
a breakdown — one key and one measure — and a better guess than the base
|
|
74
|
+
table's column count, which is the shape of the thing being counted rather
|
|
75
|
+
than the shape of the answer. */
|
|
76
|
+
const done = placeholder(body, () => gridSkeleton(2, Math.min(asked ?? pageSize, pageSize())));
|
|
77
|
+
|
|
78
|
+
let data;
|
|
79
|
+
try {
|
|
80
|
+
data = await api('/api/breakdown', {
|
|
81
|
+
text: entry.query,
|
|
82
|
+
limit: asked ?? pageSize(),
|
|
83
|
+
offset: asked ? 0 : state.page * pageSize(),
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (mine !== runToken) return;
|
|
87
|
+
done();
|
|
88
|
+
body.replaceChildren(el('p', { class: 'note', text: err.message }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
/* Everything below is global — the error banner, the SQL pane, the history —
|
|
92
|
+
so a stale reply stops here, before any of it. */
|
|
93
|
+
if (mine !== runToken) return;
|
|
94
|
+
done();
|
|
95
|
+
|
|
96
|
+
showErrors(data.errors ?? []);
|
|
97
|
+
setExplain(data.explain);
|
|
98
|
+
|
|
99
|
+
if (!data.columns) {
|
|
100
|
+
/* A half-typed breakdown is the normal state of an input box, so the
|
|
101
|
+
messages are the answer rather than a failure. They are already in the
|
|
102
|
+
banner; this is the space where the grid would have been. */
|
|
103
|
+
body.replaceChildren(el('p', { class: 'note', text: 'Fix the line above to see groups.' }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
recordQuery(entry.query);
|
|
108
|
+
|
|
109
|
+
const shown = (asked ? 0 : state.page * pageSize()) + data.rows.length;
|
|
110
|
+
body.replaceChildren(
|
|
111
|
+
head(entry, data, shown),
|
|
112
|
+
grid(entry, data),
|
|
113
|
+
pager(entry, data, shown),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What the answer is, in numbers.
|
|
119
|
+
*
|
|
120
|
+
* "Groups" rather than "rows", because that is what they are and because the
|
|
121
|
+
* difference is the whole point: 12,006 invoices came back as 4 groups, and
|
|
122
|
+
* calling those 4 rows invites the reader to think the table is nearly empty.
|
|
123
|
+
*/
|
|
124
|
+
function head(entry, data, shown) {
|
|
125
|
+
const base = data.breakdown?.base ?? entry.table;
|
|
126
|
+
const total = data.total;
|
|
127
|
+
return el('div', { class: 'result-head' }, [
|
|
128
|
+
el('h2', { text: findTable(base)?.name ?? base }),
|
|
129
|
+
el('span', {
|
|
130
|
+
class: 'stat',
|
|
131
|
+
text: total === undefined
|
|
132
|
+
? `${shown.toLocaleString()} group${shown === 1 ? '' : 's'}`
|
|
133
|
+
: total > shown
|
|
134
|
+
? `${shown.toLocaleString()} of ${total.toLocaleString()} groups`
|
|
135
|
+
: `${total.toLocaleString()} group${total === 1 ? '' : 's'}`,
|
|
136
|
+
}),
|
|
137
|
+
el('span', { class: 'stat', text: `${data.ms} ms` }),
|
|
138
|
+
exportGroups(entry, data),
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Export, for groups.
|
|
144
|
+
*
|
|
145
|
+
* Not `exportControl`. That one is built around a table: it remembers a
|
|
146
|
+
* column selection per table, re-runs through `/api/run` with a `show`
|
|
147
|
+
* clause, and names the file after the table. None of it transfers. A
|
|
148
|
+
* breakdown has no `show` — every column is a key or a measure, and dropping
|
|
149
|
+
* one does not narrow the answer, it makes it a different and wrong one — so
|
|
150
|
+
* there is no column chooser here, deliberately.
|
|
151
|
+
*
|
|
152
|
+
* What it keeps is the part worth sharing: the two scopes, each with its
|
|
153
|
+
* count spelled out, because a file named after a table gives no hint that
|
|
154
|
+
* the other forty groups were left behind.
|
|
155
|
+
*/
|
|
156
|
+
function exportGroups(entry, data) {
|
|
157
|
+
const rows = data.rows ?? [];
|
|
158
|
+
const columns = data.columns ?? [];
|
|
159
|
+
const total = data.total ?? rows.length;
|
|
160
|
+
const capped = Math.min(total, EXPORT_MAX_ROWS);
|
|
161
|
+
const base = data.breakdown?.base ?? entry.table;
|
|
162
|
+
|
|
163
|
+
const menu = el('div', { class: 'export-menu' });
|
|
164
|
+
const wrap = disclosure(el('details', { class: 'export' }, [
|
|
165
|
+
el('summary', { class: 'export-summary', title: 'Download these groups' }, 'Export'),
|
|
166
|
+
menu,
|
|
167
|
+
]));
|
|
168
|
+
|
|
169
|
+
const write = (kind, all, groups) => {
|
|
170
|
+
const name = `${base}-by-${(data.breakdown?.by ?? []).map((k) => k.alias || k.path).join('-') || 'group'}`;
|
|
171
|
+
if (kind === 'csv') download(exportFilename(name, 'csv'), toCsv(columns, groups), 'text/csv');
|
|
172
|
+
else download(exportFilename(name, 'json'), toJson(columns, groups), 'application/json');
|
|
173
|
+
toast(`Exported ${groups.length.toLocaleString()} group${groups.length === 1 ? '' : 's'}.`);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const start = async (button, kind, all) => {
|
|
177
|
+
if (!all) {
|
|
178
|
+
wrap.open = false;
|
|
179
|
+
write(kind, all, rows);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
/* One bounded re-run rather than pages stitched together — the same
|
|
183
|
+
argument the table's export makes, and it applies harder here: a group
|
|
184
|
+
is an aggregate over rows that may have changed between two requests. */
|
|
185
|
+
const label = button.textContent;
|
|
186
|
+
button.disabled = true;
|
|
187
|
+
button.textContent = 'Fetching…';
|
|
188
|
+
try {
|
|
189
|
+
const fresh = await api('/api/breakdown', { text: entry.query, limit: capped, offset: 0 });
|
|
190
|
+
if (!fresh.columns) throw new Error(fresh.errors?.[0]?.message ?? 'That line no longer groups.');
|
|
191
|
+
wrap.open = false;
|
|
192
|
+
write(kind, all, fresh.rows ?? []);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
toast(err.message, 'error');
|
|
195
|
+
} finally {
|
|
196
|
+
button.disabled = false;
|
|
197
|
+
button.textContent = label;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const pageLabel = `this page (${rows.length.toLocaleString()} group${rows.length === 1 ? '' : 's'})`;
|
|
202
|
+
const allLabel = `all groups (${capped.toLocaleString()})`;
|
|
203
|
+
for (const [kind, heading] of [['csv', 'CSV'], ['json', 'JSON']]) {
|
|
204
|
+
const group = el('div', { class: 'export-group' }, el('div', { class: 'export-heading', text: heading }));
|
|
205
|
+
for (const [label, all] of [[pageLabel, false], [allLabel, true]]) {
|
|
206
|
+
/* The second scope is offered only when it differs: "all groups (20)"
|
|
207
|
+
beside "this page (20 groups)" is two buttons for one file. */
|
|
208
|
+
if (all && capped <= rows.length) continue;
|
|
209
|
+
const button = el('button', { type: 'button', class: 'export-item', text: label });
|
|
210
|
+
button.addEventListener('click', () => void start(button, kind, all));
|
|
211
|
+
group.append(button);
|
|
212
|
+
}
|
|
213
|
+
menu.append(group);
|
|
214
|
+
}
|
|
215
|
+
return wrap;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The grid.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately plain: a header that sorts, cells that render through the same
|
|
222
|
+
* `valueNode` every other surface uses, and the keyboard cursor. No row click,
|
|
223
|
+
* because there is no row to open — see the note at the top of this file.
|
|
224
|
+
*/
|
|
225
|
+
function grid(entry, data) {
|
|
226
|
+
const wrap = el('div', { class: 'table-wrap' });
|
|
227
|
+
if (!data.rows.length) {
|
|
228
|
+
/* The headers would say which columns had nothing in them, but a breakdown
|
|
229
|
+
with no groups means the filter matched no rows at all — there is no
|
|
230
|
+
shape to show, only that fact. */
|
|
231
|
+
wrap.append(el('p', { class: 'note', text: 'Nothing to group — no rows matched.' }));
|
|
232
|
+
return wrap;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* `resolved` names each column and says whether it is a key or a measure,
|
|
236
|
+
which is the only classification a breakdown has. Its `type` is the
|
|
237
|
+
database's own, so a summed REAL right-aligns and a text key does not. */
|
|
238
|
+
const byName = new Map((data.resolved ?? []).map((c) => [c.name, c]));
|
|
239
|
+
const plans = new Map(
|
|
240
|
+
data.columns.map((name) => [name, planColumn(byName.get(name), name, data.rows)]),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const headRow = el('tr');
|
|
244
|
+
for (const name of data.columns) {
|
|
245
|
+
const meta = byName.get(name);
|
|
246
|
+
const plan = plans.get(name);
|
|
247
|
+
const direction = sortDirection(entry.query, name);
|
|
248
|
+
headRow.append(el('th', {
|
|
249
|
+
class: `align-${plan.align}${direction ? ' sorted' : ''}`,
|
|
250
|
+
/* Which half of the answer this column is. A reader who did not write
|
|
251
|
+
the line cannot otherwise tell `count` the measure from a column
|
|
252
|
+
somebody happened to group by and call `count`. */
|
|
253
|
+
title: meta?.kind === 'measure'
|
|
254
|
+
? `${meta.fn}${meta.path ? `(${meta.path})` : ''} — a measure`
|
|
255
|
+
: meta?.path
|
|
256
|
+
? `${meta.path} — grouped by${meta.type ? ` · ${meta.type}` : ''}`
|
|
257
|
+
: name,
|
|
258
|
+
}, el('button', {
|
|
259
|
+
type: 'button',
|
|
260
|
+
class: 'th-sort',
|
|
261
|
+
/* Sorting a breakdown names an output column, which is exactly what this
|
|
262
|
+
header is. So the click is the same text edit it is on a table — the
|
|
263
|
+
sort stays visible in the language and survives being walked back to. */
|
|
264
|
+
title: `Sort by ${name}`,
|
|
265
|
+
onclick: () => go({ ...entry, query: nextSort(entry.query, name) }, 'replace'),
|
|
266
|
+
text: `${name}${direction === 'desc' ? ' ↓' : direction === 'asc' ? ' ↑' : ''}`,
|
|
267
|
+
})));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const tbody = el('tbody');
|
|
271
|
+
data.rows.forEach((row, i) => {
|
|
272
|
+
const tr = el('tr', { 'data-row': String(i) });
|
|
273
|
+
for (const name of data.columns) {
|
|
274
|
+
const value = row[name];
|
|
275
|
+
const plan = plans.get(name);
|
|
276
|
+
tr.append(el('td', { class: `align-${plan.align}` },
|
|
277
|
+
value === null || value === undefined
|
|
278
|
+
? el('span', { class: 'null-value', text: '—' })
|
|
279
|
+
: valueNode({ ...(byName.get(name) ?? {}), name }, value)));
|
|
280
|
+
}
|
|
281
|
+
tbody.append(tr);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
wrap.append(el('table', {}, [el('thead', {}, headRow), tbody]));
|
|
285
|
+
/* The keyboard cursor, with nowhere to open: `onOpen` is deliberately absent
|
|
286
|
+
rather than a no-op, so Enter does nothing instead of appearing to. */
|
|
287
|
+
initGrid(wrap, {
|
|
288
|
+
tbody,
|
|
289
|
+
rowCount: data.rows.length,
|
|
290
|
+
columnCount: data.columns.length,
|
|
291
|
+
totalRows: data.total,
|
|
292
|
+
firstRow: explicitLimit(entry.query) ? 0 : state.page * pageSize(),
|
|
293
|
+
});
|
|
294
|
+
return wrap;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The same paging the table view has, over groups instead of rows. */
|
|
298
|
+
function pager(entry, data, shown) {
|
|
299
|
+
const bar = el('div', { class: 'pager' });
|
|
300
|
+
// A line that names its own limit defines the whole answer — `sort billed
|
|
301
|
+
// desc limit 12` is a top-twelve, and a second page of it is a contradiction.
|
|
302
|
+
if (explicitLimit(entry.query)) return bar;
|
|
303
|
+
if (state.page > 0) {
|
|
304
|
+
bar.append(el('button', {
|
|
305
|
+
class: 'ghost', type: 'button', text: '← Previous',
|
|
306
|
+
onclick: () => { state.page--; render(); },
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
if (data.total !== undefined ? data.total > shown : data.rows.length >= pageSize()) {
|
|
310
|
+
bar.append(el('button', {
|
|
311
|
+
class: 'ghost', type: 'button', text: 'Next →',
|
|
312
|
+
onclick: () => { state.page++; render(); },
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
if (bar.childElementCount) bar.append(el('span', { class: 'page-label', text: `Page ${state.page + 1}` }));
|
|
316
|
+
return bar;
|
|
317
|
+
}
|