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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholders shaped like the thing that is coming.
|
|
3
|
+
*
|
|
4
|
+
* "Running…" tells you the tool is alive and nothing else. A placeholder that
|
|
5
|
+
* matches the layout about to replace it does more: the page stops jumping
|
|
6
|
+
* when the data lands, because the space was already the right size and shape,
|
|
7
|
+
* and the eye has somewhere to be while it waits.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep this from being decoration:
|
|
10
|
+
*
|
|
11
|
+
* - **Nothing appears immediately.** Most queries here finish in single-digit
|
|
12
|
+
* milliseconds, and a skeleton that flashes for 40ms is worse than no
|
|
13
|
+
* skeleton — it reads as a glitch. Placeholders are mounted only if the
|
|
14
|
+
* work is still going after a delay long enough that the wait was real.
|
|
15
|
+
* - **The shape is derived, not invented.** The grid placeholder uses the
|
|
16
|
+
* table's own column count and the page size; the record placeholder uses
|
|
17
|
+
* the record's own layout. A generic three-bar shimmer would be a lie about
|
|
18
|
+
* what is loading, and would still make the page jump when it resolved.
|
|
19
|
+
*/
|
|
20
|
+
import { el } from './core.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How long a wait has to last before it is worth showing anything.
|
|
24
|
+
*
|
|
25
|
+
* Under this, the result lands before a reader would register the placeholder,
|
|
26
|
+
* and showing one turns a fast response into a visible flicker.
|
|
27
|
+
*/
|
|
28
|
+
const DELAY = 140;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mount a placeholder into `host`, unless the work finishes first.
|
|
32
|
+
*
|
|
33
|
+
* Returns a function to call when it does. Calling it before the delay
|
|
34
|
+
* elapses means nothing is ever inserted; after, it removes what was.
|
|
35
|
+
*
|
|
36
|
+
* @param {Element} host
|
|
37
|
+
* @param {() => Element} build called lazily, so a placeholder that is never
|
|
38
|
+
* shown costs nothing to construct
|
|
39
|
+
*/
|
|
40
|
+
export function placeholder(host, build) {
|
|
41
|
+
let node = null;
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
node = build();
|
|
44
|
+
host.append(node);
|
|
45
|
+
}, DELAY);
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
node?.remove();
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One shimmering block. Width is a percentage so rows look uneven, as text is. */
|
|
54
|
+
function bar(width, { height = 12 } = {}) {
|
|
55
|
+
return el('span', {
|
|
56
|
+
class: 'sk-bar',
|
|
57
|
+
style: `width:${width}%; height:${height}px`,
|
|
58
|
+
'aria-hidden': 'true',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Deterministic per position rather than random, so a placeholder does not
|
|
63
|
+
reshuffle itself if it is ever rebuilt, and two adjacent rows never come out
|
|
64
|
+
identical — which is what makes a block of bars read as text rather than as
|
|
65
|
+
a bar chart. */
|
|
66
|
+
const widths = [82, 54, 71, 45, 63, 88, 38, 76, 59, 67];
|
|
67
|
+
const widthAt = (i) => widths[i % widths.length];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A grid, before its rows arrive.
|
|
71
|
+
*
|
|
72
|
+
* @param {number} columns how many columns the table has
|
|
73
|
+
* @param {number} rows how many rows the page will hold
|
|
74
|
+
*/
|
|
75
|
+
export function gridSkeleton(columns, rows) {
|
|
76
|
+
// Bounded: a forty-column table does not need forty placeholder bars to
|
|
77
|
+
// communicate "a table is coming", and the real grid scrolls sideways anyway.
|
|
78
|
+
const across = Math.min(Math.max(columns, 3), 8);
|
|
79
|
+
const down = Math.min(Math.max(rows, 3), 12);
|
|
80
|
+
|
|
81
|
+
const grid = el('div', {
|
|
82
|
+
class: 'sk-grid',
|
|
83
|
+
style: `--sk-cols:${across}`,
|
|
84
|
+
role: 'status',
|
|
85
|
+
'aria-label': 'Loading rows',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
grid.append(el('div', { class: 'sk-row sk-head' },
|
|
89
|
+
Array.from({ length: across }, (_, i) => bar(40 + ((i * 17) % 30), { height: 8 }))));
|
|
90
|
+
|
|
91
|
+
for (let r = 0; r < down; r += 1) {
|
|
92
|
+
grid.append(el('div', { class: 'sk-row' },
|
|
93
|
+
Array.from({ length: across }, (_, c) => bar(widthAt(r * across + c)))));
|
|
94
|
+
}
|
|
95
|
+
return grid;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A record, before its fields arrive.
|
|
100
|
+
*
|
|
101
|
+
* Takes the section shapes from the layout that is about to be rendered, so
|
|
102
|
+
* the headings land where the headings will be and the two-column field grid
|
|
103
|
+
* is already the right height.
|
|
104
|
+
*
|
|
105
|
+
* @param {Array<{title?: string, count: number}>} sections
|
|
106
|
+
*/
|
|
107
|
+
export function recordSkeleton(sections) {
|
|
108
|
+
const wrap = el('div', { class: 'sk-record', role: 'status', 'aria-label': 'Loading record' });
|
|
109
|
+
|
|
110
|
+
for (const section of sections) {
|
|
111
|
+
const block = el('section', { class: 'sk-section' });
|
|
112
|
+
if (section.title) block.append(bar(14, { height: 9 }));
|
|
113
|
+
const list = el('div', { class: 'sk-fields' });
|
|
114
|
+
for (let i = 0; i < section.count; i += 1) {
|
|
115
|
+
list.append(el('div', { class: 'sk-field' }, [
|
|
116
|
+
bar(38, { height: 10 }),
|
|
117
|
+
bar(widthAt(i), { height: 12 }),
|
|
118
|
+
]));
|
|
119
|
+
}
|
|
120
|
+
block.append(list);
|
|
121
|
+
wrap.append(block);
|
|
122
|
+
}
|
|
123
|
+
return wrap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Related list, before its counts arrive. */
|
|
127
|
+
export function relatedSkeleton(count) {
|
|
128
|
+
const wrap = el('div', { class: 'sk-related', role: 'status', 'aria-label': 'Counting' });
|
|
129
|
+
for (let i = 0; i < Math.min(Math.max(count, 1), 8); i += 1) {
|
|
130
|
+
wrap.append(el('div', { class: 'sk-related-item' }, [
|
|
131
|
+
bar(100, { height: 14 }),
|
|
132
|
+
el('span', { class: 'sk-related-names' }, [
|
|
133
|
+
bar(widthAt(i), { height: 11 }),
|
|
134
|
+
bar(widthAt(i + 3) * 0.7, { height: 8 }),
|
|
135
|
+
]),
|
|
136
|
+
]));
|
|
137
|
+
}
|
|
138
|
+
return wrap;
|
|
139
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* The SQL editor.
|
|
2
|
+
|
|
3
|
+
A panel rather than a page: you write a statement against something you were
|
|
4
|
+
already looking at, and losing that context to a full-screen editor makes
|
|
5
|
+
every trip to SQL a trip away from the walk. It sits over the bottom of the
|
|
6
|
+
window, tall enough to read a result in and short enough that the page it
|
|
7
|
+
came from is still there behind it. */
|
|
8
|
+
|
|
9
|
+
.sql-panel {
|
|
10
|
+
position: fixed; left: 0; right: 0; bottom: 0; z-index: 75;
|
|
11
|
+
display: flex; flex-direction: column;
|
|
12
|
+
max-height: 78vh;
|
|
13
|
+
background: var(--surface);
|
|
14
|
+
border-top: 1px solid var(--border-strong);
|
|
15
|
+
box-shadow: 0 -12px 40px rgb(0 0 0 / 0.16);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.sql-head {
|
|
19
|
+
display: flex; align-items: center; gap: 14px;
|
|
20
|
+
padding: 10px 16px;
|
|
21
|
+
border-bottom: 1px solid var(--border);
|
|
22
|
+
flex: none;
|
|
23
|
+
}
|
|
24
|
+
.sql-title { display: flex; align-items: baseline; gap: 10px; }
|
|
25
|
+
.sql-eyebrow {
|
|
26
|
+
font: 600 12px var(--mono); color: var(--accent);
|
|
27
|
+
text-transform: uppercase; letter-spacing: 0.08em;
|
|
28
|
+
}
|
|
29
|
+
.sql-target { font: 11px var(--mono); color: var(--text-faint); }
|
|
30
|
+
.sql-head-actions { margin-left: auto; display: flex; gap: 8px; }
|
|
31
|
+
.sql-write { color: var(--danger); }
|
|
32
|
+
.sql-write:hover { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
|
|
33
|
+
|
|
34
|
+
.sql-editor { padding: 12px 16px; flex: none; position: relative; }
|
|
35
|
+
.sql-input {
|
|
36
|
+
display: block; width: 100%; resize: none;
|
|
37
|
+
padding: 10px 12px;
|
|
38
|
+
border: 1px solid var(--border); border-radius: 7px;
|
|
39
|
+
background: var(--bg); color: var(--text);
|
|
40
|
+
font: 13px/1.55 var(--mono);
|
|
41
|
+
tab-size: 2;
|
|
42
|
+
}
|
|
43
|
+
.sql-input:focus { outline: none; border-color: var(--accent); }
|
|
44
|
+
.sql-input::placeholder { color: var(--text-faint); }
|
|
45
|
+
|
|
46
|
+
.sql-results { overflow: auto; flex: 1; min-height: 0; padding-bottom: 12px; }
|
|
47
|
+
|
|
48
|
+
.sql-stats {
|
|
49
|
+
display: flex; gap: 14px; align-items: baseline;
|
|
50
|
+
padding: 2px 16px 8px;
|
|
51
|
+
font: 11px var(--mono); color: var(--text-faint);
|
|
52
|
+
font-variant-numeric: tabular-nums;
|
|
53
|
+
}
|
|
54
|
+
/* Says which kind of "there is more" this is: a page of a bounded read, or the
|
|
55
|
+
front of an answer the database computed in full. */
|
|
56
|
+
.sql-truncated { color: var(--key); }
|
|
57
|
+
|
|
58
|
+
.sql-error {
|
|
59
|
+
margin: 0 16px; padding: 10px 12px;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
|
62
|
+
border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent);
|
|
63
|
+
color: var(--danger);
|
|
64
|
+
font: 12px/1.5 var(--mono);
|
|
65
|
+
white-space: pre-wrap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Said before the grid, because a row that opens a record should say so before
|
|
69
|
+
it is clicked rather than after. */
|
|
70
|
+
.sql-walk {
|
|
71
|
+
margin: 0 16px 6px; font: 11px var(--mono); color: var(--accent);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Anchored to the caret rather than the field — see placeAtCaret. */
|
|
75
|
+
.ac-mirror {
|
|
76
|
+
position: absolute; visibility: hidden; pointer-events: none;
|
|
77
|
+
top: 0; left: -9999px;
|
|
78
|
+
overflow-wrap: break-word;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/* The statements you have kept. One row, config and personal together — to
|
|
83
|
+
whoever is looking for one they are the same thing, and the difference is
|
|
84
|
+
only whether it can be deleted. */
|
|
85
|
+
.sql-saved {
|
|
86
|
+
display: flex; gap: 6px; flex-wrap: wrap;
|
|
87
|
+
padding: 0 16px 4px;
|
|
88
|
+
flex: none;
|
|
89
|
+
}
|
|
90
|
+
.sql-chip {
|
|
91
|
+
display: inline-flex; align-items: center;
|
|
92
|
+
border: 1px solid var(--border); border-radius: 999px;
|
|
93
|
+
background: var(--bg);
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
}
|
|
96
|
+
.sql-chip:hover { border-color: var(--accent); }
|
|
97
|
+
.sql-chip-open {
|
|
98
|
+
padding: 3px 10px;
|
|
99
|
+
background: none; border: none;
|
|
100
|
+
color: var(--text-dim); cursor: pointer;
|
|
101
|
+
font: 11.5px var(--mono);
|
|
102
|
+
}
|
|
103
|
+
.sql-chip:hover .sql-chip-open { color: var(--accent); }
|
|
104
|
+
.sql-chip-remove {
|
|
105
|
+
padding: 3px 8px 3px 2px;
|
|
106
|
+
background: none; border: none;
|
|
107
|
+
color: var(--text-faint); cursor: pointer; font-size: 12px; line-height: 1;
|
|
108
|
+
}
|
|
109
|
+
.sql-chip-remove:hover { color: var(--danger); }
|
|
110
|
+
/* Not a button: a config statement is not this browser's to delete, and an ×
|
|
111
|
+
that refuses is worse than no ×. */
|
|
112
|
+
.sql-chip-tag {
|
|
113
|
+
padding: 2px 8px 2px 2px;
|
|
114
|
+
color: var(--text-faint);
|
|
115
|
+
font: 9px var(--mono); text-transform: uppercase; letter-spacing: 0.05em;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* An empty result knows its shape, and saying so is the difference between
|
|
119
|
+
"your statement returned nothing" and "something went wrong". */
|
|
120
|
+
.sql-empty {
|
|
121
|
+
margin: 0 16px; padding: 10px 12px;
|
|
122
|
+
border: 1px solid var(--border); border-radius: 6px;
|
|
123
|
+
background: var(--surface-2);
|
|
124
|
+
font: 12px/1.6 var(--mono); color: var(--text-dim);
|
|
125
|
+
}
|
|
126
|
+
.sql-empty strong { color: var(--text); }
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL editor.
|
|
3
|
+
*
|
|
4
|
+
* tablewalk's argument against a SQL box is in `src/shared/query.ts`: a tool
|
|
5
|
+
* that lets people type SQL "would have to either trust them or write a SQL
|
|
6
|
+
* parser to untrust them". That is a good argument for why the *query language*
|
|
7
|
+
* exists and a bad reason to have no SQL at all — every language has a last
|
|
8
|
+
* mile, and for this one it is window functions, recursive CTEs and `EXPLAIN`.
|
|
9
|
+
* Without a SQL box that last mile means leaving for `psql`, and once you have
|
|
10
|
+
* left you do not come back for the walk.
|
|
11
|
+
*
|
|
12
|
+
* The dilemma is dissolved rather than answered: the statement is not trusted
|
|
13
|
+
* *or* parsed, it is handed to a connection that cannot write. See `runSql` in
|
|
14
|
+
* adapters/adapter.ts for what that costs and what it buys.
|
|
15
|
+
*
|
|
16
|
+
* Two ways in, on purpose. A statement typed from scratch is what people expect
|
|
17
|
+
* from a database browser. The other is the ramp: run something in the query
|
|
18
|
+
* language, open the SQL pane, and edit what it compiled to. That turns the
|
|
19
|
+
* pane from a thing you read into a thing you start from, and it is the only
|
|
20
|
+
* place in the tool that teaches the SQL behind the language.
|
|
21
|
+
*/
|
|
22
|
+
import { $, api, el, explainedSql, go, render, state, toast, whenConnectionChanges } from './core.js';
|
|
23
|
+
import { attachComplete } from './complete.js';
|
|
24
|
+
import { sqlSuggestions, tokenAt } from './sqlcomplete.js';
|
|
25
|
+
import { renderGrid } from './table.js';
|
|
26
|
+
import { addMenuItem } from './menu.js';
|
|
27
|
+
import { promptFor } from './prompt.js';
|
|
28
|
+
import { removeStatement, saveStatement, savedStatements } from './sqlsaved.js';
|
|
29
|
+
|
|
30
|
+
/** The statement being edited, per connection, so switching back keeps it. */
|
|
31
|
+
const drafts = new Map();
|
|
32
|
+
|
|
33
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
34
|
+
|
|
35
|
+
let panel = null;
|
|
36
|
+
|
|
37
|
+
export const sqlOpen = () => Boolean(panel);
|
|
38
|
+
|
|
39
|
+
export function openSql(initial) {
|
|
40
|
+
if (panel) {
|
|
41
|
+
if (initial !== undefined) setText(initial);
|
|
42
|
+
panel.querySelector('.sql-input')?.focus();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (initial !== undefined) drafts.set(connectionKey(), initial);
|
|
46
|
+
paint();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setText(text) {
|
|
50
|
+
drafts.set(connectionKey(), text);
|
|
51
|
+
const input = panel?.querySelector('.sql-input');
|
|
52
|
+
if (input) {
|
|
53
|
+
input.value = text;
|
|
54
|
+
autoGrow(input);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function close() {
|
|
59
|
+
panel?.remove();
|
|
60
|
+
panel = null;
|
|
61
|
+
document.removeEventListener('keydown', onKey, true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onKey(e) {
|
|
65
|
+
if (e.key !== 'Escape') return;
|
|
66
|
+
// The completion list eats Escape first; this only fires when it is closed.
|
|
67
|
+
if (document.querySelector('.ac:not([hidden])')) return;
|
|
68
|
+
const tag = document.activeElement?.tagName;
|
|
69
|
+
if (tag === 'TEXTAREA' || tag === 'INPUT') { document.activeElement.blur(); return; }
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
close();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* A textarea that grows with its content, to a point. A fixed-height box makes
|
|
75
|
+
a six-line statement a two-line window onto a six-line statement; an
|
|
76
|
+
unbounded one pushes the results off the screen. */
|
|
77
|
+
function autoGrow(input) {
|
|
78
|
+
input.style.height = 'auto';
|
|
79
|
+
input.style.height = `${Math.min(Math.max(input.scrollHeight, 84), 340)}px`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function paint() {
|
|
83
|
+
const existing = panel;
|
|
84
|
+
panel = el('div', { class: 'sql-panel', role: 'dialog', 'aria-label': 'SQL' });
|
|
85
|
+
|
|
86
|
+
const writable = state.connections?.find((c) => c.id === state.activeConnection)?.writable;
|
|
87
|
+
|
|
88
|
+
const input = el('textarea', {
|
|
89
|
+
class: 'sql-input',
|
|
90
|
+
spellcheck: 'false',
|
|
91
|
+
autocapitalize: 'off',
|
|
92
|
+
autocomplete: 'off',
|
|
93
|
+
'aria-label': 'SQL statement',
|
|
94
|
+
placeholder: 'select * from customer where credit_limit > 5000',
|
|
95
|
+
});
|
|
96
|
+
input.value = drafts.get(connectionKey()) ?? '';
|
|
97
|
+
|
|
98
|
+
const results = el('div', { class: 'sql-results' });
|
|
99
|
+
|
|
100
|
+
const run = (write = false) => void execute(input.value, results, { write });
|
|
101
|
+
|
|
102
|
+
input.addEventListener('input', () => {
|
|
103
|
+
drafts.set(connectionKey(), input.value);
|
|
104
|
+
autoGrow(input);
|
|
105
|
+
});
|
|
106
|
+
input.addEventListener('keydown', (e) => {
|
|
107
|
+
// ⌘↵ runs. Plain Enter is a newline: this is a text editor, and a box where
|
|
108
|
+
// Enter submits is a box you cannot write two lines in.
|
|
109
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
run();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const head = el('div', { class: 'sql-head' }, [
|
|
116
|
+
el('div', { class: 'sql-title' }, [
|
|
117
|
+
el('span', { class: 'sql-eyebrow', text: 'SQL' }),
|
|
118
|
+
el('span', {
|
|
119
|
+
class: 'sql-target',
|
|
120
|
+
text: `${state.schema?.label ?? ''} · ${state.schema?.dialect ?? ''}`,
|
|
121
|
+
}),
|
|
122
|
+
]),
|
|
123
|
+
el('div', { class: 'sql-head-actions' }, [
|
|
124
|
+
/* Offered only where the server would allow it. A button that exists to
|
|
125
|
+
be refused is a button that lies. */
|
|
126
|
+
writable
|
|
127
|
+
? el('button', {
|
|
128
|
+
type: 'button', class: 'ghost sql-write',
|
|
129
|
+
title: 'Run this statement on the write connection',
|
|
130
|
+
text: 'Run as write…',
|
|
131
|
+
onclick: () => void confirmWrite(input.value, results),
|
|
132
|
+
})
|
|
133
|
+
: null,
|
|
134
|
+
el('button', {
|
|
135
|
+
type: 'button', class: 'ghost', text: 'Save…',
|
|
136
|
+
title: 'Keep this statement under a name',
|
|
137
|
+
onclick: () => void save(input.value),
|
|
138
|
+
}),
|
|
139
|
+
el('button', { type: 'button', text: 'Run ⌘↵', onclick: () => run() }),
|
|
140
|
+
el('button', { type: 'button', class: 'ghost', text: 'Done', onclick: close }),
|
|
141
|
+
].filter(Boolean)),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
panel.append(head, savedBar(), el('div', { class: 'sql-editor' }, input), results);
|
|
145
|
+
document.body.append(panel);
|
|
146
|
+
existing?.remove();
|
|
147
|
+
|
|
148
|
+
attachComplete(input, sqlSuggestions, { anchor: 'caret', split: tokenAt });
|
|
149
|
+
document.addEventListener('keydown', onKey, true);
|
|
150
|
+
|
|
151
|
+
autoGrow(input);
|
|
152
|
+
input.focus();
|
|
153
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The statements you have kept, as one row above the editor.
|
|
158
|
+
*
|
|
159
|
+
* Config-defined and browser-saved together, because to whoever is looking for
|
|
160
|
+
* one they are the same thing — the difference is only whether it can be
|
|
161
|
+
* deleted, which is why only one of them offers an ×.
|
|
162
|
+
*/
|
|
163
|
+
function savedBar() {
|
|
164
|
+
const all = savedStatements();
|
|
165
|
+
if (!all.length) return el('div', { hidden: true });
|
|
166
|
+
|
|
167
|
+
const bar = el('div', { class: 'sql-saved' });
|
|
168
|
+
for (const item of all) {
|
|
169
|
+
bar.append(el('span', { class: `sql-chip source-${item.source}` }, [
|
|
170
|
+
el('button', {
|
|
171
|
+
type: 'button',
|
|
172
|
+
class: 'sql-chip-open',
|
|
173
|
+
title: item.description ?? item.sql,
|
|
174
|
+
text: item.name,
|
|
175
|
+
onclick: () => setText(item.sql),
|
|
176
|
+
}),
|
|
177
|
+
item.source === 'saved'
|
|
178
|
+
? el('button', {
|
|
179
|
+
type: 'button', class: 'sql-chip-remove',
|
|
180
|
+
title: `Forget "${item.name}"`,
|
|
181
|
+
'aria-label': `Forget ${item.name}`,
|
|
182
|
+
text: '×',
|
|
183
|
+
onclick: () => { removeStatement(item.name); paint(); },
|
|
184
|
+
})
|
|
185
|
+
: el('span', { class: 'sql-chip-tag', title: 'Defined in tablewalk.json', text: 'config' }),
|
|
186
|
+
]));
|
|
187
|
+
}
|
|
188
|
+
return bar;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function save(sql) {
|
|
192
|
+
const statement = sql.trim();
|
|
193
|
+
if (!statement) return;
|
|
194
|
+
const name = await promptFor({
|
|
195
|
+
title: 'Name this statement',
|
|
196
|
+
label: 'Name',
|
|
197
|
+
hint: statement.length > 120 ? `${statement.slice(0, 120)}…` : statement,
|
|
198
|
+
placeholder: 'e.g. Customers over their limit',
|
|
199
|
+
});
|
|
200
|
+
if (!name) return;
|
|
201
|
+
if (saveStatement(name, statement)) {
|
|
202
|
+
toast(`Saved "${name}".`, 'ok');
|
|
203
|
+
paint();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run a statement and render whatever comes back.
|
|
209
|
+
*
|
|
210
|
+
* Errors land beside the editor rather than as a toast that disappears: a SQL
|
|
211
|
+
* error is something you read while fixing the statement it came from, and the
|
|
212
|
+
* database's own wording is kept because any paraphrase says less.
|
|
213
|
+
*/
|
|
214
|
+
async function execute(text, results, { write = false } = {}) {
|
|
215
|
+
const statement = text.trim();
|
|
216
|
+
if (!statement) return;
|
|
217
|
+
|
|
218
|
+
results.replaceChildren(el('p', { class: 'loading', text: 'Running…' }));
|
|
219
|
+
let data;
|
|
220
|
+
try {
|
|
221
|
+
data = await api('/api/sql', { text: statement, limit: 500, write });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
results.replaceChildren(el('p', { class: 'sql-error', text: err.message }));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (data.errors?.length) {
|
|
228
|
+
results.replaceChildren(el('p', { class: 'sql-error', text: data.errors[0].message }));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (write) {
|
|
233
|
+
toast(`${data.affected} row${data.affected === 1 ? '' : 's'} changed.`, 'ok');
|
|
234
|
+
results.replaceChildren(
|
|
235
|
+
el('p', { class: 'note', text: `${data.affected} row${data.affected === 1 ? '' : 's'} changed.` }),
|
|
236
|
+
);
|
|
237
|
+
// The schema and every open page may now be stale.
|
|
238
|
+
render();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
results.replaceChildren(...[
|
|
243
|
+
el('div', { class: 'sql-stats' }, [
|
|
244
|
+
el('span', { text: `${data.rows.length} row${data.rows.length === 1 ? '' : 's'}` }),
|
|
245
|
+
el('span', { text: `${data.ms} ms` }),
|
|
246
|
+
/* Said rather than implied. `bounded: false` means the database ran the
|
|
247
|
+
whole statement and we are showing the front of the answer — which is
|
|
248
|
+
a different fact from "there are more rows", and the reader should not
|
|
249
|
+
have to infer which one they are looking at. */
|
|
250
|
+
data.truncated
|
|
251
|
+
? el('span', {
|
|
252
|
+
class: 'sql-truncated',
|
|
253
|
+
text: data.bounded
|
|
254
|
+
? `first ${data.rows.length} — add a LIMIT to see a different page`
|
|
255
|
+
: `first ${data.rows.length} of what the database returned`,
|
|
256
|
+
})
|
|
257
|
+
: null,
|
|
258
|
+
].filter(Boolean)),
|
|
259
|
+
grid(data),
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rows as a grid, walkable when the columns can identify a row.
|
|
265
|
+
*
|
|
266
|
+
* A result whose columns include every part of some table's primary key can be
|
|
267
|
+
* walked, which is what stops SQL being a dead end: the answer feeds back into
|
|
268
|
+
* the thing the tool is for. When it cannot, the rows are inert *and look it* —
|
|
269
|
+
* `renderGrid` is given no click handler, so nothing offers a click that does
|
|
270
|
+
* nothing.
|
|
271
|
+
*/
|
|
272
|
+
function grid(data) {
|
|
273
|
+
const target = walkableTable(data.columns);
|
|
274
|
+
/* `renderGrid` reads rows as objects keyed by column name; the wire format is
|
|
275
|
+
positional so duplicate names survive the trip. Rebuilt here with the
|
|
276
|
+
deduplicated names, which is the last point both are in hand. */
|
|
277
|
+
const rows = data.rows.map((row) =>
|
|
278
|
+
Object.fromEntries(data.columns.map((name, i) => [name, row[i]])),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const wrap = el('div');
|
|
282
|
+
|
|
283
|
+
/* An empty result still knows its shape, and saying so is the difference
|
|
284
|
+
between "your statement returned nothing" and "something went wrong".
|
|
285
|
+
The adapters go out of their way to report columns for a result with no
|
|
286
|
+
rows; the shared grid returns "No rows match." before it looks at them,
|
|
287
|
+
which is right for a table view and throws away the useful half here. */
|
|
288
|
+
if (!data.rows.length) {
|
|
289
|
+
wrap.append(
|
|
290
|
+
el('p', { class: 'sql-empty' }, [
|
|
291
|
+
el('strong', { text: 'No rows.' }),
|
|
292
|
+
el('span', {
|
|
293
|
+
text: data.columns.length
|
|
294
|
+
? ` The statement returned these columns: ${data.columns.join(', ')}.`
|
|
295
|
+
: ' The statement returned no columns either.',
|
|
296
|
+
}),
|
|
297
|
+
]),
|
|
298
|
+
);
|
|
299
|
+
return wrap;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (target) {
|
|
303
|
+
wrap.append(el('p', { class: 'sql-walk', text: `Rows open as ${target.name} records.` }));
|
|
304
|
+
}
|
|
305
|
+
wrap.append(
|
|
306
|
+
renderGrid(target, { columns: data.columns, rows }, {
|
|
307
|
+
onRowClick: target ? (row) => openRecord(target, row) : undefined,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
return wrap;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The table whose rows these are, if the columns can say. */
|
|
314
|
+
function walkableTable(columns) {
|
|
315
|
+
const present = new Set(columns);
|
|
316
|
+
const candidates = (state.schema?.tables ?? []).filter((t) => {
|
|
317
|
+
const key = t.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
318
|
+
return key.length > 0 && key.every((c) => present.has(c));
|
|
319
|
+
});
|
|
320
|
+
if (!candidates.length) return null;
|
|
321
|
+
/* Several tables can share a key shape — `id` is not distinctive. The one
|
|
322
|
+
whose other columns also appear is the one the statement is about. */
|
|
323
|
+
return candidates.sort((a, b) => overlap(b, present) - overlap(a, present))[0];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const overlap = (table, present) => table.columns.filter((c) => present.has(c.name)).length;
|
|
327
|
+
|
|
328
|
+
function openRecord(table, row) {
|
|
329
|
+
const key = {};
|
|
330
|
+
for (const column of table.columns.filter((c) => c.primaryKey)) key[column.name] = row[column.name];
|
|
331
|
+
close();
|
|
332
|
+
go({ kind: 'row', table: table.id, key, label: String(Object.values(key).join('/')) }, 'reset');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A write, with the statement in front of you.
|
|
337
|
+
*
|
|
338
|
+
* The confirm shows the exact text rather than asking "are you sure": the
|
|
339
|
+
* question worth answering is not whether you meant to press the button, it is
|
|
340
|
+
* whether the statement says what you think it says.
|
|
341
|
+
*/
|
|
342
|
+
async function confirmWrite(text, results) {
|
|
343
|
+
const statement = text.trim();
|
|
344
|
+
if (!statement) return;
|
|
345
|
+
|
|
346
|
+
const dialog = el('dialog', { class: 'confirm' }, [
|
|
347
|
+
el('h2', { text: 'Run this as a write?' }),
|
|
348
|
+
el('p', { text: 'This runs on the write connection. It is not undoable from here.' }),
|
|
349
|
+
el('pre', { class: 'confirm-params', text: statement }),
|
|
350
|
+
el('div', { class: 'confirm-actions' }, [
|
|
351
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
|
|
352
|
+
el('button', {
|
|
353
|
+
type: 'button', class: 'danger', text: 'Run as write',
|
|
354
|
+
onclick: () => { dialog.close(); void execute(statement, results, { write: true }); },
|
|
355
|
+
}),
|
|
356
|
+
]),
|
|
357
|
+
]);
|
|
358
|
+
document.body.append(dialog);
|
|
359
|
+
dialog.addEventListener('close', () => dialog.remove());
|
|
360
|
+
dialog.showModal();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function initSql() {
|
|
364
|
+
/* The ramp.
|
|
365
|
+
|
|
366
|
+
The SQL pane has always shown what the query language compiled to, and it
|
|
367
|
+
has always been a `<pre>` — something to read. Making it a starting point
|
|
368
|
+
costs one button and changes what the pane is for: you can now build a
|
|
369
|
+
query the easy way, see the statement, and take it over at the point the
|
|
370
|
+
language runs out. That is the shortest path from knowing the language to
|
|
371
|
+
knowing the database, and nothing else in the tool offers it. */
|
|
372
|
+
$('explain-edit')?.addEventListener('click', () => openSql(explainedSql()));
|
|
373
|
+
|
|
374
|
+
/* An open editor follows the database it is switched to.
|
|
375
|
+
|
|
376
|
+
The panel is built entirely from state — which connection it names, which
|
|
377
|
+
dialect, whether "Run as write…" is offered, and which draft is in the
|
|
378
|
+
box — and it was built once. Left open across a switch it kept the head
|
|
379
|
+
it started with: it went on naming the previous database while the
|
|
380
|
+
statement it ran went to the new one, and it kept offering a write on a
|
|
381
|
+
connection the server would refuse. `paint()` already replaces an
|
|
382
|
+
existing panel, so following the switch is one call. */
|
|
383
|
+
whenConnectionChanges(() => {
|
|
384
|
+
if (panel) paint();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
addMenuItem({
|
|
388
|
+
label: 'SQL…',
|
|
389
|
+
detail: 'write a statement (⌘⇧↵)',
|
|
390
|
+
onSelect: () => openSql(),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
document.addEventListener('keydown', (e) => {
|
|
394
|
+
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey) || !e.shiftKey) return;
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
openSql();
|
|
397
|
+
}, true);
|
|
398
|
+
}
|