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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion for the SQL editor.
|
|
3
|
+
*
|
|
4
|
+
* The query language's completer knows the grammar it is completing, because
|
|
5
|
+
* tablewalk wrote that grammar. SQL is somebody else's language and this is not
|
|
6
|
+
* a parser for it — writing one to power a dropdown would be a large amount of
|
|
7
|
+
* code that is wrong in a different way for each dialect.
|
|
8
|
+
*
|
|
9
|
+
* What it does instead is answer two questions from the text to the left of the
|
|
10
|
+
* caret: **what is in scope**, and **what kind of thing goes here**. Both are
|
|
11
|
+
* answerable with a scan, and both degrade in a way that costs the reader
|
|
12
|
+
* nothing — a wrong guess offers a list they ignore, where a parser that
|
|
13
|
+
* refused to guess would offer nothing at all.
|
|
14
|
+
*
|
|
15
|
+
* The scope question is answered by finding `FROM x`, `JOIN y AS b` and their
|
|
16
|
+
* aliases anywhere in the statement, not only before the caret: people write
|
|
17
|
+
* `SELECT ` and then the columns they want, and at that moment the tables are
|
|
18
|
+
* all to the *right* of where they are typing. A completer that only looked
|
|
19
|
+
* backwards would be silent at exactly the moment it is most wanted.
|
|
20
|
+
*/
|
|
21
|
+
import { findTable, state } from './core.js';
|
|
22
|
+
|
|
23
|
+
/* Keywords offered as a last resort, in the order they occur in a statement
|
|
24
|
+
rather than alphabetically — the point of the list is to suggest what comes
|
|
25
|
+
next, and `WHERE` is a likelier next word than `ALL`. */
|
|
26
|
+
const KEYWORDS = [
|
|
27
|
+
'SELECT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT', 'OFFSET',
|
|
28
|
+
'JOIN', 'LEFT JOIN', 'INNER JOIN', 'ON', 'AS', 'AND', 'OR', 'NOT', 'IN', 'LIKE',
|
|
29
|
+
'IS NULL', 'IS NOT NULL', 'BETWEEN', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
|
|
30
|
+
'DISTINCT', 'COUNT(', 'SUM(', 'AVG(', 'MIN(', 'MAX(', 'COALESCE(', 'WITH', 'UNION',
|
|
31
|
+
'ASC', 'DESC', 'EXISTS',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Words after which a table name is what comes next. */
|
|
35
|
+
const EXPECTS_TABLE = /\b(from|join|update|into|table)\s+(\w*)$/i;
|
|
36
|
+
|
|
37
|
+
/** Words after which a column name is what comes next. */
|
|
38
|
+
const EXPECTS_COLUMN =
|
|
39
|
+
/\b(select|where|on|and|or|by|having|set|distinct|,|\(|=|<|>|<=|>=|!=|<>|\+|-)\s*(\w*)$/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tables named in the statement, by the name they are referred to.
|
|
43
|
+
*
|
|
44
|
+
* Both directions: the alias if there is one, and the table's own name, since
|
|
45
|
+
* `FROM customer` makes `customer.id` valid too. Matching is deliberately loose
|
|
46
|
+
* about the shape of what follows — `AS` optional, a comma or newline ending
|
|
47
|
+
* it — because the alternative is a parser.
|
|
48
|
+
*/
|
|
49
|
+
export function scopeOf(text) {
|
|
50
|
+
const scope = new Map();
|
|
51
|
+
const pattern = /\b(?:from|join|update|into)\s+([A-Za-z_][\w.$]*)(?:\s+(?:as\s+)?([A-Za-z_]\w*))?/gi;
|
|
52
|
+
|
|
53
|
+
for (const match of text.matchAll(pattern)) {
|
|
54
|
+
const [, name, alias] = match;
|
|
55
|
+
// `FROM customer WHERE` — `WHERE` is not an alias, and neither is any other
|
|
56
|
+
// word that can legally follow a table name.
|
|
57
|
+
const isKeyword = alias && /^(where|join|left|right|inner|outer|cross|on|group|order|limit|having|union|set|values|using|and|or)$/i.test(alias);
|
|
58
|
+
const table = findTable(name) ?? state.schema?.tables.find((t) => t.name === name);
|
|
59
|
+
if (!table) continue;
|
|
60
|
+
scope.set(table.name, table);
|
|
61
|
+
scope.set(table.id, table);
|
|
62
|
+
if (alias && !isKeyword) scope.set(alias, table);
|
|
63
|
+
}
|
|
64
|
+
return scope;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The word being typed, and where it starts. Splits on SQL's punctuation. */
|
|
68
|
+
export function tokenAt(text, caret) {
|
|
69
|
+
const before = text.slice(0, caret);
|
|
70
|
+
const start = Math.max(0, ...[' ', '\n', '\t', ',', '(', ')', '=', '<', '>', '+', '-', '*', '/', ';']
|
|
71
|
+
.map((c) => before.lastIndexOf(c) + 1));
|
|
72
|
+
return { word: before.slice(start), start };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const columnItem = (table, column, detail) => ({
|
|
76
|
+
kind: column.primaryKey ? 'key' : column.references ? 'ref' : 'column',
|
|
77
|
+
label: column.name,
|
|
78
|
+
value: column.name,
|
|
79
|
+
detail: detail ?? `${table.name} · ${column.type}`,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What could come next, given where the caret is.
|
|
84
|
+
*
|
|
85
|
+
* Ordering within the list matters more than the list itself: the first entry
|
|
86
|
+
* is what Tab accepts, so it has to be the likeliest thing rather than the
|
|
87
|
+
* alphabetically first. Columns of tables actually in the statement come before
|
|
88
|
+
* every other table's columns, and keywords come last — a keyword is always
|
|
89
|
+
* *possible*, which is exactly why it is never the best guess.
|
|
90
|
+
*/
|
|
91
|
+
export function sqlSuggestions(text, caret) {
|
|
92
|
+
if (!state.schema) return [];
|
|
93
|
+
const { word } = tokenAt(text, caret);
|
|
94
|
+
const before = text.slice(0, caret);
|
|
95
|
+
const scope = scopeOf(text);
|
|
96
|
+
const matches = (name) => name.toLowerCase().startsWith(word.toLowerCase());
|
|
97
|
+
|
|
98
|
+
/* `alias.` — the one case with a definite answer. Nothing else can follow a
|
|
99
|
+
qualified name, so nothing else is offered. */
|
|
100
|
+
const qualified = /([A-Za-z_]\w*)\.(\w*)$/.exec(before);
|
|
101
|
+
if (qualified) {
|
|
102
|
+
const [, prefix, partial] = qualified;
|
|
103
|
+
const table = scope.get(prefix) ?? findTable(prefix) ?? state.schema.tables.find((t) => t.name === prefix);
|
|
104
|
+
if (!table) return [];
|
|
105
|
+
return table.columns
|
|
106
|
+
.filter((c) => c.name.toLowerCase().startsWith(partial.toLowerCase()))
|
|
107
|
+
.map((c) => ({ ...columnItem(table, c), value: c.name }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const out = [];
|
|
111
|
+
const tableItem = (t) => ({
|
|
112
|
+
kind: t.isView ? 'view' : 'table',
|
|
113
|
+
label: t.name,
|
|
114
|
+
value: t.id,
|
|
115
|
+
detail: `${t.columns.length} column${t.columns.length === 1 ? '' : 's'}`,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (EXPECTS_TABLE.test(before)) {
|
|
119
|
+
// After FROM, a table is the only sensible suggestion, so nothing dilutes it.
|
|
120
|
+
return state.schema.tables.filter((t) => matches(t.name) || matches(t.id)).map(tableItem);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (EXPECTS_COLUMN.test(before) || word) {
|
|
124
|
+
for (const table of new Set(scope.values())) {
|
|
125
|
+
for (const column of table.columns) {
|
|
126
|
+
if (matches(column.name)) out.push(columnItem(table, column));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/* Aliases and table names themselves, so `c.` is reachable by typing `c`
|
|
130
|
+
and accepting rather than by remembering it. */
|
|
131
|
+
for (const [name, table] of scope) {
|
|
132
|
+
if (matches(name) && name !== table.name) {
|
|
133
|
+
out.push({ kind: 'alias', label: name, value: name, detail: `alias for ${table.name}` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/* Every other table's columns, once the statement's own are exhausted. A
|
|
137
|
+
column typed before its FROM clause exists is a real way people write
|
|
138
|
+
SQL, and offering nothing there is worse than offering a long list. */
|
|
139
|
+
if (!out.length) {
|
|
140
|
+
for (const table of state.schema.tables) {
|
|
141
|
+
if (scope.has(table.name)) continue;
|
|
142
|
+
for (const column of table.columns) {
|
|
143
|
+
if (matches(column.name)) out.push(columnItem(table, column, `${table.name} · not in this statement`));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!word || EXPECTS_TABLE.test(before) === false) {
|
|
150
|
+
for (const keyword of KEYWORDS) {
|
|
151
|
+
if (matches(keyword)) {
|
|
152
|
+
out.push({ kind: 'keyword', label: keyword, value: keyword, detail: '' });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Tables last among the non-keywords: a bare word is more often a column.
|
|
158
|
+
for (const table of state.schema.tables) {
|
|
159
|
+
if (matches(table.name) && !scope.has(table.name)) out.push(tableItem(table));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved SQL statements: the ones you keep, and the ones your team commits.
|
|
3
|
+
*
|
|
4
|
+
* Two stores, one list. A statement in `tablewalk.json` was checked in by
|
|
5
|
+
* somebody and is the same to the person looking for it as one they saved this
|
|
6
|
+
* morning — the only difference is whether they may delete it. Merging them
|
|
7
|
+
* here rather than showing two lists is the whole point: nobody wants to
|
|
8
|
+
* remember which half of their query library they are looking at.
|
|
9
|
+
*
|
|
10
|
+
* Stored as text, never as anything compiled. It stays readable in the editor,
|
|
11
|
+
* it survives the schema changing underneath it — at worst as a visible error
|
|
12
|
+
* you can fix rather than a silently wrong answer — and a statement in the
|
|
13
|
+
* config file can be reviewed by someone who knows SQL and nothing about this
|
|
14
|
+
* tool.
|
|
15
|
+
*/
|
|
16
|
+
import { api, loadJson, saveJson, state, toast } from './core.js';
|
|
17
|
+
|
|
18
|
+
const KEY = 'tablewalk.sql.v1';
|
|
19
|
+
|
|
20
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
21
|
+
|
|
22
|
+
/** Config-defined statements for the open connection, fetched once per switch. */
|
|
23
|
+
let fromConfig = [];
|
|
24
|
+
let fetchedFor = null;
|
|
25
|
+
|
|
26
|
+
export async function loadSavedSql() {
|
|
27
|
+
const connection = connectionKey();
|
|
28
|
+
try {
|
|
29
|
+
const data = await api('/api/queries');
|
|
30
|
+
/* Checked against the connection in hand, not the one this call started
|
|
31
|
+
on. Two fast switches meant the slower reply landed last and wrote
|
|
32
|
+
*its* statements under the *current* connection's name — one database's
|
|
33
|
+
saved SQL listed as another's, offered on a schema it was never written
|
|
34
|
+
against.
|
|
35
|
+
|
|
36
|
+
The server says which connection answered, so the reply is checked
|
|
37
|
+
against that rather than against a variable this function captured. */
|
|
38
|
+
if (data.connection && data.connection !== connectionKey()) return;
|
|
39
|
+
if (connection !== connectionKey()) return;
|
|
40
|
+
fromConfig = data.queries ?? [];
|
|
41
|
+
fetchedFor = connectionKey();
|
|
42
|
+
} catch {
|
|
43
|
+
/* A config file that does not answer costs the committed half of the list.
|
|
44
|
+
The browser's own statements still work, which is the right way round. */
|
|
45
|
+
if (connection !== connectionKey()) return;
|
|
46
|
+
fromConfig = [];
|
|
47
|
+
fetchedFor = connectionKey();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function localFor(connection) {
|
|
52
|
+
return loadJson(KEY, {})[connection] ?? [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Everything saved for this connection, config first.
|
|
57
|
+
*
|
|
58
|
+
* Config first because a checked-in statement is usually the canonical one and
|
|
59
|
+
* the personal ones are variations on it.
|
|
60
|
+
*/
|
|
61
|
+
export function savedStatements() {
|
|
62
|
+
const connection = connectionKey();
|
|
63
|
+
const config = fetchedFor === connection ? fromConfig : [];
|
|
64
|
+
return [
|
|
65
|
+
...config.map((q) => ({ ...q, source: 'config' })),
|
|
66
|
+
...localFor(connection).map((q) => ({ ...q, source: 'saved' })),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Keep a statement under a name.
|
|
72
|
+
*
|
|
73
|
+
* A repeated name replaces rather than duplicating: two entries called "Daily
|
|
74
|
+
* totals" that do different things is worse than losing the older one, and the
|
|
75
|
+
* person typing the name a second time is almost always correcting the first.
|
|
76
|
+
* A name the config file already uses is refused instead — that one is not
|
|
77
|
+
* ours to replace, and silently shadowing it would make a committed statement
|
|
78
|
+
* mean something different on one machine.
|
|
79
|
+
*/
|
|
80
|
+
export function saveStatement(name, sql) {
|
|
81
|
+
const connection = connectionKey();
|
|
82
|
+
const trimmed = name.trim();
|
|
83
|
+
if (!trimmed) return false;
|
|
84
|
+
|
|
85
|
+
if (fromConfig.some((q) => q.name === trimmed)) {
|
|
86
|
+
toast(`"${trimmed}" is defined in tablewalk.json. Choose another name.`, 'error');
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const all = loadJson(KEY, {});
|
|
91
|
+
const kept = (all[connection] ?? []).filter((q) => q.name !== trimmed);
|
|
92
|
+
kept.push({ name: trimmed, sql: sql.trim() });
|
|
93
|
+
all[connection] = kept;
|
|
94
|
+
|
|
95
|
+
if (!saveJson(KEY, all)) {
|
|
96
|
+
toast('Could not save — this browser is not allowing local storage.', 'error');
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function removeStatement(name) {
|
|
103
|
+
const connection = connectionKey();
|
|
104
|
+
const all = loadJson(KEY, {});
|
|
105
|
+
all[connection] = (all[connection] ?? []).filter((q) => q.name !== name);
|
|
106
|
+
saveJson(KEY, all);
|
|
107
|
+
}
|