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,881 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query language, written down inside the tool that speaks it.
|
|
3
|
+
*
|
|
4
|
+
* Clicking here writes query text rather than hiding state, which is the best
|
|
5
|
+
* thing about this interface and also its one cliff: the first screen is an
|
|
6
|
+
* empty bar with a placeholder, and nothing on it says what the bar takes.
|
|
7
|
+
* Every other way of learning it — the README, the error messages, the SQL
|
|
8
|
+
* pane — is either somewhere else or only reachable after you have already
|
|
9
|
+
* guessed something that parses.
|
|
10
|
+
*
|
|
11
|
+
* Two answers, and they share a generator:
|
|
12
|
+
*
|
|
13
|
+
* - a reference panel (`?`, or the overflow menu), grammar on the left and
|
|
14
|
+
* working examples on the right;
|
|
15
|
+
* - three runnable examples on the empty state, so the landing page teaches
|
|
16
|
+
* the language instead of only drawing the schema.
|
|
17
|
+
*
|
|
18
|
+
* Both are built from the *connected* schema. A reference full of `customer`
|
|
19
|
+
* and `invoice` is a reference about somebody else's database: on a schema
|
|
20
|
+
* with sixty-six tables and none of those names it teaches the syntax and
|
|
21
|
+
* nothing about the thing in front of you, and every example in it is a link
|
|
22
|
+
* that errors when clicked. Examples that name real tables can be clicked, and
|
|
23
|
+
* clicking one is the fastest way to learn a language.
|
|
24
|
+
*
|
|
25
|
+
* What is documented here is what `src/shared/query.ts` accepts, checked by
|
|
26
|
+
* running every example against a real database rather than by reading the
|
|
27
|
+
* README. One thing the grammar looks like it should take is deliberately
|
|
28
|
+
* absent: `not contains`, which the compiler rejects in as many words.
|
|
29
|
+
*/
|
|
30
|
+
import { $, api, columnKind, el, labelColumn, looksLikeDateColumn, primaryKey, quote, state, trapFocus } from './core.js';
|
|
31
|
+
import { addMenuItem } from './menu.js';
|
|
32
|
+
|
|
33
|
+
/* Enough rows to see whether a column is categorical — two distinct values out
|
|
34
|
+
of five says `status`, five out of five says `reference` — and few enough
|
|
35
|
+
that the request costs nothing on a table with a million rows in it. */
|
|
36
|
+
const SAMPLE_ROWS = 5;
|
|
37
|
+
|
|
38
|
+
/* ---------- schema reading ----------
|
|
39
|
+
|
|
40
|
+
Mirrors of shared/schema.ts, the way core.js already mirrors `labelColumn`.
|
|
41
|
+
The client cannot import the TypeScript directly and there is no build step
|
|
42
|
+
to compile it, so the rule is the one core.js follows: mirror the small
|
|
43
|
+
derived helpers, and never let a mirror decide anything the server has
|
|
44
|
+
already decided. */
|
|
45
|
+
|
|
46
|
+
/* Was a third copy of the date test, with the same name pattern and a
|
|
47
|
+
narrower type test: `/char|text|varchar|string/` rather than the shared
|
|
48
|
+
classifier, so a `uuid`, `json`, `enum` or `clob` column named `created_at`
|
|
49
|
+
was a date to the server and to the query bar and not to the examples on
|
|
50
|
+
this page. The classifier is one function; asking it is cheaper than
|
|
51
|
+
keeping a copy honest. */
|
|
52
|
+
const isDate = (column) => looksLikeDateColumn(column);
|
|
53
|
+
const isNumber = (column) => columnKind(column.type) === 'number';
|
|
54
|
+
const isText = (column) => columnKind(column.type) === 'text';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Forward hops worth putting in an example.
|
|
58
|
+
*
|
|
59
|
+
* Two exclusions:
|
|
60
|
+
*
|
|
61
|
+
* - a composite key, whose hop name is its columns joined with `+`. The bar
|
|
62
|
+
* does take `invoice_id+line_no.sku`, but a two-column key is not what
|
|
63
|
+
* somebody meeting the dot for the first time should be reading — the
|
|
64
|
+
* point of the example is that a dot follows a reference, and a `+` in the
|
|
65
|
+
* middle of it is a second thing to explain before the first one lands.
|
|
66
|
+
* - two keys sharing a hop name, which `resolvePath` refuses as ambiguous
|
|
67
|
+
* rather than guessing. Emitting one would be emitting an error.
|
|
68
|
+
*/
|
|
69
|
+
function hops(schema, tableId) {
|
|
70
|
+
const forward = schema.foreignKeys.filter((fk) => fk.from.table === tableId && fk.from.columns.length === 1);
|
|
71
|
+
const counts = new Map();
|
|
72
|
+
for (const fk of forward) counts.set(fk.from.columns[0], (counts.get(fk.from.columns[0]) ?? 0) + 1);
|
|
73
|
+
const incoming = (id) => schema.foreignKeys.filter((fk) => fk.to.table === id).length;
|
|
74
|
+
|
|
75
|
+
return forward
|
|
76
|
+
.filter((fk) => counts.get(fk.from.columns[0]) === 1)
|
|
77
|
+
.map((fk) => ({ name: fk.from.columns[0], target: fk.to.table }))
|
|
78
|
+
/* Ordered here rather than taken as introspection left it. SQLite's
|
|
79
|
+
`PRAGMA foreign_key_list` hands back the *last* declared key first, so
|
|
80
|
+
the unordered version picked `duplicate_of_id` out of a ticket table
|
|
81
|
+
with nine references on it — a legal walk into "the ticket this one
|
|
82
|
+
duplicates", which is nobody's first question.
|
|
83
|
+
|
|
84
|
+
A self-reference goes last, and otherwise the most-pointed-at table
|
|
85
|
+
wins: the table half the schema references is the one the schema is
|
|
86
|
+
about, and a walk into it is a walk somebody recognises. */
|
|
87
|
+
.sort((a, b) => (a.target === tableId ? 1 : 0) - (b.target === tableId ? 1 : 0)
|
|
88
|
+
|| incoming(b.target) - incoming(a.target)
|
|
89
|
+
|| a.name.localeCompare(b.name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tableById = (schema, id) => schema.tables.find((t) => t.id === id);
|
|
93
|
+
|
|
94
|
+
/* ---------- values ----------
|
|
95
|
+
|
|
96
|
+
Real values, from a handful of real rows. `status = open` teaches more than
|
|
97
|
+
`status = <value>`, and — the part that matters — it is a link that returns
|
|
98
|
+
rows when clicked, because the value came out of the table it filters. */
|
|
99
|
+
|
|
100
|
+
/* Words the clause scanner will take as clauses wherever they appear.
|
|
101
|
+
`CLAUSE_RE` runs over the raw text before anything knows about quotes, so a
|
|
102
|
+
status of "order by" would be read as a sort clause and the example would
|
|
103
|
+
fail. Such a value is skipped rather than emitted and hoped for. */
|
|
104
|
+
const RESERVED = /(^|\s)(filter|where|show|select|sort|order|by|limit|and|or|not|is|in|like|contains|startswith|endswith|asc|desc)(\s|$)/i;
|
|
105
|
+
|
|
106
|
+
/** Whether a sampled value can be written into a query as a literal. */
|
|
107
|
+
function usable(value) {
|
|
108
|
+
if (value === null || value === undefined || typeof value === 'object') return false;
|
|
109
|
+
const text = String(value);
|
|
110
|
+
/* Long values make an unreadable example, and a quote inside one has no
|
|
111
|
+
escape in this grammar — `unquoteValue` strips the outer pair and nothing
|
|
112
|
+
else, so the value would end mid-word. */
|
|
113
|
+
return text !== '' && text.length <= 24 && !/["'\n]/.test(text) && !RESERVED.test(text);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* A comma inside a value breaks an `in` list and only an `in` list: the list
|
|
117
|
+
is split on commas before anything looks at the quotes around them. */
|
|
118
|
+
const listSafe = (value) => !value.includes(',');
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A sampled value, written the way someone would type it.
|
|
122
|
+
*
|
|
123
|
+
* The one rewrite is a flag: SQLite has no boolean type, so `is_urgent` comes
|
|
124
|
+
* back as 1 and 0, and `is_urgent = 0` is an example that needs the column's
|
|
125
|
+
* storage explained before it teaches anything. The compiler coerces `false`
|
|
126
|
+
* to whatever the column actually holds — that is the whole point of it
|
|
127
|
+
* holding the schema — so the readable form is also the correct one.
|
|
128
|
+
*/
|
|
129
|
+
function literal(column, value) {
|
|
130
|
+
const flag = /bool/i.test(column.type ?? '') || /^(is|has)_/i.test(column.name);
|
|
131
|
+
if (flag && /^(0|1|true|false)$/i.test(value)) return /^(1|true)$/i.test(value) ? 'true' : 'false';
|
|
132
|
+
return quote(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function valuesOf(rows, column) {
|
|
136
|
+
const seen = [];
|
|
137
|
+
for (const row of rows ?? []) {
|
|
138
|
+
const value = row?.[column];
|
|
139
|
+
if (!usable(value)) continue;
|
|
140
|
+
if (!seen.includes(String(value))) seen.push(String(value));
|
|
141
|
+
}
|
|
142
|
+
return seen;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A round number below the smallest value seen, so `>=` cannot miss. */
|
|
146
|
+
function roundDown(n) {
|
|
147
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
148
|
+
const magnitude = 10 ** Math.floor(Math.log10(n));
|
|
149
|
+
return Math.floor(n / magnitude) * magnitude;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ---------- choosing what to talk about ---------- */
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The table the examples are mostly about.
|
|
156
|
+
*
|
|
157
|
+
* A hub with outgoing references and a readable label column, because that is
|
|
158
|
+
* the table where every part of the language has something to say: it has
|
|
159
|
+
* columns worth filtering, a reference worth walking, and a name worth
|
|
160
|
+
* showing. Picking the first table alphabetically gets you `address` on a
|
|
161
|
+
* schema whose story is `sales_order`.
|
|
162
|
+
*
|
|
163
|
+
* Views rank below tables — a view is someone's saved answer, and the first
|
|
164
|
+
* thing a newcomer should see is the shape of the data.
|
|
165
|
+
*/
|
|
166
|
+
function rank(schema, table) {
|
|
167
|
+
const out = Math.min(hops(schema, table.id).length, 3);
|
|
168
|
+
const incoming = Math.min(schema.foreignKeys.filter((fk) => fk.to.table === table.id).length, 6);
|
|
169
|
+
const label = labelColumn(table);
|
|
170
|
+
const named = label && !primaryKey(table).includes(label);
|
|
171
|
+
/* Text that is neither a key nor a foreign key: the columns somebody would
|
|
172
|
+
actually filter on. Without this the winner on the demo schema was
|
|
173
|
+
`invoice_line`, a junction table whose every column is a key — a table
|
|
174
|
+
with plenty of edges and nothing to say about any of them. */
|
|
175
|
+
const readable = table.columns.some((c) => isText(c) && !c.primaryKey && !c.references && !isDate(c));
|
|
176
|
+
return out * 3 + incoming
|
|
177
|
+
+ (named ? 4 : 0)
|
|
178
|
+
+ (readable ? 4 : 0)
|
|
179
|
+
+ (table.columns.some(isDate) ? 2 : 0)
|
|
180
|
+
- (table.isView ? 8 : 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The table an example should be about.
|
|
185
|
+
*
|
|
186
|
+
* @param {{ tables: Array<Record<string, unknown>> }} schema
|
|
187
|
+
* @param {(table: Record<string, unknown>) => number} [extra]
|
|
188
|
+
* An extra score, so a caller can prefer tables that suit its own example —
|
|
189
|
+
* typed because the default takes no arguments and every caller passes one.
|
|
190
|
+
*/
|
|
191
|
+
function bestTable(schema, extra = () => 0) {
|
|
192
|
+
let best = null;
|
|
193
|
+
let bestScore = -Infinity;
|
|
194
|
+
for (const table of schema.tables) {
|
|
195
|
+
const score = rank(schema, table) + extra(table);
|
|
196
|
+
// Ties break on the earlier table, so the same schema always produces the
|
|
197
|
+
// same examples — an example that moves between reloads reads as a bug.
|
|
198
|
+
if (score > bestScore) {
|
|
199
|
+
best = table;
|
|
200
|
+
bestScore = score;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return best;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The deepest readable path out of a table, up to two hops.
|
|
208
|
+
*
|
|
209
|
+
* Two rather than one wherever the graph allows, because one hop is easy to
|
|
210
|
+
* read as a join column and two is unmistakably a walk. The last segment has
|
|
211
|
+
* to be a label — `customer_id.party_id.display_name` is a sentence,
|
|
212
|
+
* `customer_id.party_id.id` is a number nobody asked for.
|
|
213
|
+
*/
|
|
214
|
+
function bestPath(schema, tableId) {
|
|
215
|
+
let best = null;
|
|
216
|
+
for (const first of hops(schema, tableId)) {
|
|
217
|
+
const target = tableById(schema, first.target);
|
|
218
|
+
if (!target) continue;
|
|
219
|
+
const label = labelColumn(target);
|
|
220
|
+
if (label) best = best ?? { path: `${first.name}.${label}`, hops: 1, table: target.id };
|
|
221
|
+
|
|
222
|
+
for (const second of hops(schema, target.id)) {
|
|
223
|
+
// A path that returns to where it started says nothing about the graph.
|
|
224
|
+
if (second.target === tableId || second.target === target.id) continue;
|
|
225
|
+
const further = tableById(schema, second.target);
|
|
226
|
+
const deep = further && labelColumn(further);
|
|
227
|
+
if (!deep) continue;
|
|
228
|
+
const column = further.columns.find((c) => c.name === deep);
|
|
229
|
+
if (column && !isText(column)) continue;
|
|
230
|
+
return { path: `${first.name}.${second.name}.${deep}`, hops: 2, table: further.id };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return best;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The table the dot-walk example should start from.
|
|
238
|
+
*
|
|
239
|
+
* The hub if it has a path, because keeping every example on one table means
|
|
240
|
+
* the reader learns one schema rather than five. But a two-hop path is worth
|
|
241
|
+
* changing table for: one hop still reads as a join column, and two is
|
|
242
|
+
* unmistakably a walk.
|
|
243
|
+
*/
|
|
244
|
+
function walkFrom(schema, hub) {
|
|
245
|
+
const pathAt = (table) => (table ? bestPath(schema, table.id) : null);
|
|
246
|
+
if (pathAt(hub)?.hops === 2) return hub;
|
|
247
|
+
|
|
248
|
+
const deep = bestTable(schema, (t) => (bestPath(schema, t.id)?.hops === 2 ? 40 : 0));
|
|
249
|
+
if (pathAt(deep)?.hops === 2) return deep;
|
|
250
|
+
if (pathAt(hub)) return hub;
|
|
251
|
+
|
|
252
|
+
const any = bestTable(schema, (t) => (bestPath(schema, t.id) ? 40 : 0));
|
|
253
|
+
return pathAt(any) ? any : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The column a filter example should use: the most categorical one there is. */
|
|
257
|
+
function filterColumn(table, rows) {
|
|
258
|
+
/* A numeric foreign key is excluded on purpose. `customer_id = 6` is a
|
|
259
|
+
legal filter and a terrible example: reaching a row by the number in its
|
|
260
|
+
key column is the thing dot-walking exists to replace. A *text* key —
|
|
261
|
+
`currency_code = AUD` — is a code someone can read, and stays. */
|
|
262
|
+
const candidates = table.columns.filter(
|
|
263
|
+
(c) => !c.primaryKey && !isDate(c) && (isText(c) || (isNumber(c) && !c.references)),
|
|
264
|
+
);
|
|
265
|
+
if (!candidates.length) return null;
|
|
266
|
+
|
|
267
|
+
if (rows?.length) {
|
|
268
|
+
/* Fewest distinct values wins: over five sampled rows `status` shows two
|
|
269
|
+
and `reference` shows five, which is the difference between a filter
|
|
270
|
+
that reads like a question and one that reads like a key lookup.
|
|
271
|
+
Text breaks the tie, because `status = open` says what it means and
|
|
272
|
+
`active = 1` needs the schema open beside it.
|
|
273
|
+
|
|
274
|
+
An unfilled column is charged for every row it has nothing in. A column
|
|
275
|
+
that is null four times out of five has one distinct value and would
|
|
276
|
+
otherwise win outright — which is how `external_ref = EXT-10913`, an
|
|
277
|
+
external system's identifier for one row, came to be the first thing
|
|
278
|
+
the landing page taught. */
|
|
279
|
+
const cost = (c) => c.values.length + (rows.length - c.filled) + (isText(c.column) ? 0 : 2);
|
|
280
|
+
const scored = candidates
|
|
281
|
+
.map((column) => ({
|
|
282
|
+
column,
|
|
283
|
+
values: valuesOf(rows, column.name),
|
|
284
|
+
filled: rows.filter((row) => usable(row?.[column.name])).length,
|
|
285
|
+
}))
|
|
286
|
+
.filter((c) => c.values.length)
|
|
287
|
+
.sort((a, b) => cost(a) - cost(b) || a.values[0].length - b.values[0].length);
|
|
288
|
+
if (scored.length) return scored[0];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const named = candidates.find((c) => /status|state|type|kind|category|code|active|priority/i.test(c.name));
|
|
292
|
+
return { column: named ?? candidates[0], values: [] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A column that is null somewhere in the sample, so `is empty` finds rows. */
|
|
296
|
+
function nullableColumn(table, rows) {
|
|
297
|
+
const optional = table.columns.filter((c) => !c.primaryKey && c.nullable !== false);
|
|
298
|
+
if (!optional.length) return null;
|
|
299
|
+
const empty = optional.find((c) => (rows ?? []).some((row) => row?.[c.name] === null));
|
|
300
|
+
return empty ?? optional[0];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A column that is never null, for the second half of an `and`.
|
|
305
|
+
*
|
|
306
|
+
* The point of that example is the word `and`, so the condition beside it has
|
|
307
|
+
* to be one that cannot change the answer: `is not empty` on a NOT NULL column
|
|
308
|
+
* is true of every row, which leaves the example returning exactly what its
|
|
309
|
+
* first condition returned. Pairing two selective conditions instead gave a
|
|
310
|
+
* demonstration of `and` that came back with nothing in it.
|
|
311
|
+
*/
|
|
312
|
+
function requiredColumn(table, rows) {
|
|
313
|
+
const declared = table.columns.filter((c) => c.nullable === false && !c.primaryKey);
|
|
314
|
+
if (declared.length) return declared[0];
|
|
315
|
+
return table.columns.find((c) => !c.primaryKey && (rows ?? []).length
|
|
316
|
+
&& (rows ?? []).every((row) => row?.[c.name] !== null && row?.[c.name] !== undefined)) ?? null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const dateColumn = (table) => table.columns.find(isDate);
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A date window wide enough to reach the newest row the sample saw.
|
|
323
|
+
*
|
|
324
|
+
* `last 30 days` on a database whose most recent row is a year old is a
|
|
325
|
+
* correct query that returns nothing, and nothing is what a beginner reads as
|
|
326
|
+
* "this feature does not work". The window is widened to the first phrase that
|
|
327
|
+
* covers the data — still a phrase somebody would type, not a computed date.
|
|
328
|
+
*/
|
|
329
|
+
const WINDOWS = [
|
|
330
|
+
{ label: 'last 30 days', days: 30 },
|
|
331
|
+
{ label: 'last 90 days', days: 90 },
|
|
332
|
+
{ label: 'last 6 months', days: 183 },
|
|
333
|
+
{ label: 'last 12 months', days: 366 },
|
|
334
|
+
{ label: 'last 24 months', days: 731 },
|
|
335
|
+
{ label: 'last 60 months', days: 1827 },
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
function dateWindowFor(values, now) {
|
|
339
|
+
const newest = (values ?? [])
|
|
340
|
+
.map((value) => new Date(String(value)).getTime())
|
|
341
|
+
.filter((t) => Number.isFinite(t))
|
|
342
|
+
.sort((a, b) => b - a)[0];
|
|
343
|
+
if (newest === undefined) return WINDOWS[0].label;
|
|
344
|
+
const age = (now.getTime() - newest) / 86_400_000;
|
|
345
|
+
return (WINDOWS.find((w) => w.days >= age) ?? WINDOWS[WINDOWS.length - 1]).label;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* ---------- the generator ---------- */
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Every example the panel and the empty state can offer, from a schema and
|
|
352
|
+
* (optionally) a few sampled rows.
|
|
353
|
+
*
|
|
354
|
+
* A pure function of its arguments, which is what makes it testable without a
|
|
355
|
+
* browser: the fixtures in test/help.test.ts include a schema with no foreign
|
|
356
|
+
* keys, which is the shape that would otherwise emit a dot-walk example that
|
|
357
|
+
* errors the moment someone clicked it.
|
|
358
|
+
*
|
|
359
|
+
* Keys are absent rather than empty when the schema cannot support them. A
|
|
360
|
+
* caller that renders whatever it finds degrades on its own.
|
|
361
|
+
*
|
|
362
|
+
* @typedef {{query: string, caption: string}} Example
|
|
363
|
+
* @typedef {{
|
|
364
|
+
* sampleTable: string | null,
|
|
365
|
+
* examples: Record<string, Example>,
|
|
366
|
+
* starters: Array<Example & {key: string}>,
|
|
367
|
+
* }} Examples
|
|
368
|
+
*
|
|
369
|
+
* @param {any} schema
|
|
370
|
+
* @param {Record<string, Array<Record<string, unknown>>>} [samples]
|
|
371
|
+
* @param {Date} [now] injected, so a date window is testable to the day
|
|
372
|
+
* @returns {Examples}
|
|
373
|
+
*/
|
|
374
|
+
export function buildExamples(schema, samples = {}, now = new Date()) {
|
|
375
|
+
/** @type {Examples} */
|
|
376
|
+
const out = { sampleTable: null, examples: {}, starters: [] };
|
|
377
|
+
if (!schema?.tables?.length) return out;
|
|
378
|
+
|
|
379
|
+
const hub = bestTable(schema);
|
|
380
|
+
if (!hub) return out;
|
|
381
|
+
out.sampleTable = hub.id;
|
|
382
|
+
|
|
383
|
+
const rows = samples[hub.id] ?? [];
|
|
384
|
+
const examples = out.examples;
|
|
385
|
+
const add = (key, query, caption) => {
|
|
386
|
+
examples[key] = { query, caption };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
add('table', hub.id, `every row in ${hub.name}`);
|
|
390
|
+
|
|
391
|
+
/* ---- filters ---- */
|
|
392
|
+
|
|
393
|
+
const chosen = filterColumn(hub, rows);
|
|
394
|
+
const column = chosen?.column;
|
|
395
|
+
const values = chosen?.values ?? [];
|
|
396
|
+
|
|
397
|
+
if (column && values.length) {
|
|
398
|
+
add('filter', `${hub.id} ${column.name} = ${literal(column, values[0])}`, 'a column, an operator, a value');
|
|
399
|
+
if (values.length > 1) {
|
|
400
|
+
add('or', `${hub.id} ${column.name} = ${literal(column, values[0])} or ${column.name} = ${literal(column, values[1])}`, 'either one');
|
|
401
|
+
const list = values.filter(listSafe).slice(0, 2);
|
|
402
|
+
if (list.length > 1) {
|
|
403
|
+
add('in', `${hub.id} ${column.name} in (${list.map((v) => literal(column, v)).join(', ')})`, 'the same thing, shorter');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} else if (column) {
|
|
407
|
+
add('filter', `${hub.id} ${column.name} is not empty`, 'rows where the column is set');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const label = labelColumn(hub);
|
|
411
|
+
const labelValues = label ? valuesOf(rows, label) : [];
|
|
412
|
+
if (label && labelValues.length) {
|
|
413
|
+
/* Both of these are cut out of a value that is in the table, so both come
|
|
414
|
+
back with rows. A guessed word would not, and a filter that silently
|
|
415
|
+
matches nothing is the exact failure this language was built to avoid.
|
|
416
|
+
|
|
417
|
+
`contains` prefers what the sampled values have in common — `TKT-90`
|
|
418
|
+
out of TKT-9014 and TKT-9060 — because the shared part is the part
|
|
419
|
+
worth searching for. Where they share nothing, the first word of the
|
|
420
|
+
first value is a real word out of the column: `contains Harbour`. */
|
|
421
|
+
const shared = labelValues.length > 1 ? commonPrefix(labelValues).trim() : '';
|
|
422
|
+
const word = shared.length >= 3 ? shared : labelValues[0].split(/\s+/)[0];
|
|
423
|
+
const front = labelValues[0].slice(0, 3).trim();
|
|
424
|
+
if (word && !RESERVED.test(word)) add('contains', `${hub.id} ${label} contains ${quote(word)}`, 'anywhere in the text');
|
|
425
|
+
if (front.length >= 2 && front !== word && !RESERVED.test(front)) {
|
|
426
|
+
add('startswith', `${hub.id} ${label} startswith ${quote(front)}`, 'at the front of it — endswith too');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* A quantity, not a key: `total >= 10000` is a question somebody might ask
|
|
431
|
+
and `customer_id >= 1` is arithmetic on an identifier. */
|
|
432
|
+
const numbers = hub.columns.filter((c) => isNumber(c) && !c.primaryKey && !c.references);
|
|
433
|
+
const number = numbers.find((c) => /total|amount|price|cost|qty|quantity|balance|limit|value/i.test(c.name))
|
|
434
|
+
?? numbers[0];
|
|
435
|
+
/* Rounded down from the smallest value in the sample, so the comparison
|
|
436
|
+
cannot come back empty on the table it was built from. */
|
|
437
|
+
const floor = number ? roundDown(Math.min(...valuesOf(rows, number.name).map(Number).filter(Number.isFinite))) : null;
|
|
438
|
+
if (number && floor !== null) add('compare', `${hub.id} ${number.name} >= ${floor}`, 'the ordering operators, on numbers and on dates');
|
|
439
|
+
|
|
440
|
+
const optional = nullableColumn(hub, rows);
|
|
441
|
+
if (optional) add('empty', `${hub.id} ${optional.name} is empty`, 'nothing recorded — is not empty for the opposite');
|
|
442
|
+
|
|
443
|
+
const required = requiredColumn(hub, rows);
|
|
444
|
+
if (column && values.length && required && required.name !== column.name) {
|
|
445
|
+
const first = `${column.name} = ${literal(column, values[0])}`;
|
|
446
|
+
add('and', `${hub.id} ${first} and ${required.name} is not empty`, 'both conditions');
|
|
447
|
+
if (values.length > 1) {
|
|
448
|
+
add(
|
|
449
|
+
'brackets',
|
|
450
|
+
`${hub.id} (${column.name} = ${literal(column, values[0])} or ${column.name} = ${literal(column, values[1])}) and ${required.name} is not empty`,
|
|
451
|
+
'or binds loosest, so brackets change the grouping',
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/* ---- references ---- */
|
|
457
|
+
|
|
458
|
+
const walk = walkFrom(schema, hub);
|
|
459
|
+
const path = walk && bestPath(schema, walk.id);
|
|
460
|
+
if (walk && path) {
|
|
461
|
+
const shown = labelColumn(walk) ?? primaryKey(walk)[0] ?? walk.columns[0]?.name;
|
|
462
|
+
const hopped = path.hops === 2 ? 'two tables along' : 'on the table it points at';
|
|
463
|
+
add('dotwalk', `${walk.id} show ${shown}, ${path.path}`, `a column ${hopped}`);
|
|
464
|
+
add('dotfilter', `${walk.id} ${path.path} is not empty`, 'filtering across the reference');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* ---- projection, order, limit, dates ---- */
|
|
468
|
+
|
|
469
|
+
/* The columns a person would read a row by, not the first three declared:
|
|
470
|
+
a projection example whose point is `show id, customer_id, quote_id` has
|
|
471
|
+
demonstrated the keyword and taught nothing about the table. */
|
|
472
|
+
const readable = hub.columns.filter((c) => !c.references && (c.name === label || !c.primaryKey));
|
|
473
|
+
const shortlist = [...new Set([label, ...readable.map((c) => c.name)].filter(Boolean))].slice(0, 3);
|
|
474
|
+
if (shortlist.length > 1) add('show', `${hub.id} show ${shortlist.join(', ')}`, 'only these columns, in this order');
|
|
475
|
+
|
|
476
|
+
const dated = dateColumn(hub) ? hub : schema.tables.find((t) => !t.isView && dateColumn(t));
|
|
477
|
+
const date = dated && dateColumn(dated);
|
|
478
|
+
if (date) {
|
|
479
|
+
add('sort', `${dated.id} sort ${date.name} desc limit 10`, 'the ten most recent');
|
|
480
|
+
// Only the sampled table has values to measure; anywhere else the window
|
|
481
|
+
// is the one people reach for first.
|
|
482
|
+
const phrase = dated.id === hub.id
|
|
483
|
+
? dateWindowFor(rows.map((row) => row?.[date.name]), now)
|
|
484
|
+
: WINDOWS[0].label;
|
|
485
|
+
add('dates', `${dated.id} ${date.name} = ${phrase}`, 'a window, resolved to two real timestamps');
|
|
486
|
+
} else if (label) {
|
|
487
|
+
add('sort', `${hub.id} sort ${label} limit 10`, 'ten rows, in order');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
add('limit', `${hub.id} limit 5`, 'stop after five');
|
|
491
|
+
|
|
492
|
+
/* ---- the three for the empty state ----
|
|
493
|
+
|
|
494
|
+
A filter, a walk and an order: the three things the language does that a
|
|
495
|
+
list of tables cannot, in the order someone meets them. Anything the
|
|
496
|
+
schema cannot support is skipped rather than faked, and the bare table
|
|
497
|
+
name backfills — on a database with no foreign keys the landing page
|
|
498
|
+
offers three queries that work rather than two that do and one that
|
|
499
|
+
would have errored on the first click. */
|
|
500
|
+
for (const key of ['filter', 'dotwalk', 'sort', 'table']) {
|
|
501
|
+
if (examples[key] && out.starters.length < 3) out.starters.push({ key, ...examples[key] });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function commonPrefix(values) {
|
|
508
|
+
let prefix = values[0];
|
|
509
|
+
for (const value of values.slice(1)) {
|
|
510
|
+
let i = 0;
|
|
511
|
+
while (i < prefix.length && prefix[i] === value[i]) i++;
|
|
512
|
+
prefix = prefix.slice(0, i);
|
|
513
|
+
}
|
|
514
|
+
return prefix;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/* ---------- sampled rows ---------- */
|
|
518
|
+
|
|
519
|
+
/* Keyed by connection as well as table: the same table name means a different
|
|
520
|
+
table on the next database, and examples built from the last one would be
|
|
521
|
+
quietly wrong. Promises rather than rows, so the panel and the empty state
|
|
522
|
+
opening together make one request. */
|
|
523
|
+
const sampled = new Map();
|
|
524
|
+
|
|
525
|
+
function sampleKey(tableId) {
|
|
526
|
+
return `${state.activeConnection ?? state.schema?.label ?? ''} ${tableId}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function sampleRows(tableId) {
|
|
530
|
+
const key = sampleKey(tableId);
|
|
531
|
+
if (!sampled.has(key)) {
|
|
532
|
+
sampled.set(
|
|
533
|
+
key,
|
|
534
|
+
api('/api/run', { q: tableId, limit: SAMPLE_ROWS })
|
|
535
|
+
.then((data) => data.rows ?? [])
|
|
536
|
+
/* An empty table, a permission error, a connection that dropped: the
|
|
537
|
+
examples fall back to the value-free forms rather than the panel
|
|
538
|
+
failing to open. */
|
|
539
|
+
.catch(() => []),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return sampled.get(key);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Draw with what is known now, then again when the sample arrives.
|
|
547
|
+
*
|
|
548
|
+
* The first pass is not a placeholder — it is a complete set of examples that
|
|
549
|
+
* all run, built from the schema alone. The second pass only makes them
|
|
550
|
+
* better, so nothing flashes into existence and nothing is missing if the
|
|
551
|
+
* request never comes back.
|
|
552
|
+
*/
|
|
553
|
+
function withSamples(host, draw) {
|
|
554
|
+
const schema = state.schema;
|
|
555
|
+
const examples = buildExamples(schema, {});
|
|
556
|
+
draw(examples);
|
|
557
|
+
if (!examples.sampleTable) return;
|
|
558
|
+
void sampleRows(examples.sampleTable).then((rows) => {
|
|
559
|
+
/* The connection can change while the request is in flight. Drawing rows
|
|
560
|
+
from the database you have just left would put one schema's values
|
|
561
|
+
beside another schema's table names, and every example built that way
|
|
562
|
+
would be a link that errors. */
|
|
563
|
+
if (!rows.length || !host.isConnected || state.schema !== schema) return;
|
|
564
|
+
draw(buildExamples(schema, { [examples.sampleTable]: rows }));
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* ---------- running an example ---------- */
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Put the example in the bar and run it.
|
|
572
|
+
*
|
|
573
|
+
* Through the form rather than by navigating directly, for the reason
|
|
574
|
+
* history.js gives for the same move: the one submit handler in app.js decides
|
|
575
|
+
* what a query means, and a second caller deciding it separately is how the
|
|
576
|
+
* two start to disagree. Leaving the text in the bar is the point — the next
|
|
577
|
+
* thing to do with an example is edit it.
|
|
578
|
+
*/
|
|
579
|
+
function runExample(query) {
|
|
580
|
+
const input = $('query');
|
|
581
|
+
if (!input) return;
|
|
582
|
+
input.value = query;
|
|
583
|
+
$('querybar').requestSubmit();
|
|
584
|
+
input.focus();
|
|
585
|
+
input.setSelectionRange(query.length, query.length);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function exampleButton(example, onRun) {
|
|
589
|
+
return el('button', {
|
|
590
|
+
type: 'button',
|
|
591
|
+
class: 'help-example',
|
|
592
|
+
title: 'Run this query',
|
|
593
|
+
onclick: () => {
|
|
594
|
+
onRun?.();
|
|
595
|
+
runExample(example.query);
|
|
596
|
+
},
|
|
597
|
+
}, [
|
|
598
|
+
el('code', { class: 'help-query', text: example.query }),
|
|
599
|
+
el('span', { class: 'help-caption', text: example.caption }),
|
|
600
|
+
]);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ---------- the empty state ---------- */
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Three runnable queries on the landing page.
|
|
607
|
+
*
|
|
608
|
+
* The empty state used to be a sentence and a diagram, which tells you the
|
|
609
|
+
* tool is about relationships and leaves you to guess the grammar. A diagram
|
|
610
|
+
* answers "what is in here"; these answer "what do I type".
|
|
611
|
+
*/
|
|
612
|
+
/* ---------- the panel ---------- */
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* What each section says, and which generated examples belong under it.
|
|
616
|
+
*
|
|
617
|
+
* The grammar lines are fixed text and the examples are not: syntax is the
|
|
618
|
+
* same on every database, and what is worth filtering is not. Anything named
|
|
619
|
+
* here that the schema could not produce simply does not appear, which is why
|
|
620
|
+
* the keys are a list rather than a template with holes in it.
|
|
621
|
+
*/
|
|
622
|
+
const SECTIONS = [
|
|
623
|
+
{
|
|
624
|
+
title: 'Start with a table',
|
|
625
|
+
note: 'The table comes first, because that is how people say it out loud. Everything after it is optional.',
|
|
626
|
+
grammar: [['table', 'every row, in a sensible order']],
|
|
627
|
+
keys: ['table'],
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
title: 'Filter',
|
|
631
|
+
note: 'A condition straight after the table name is already a filter. Put "where" or "filter" in front of it if that reads better.',
|
|
632
|
+
grammar: [
|
|
633
|
+
['column = value', 'also != < <= > >='],
|
|
634
|
+
['column contains text', 'anywhere in it, whatever the case'],
|
|
635
|
+
['column startswith text', 'endswith too'],
|
|
636
|
+
['column like Wh%', 'your own wildcards: % any run, _ one character'],
|
|
637
|
+
['column in (a, b)', 'any of them — not in for none of them'],
|
|
638
|
+
['column between a and b', 'a range, both ends included'],
|
|
639
|
+
['column is empty', 'NULL — is not empty for the opposite'],
|
|
640
|
+
['column = "two words"', 'quote a value with spaces in it'],
|
|
641
|
+
],
|
|
642
|
+
keys: ['filter', 'contains', 'startswith', 'like', 'compare', 'empty', 'in', 'between'],
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
title: 'Combine',
|
|
646
|
+
note: 'and binds tighter than or, so brackets are how you say the other thing.',
|
|
647
|
+
grammar: [
|
|
648
|
+
['a and b', 'both'],
|
|
649
|
+
['a or b', 'either'],
|
|
650
|
+
['(a or b) and c', 'grouped'],
|
|
651
|
+
],
|
|
652
|
+
keys: ['and', 'or', 'brackets'],
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
title: 'Walk a reference',
|
|
656
|
+
note: 'A dot follows a foreign key into the table it points at, and keeps going. Up to four hops.',
|
|
657
|
+
grammar: [
|
|
658
|
+
['reference.column', 'a column on the table this one points at'],
|
|
659
|
+
['reference.reference.column', 'two tables along'],
|
|
660
|
+
],
|
|
661
|
+
keys: ['dotwalk', 'dotfilter'],
|
|
662
|
+
absent: 'This database has no foreign keys, so there is nothing to walk.',
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
title: 'Show only some columns',
|
|
666
|
+
note: 'show picks the columns and their order. select means the same thing.',
|
|
667
|
+
grammar: [['show a, b, c', 'these columns, in this order']],
|
|
668
|
+
keys: ['show'],
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
title: 'Sort and limit',
|
|
672
|
+
note: 'order by is accepted for sort. Without one, rows come back newest-first where the table has an indexed column for it.',
|
|
673
|
+
grammar: [
|
|
674
|
+
['sort column', 'ascending'],
|
|
675
|
+
['sort column desc', 'descending'],
|
|
676
|
+
['sort a, b desc', 'more than one'],
|
|
677
|
+
['limit 20', 'at most this many'],
|
|
678
|
+
],
|
|
679
|
+
keys: ['sort', 'limit'],
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
title: 'Dates',
|
|
683
|
+
note: 'A date phrase is a window, not a point: "= last 30 days" compiles to two real timestamps, which the SQL button will show you.',
|
|
684
|
+
grammar: [
|
|
685
|
+
['today', 'yesterday, tomorrow'],
|
|
686
|
+
['this week', 'last week — weeks start Monday'],
|
|
687
|
+
['this month', 'last month, this year, last year'],
|
|
688
|
+
['last 30 days', 'last 12 hours, last 6 weeks, last 3 months'],
|
|
689
|
+
['next 7 days', 'the same units, forwards'],
|
|
690
|
+
],
|
|
691
|
+
keys: ['dates'],
|
|
692
|
+
absent: 'No table here has a date column.',
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
/* Not generated from the schema, so no `keys` and nothing to be absent:
|
|
696
|
+
these work on every result in every database. They live here because
|
|
697
|
+
the alternative was a hint line under every table, and a permanent
|
|
698
|
+
instruction is a poor trade for the space it takes from the data. */
|
|
699
|
+
title: 'Moving around a result',
|
|
700
|
+
note: 'Tab into the table, then the arrow keys move a cell cursor. Hold ⌘ to jump to the end of a row or column.',
|
|
701
|
+
grammar: [
|
|
702
|
+
['↑ ↓ ← →', 'move one cell'],
|
|
703
|
+
['Enter', 'open the record for that row'],
|
|
704
|
+
['⌘C', 'copy the cell under the cursor'],
|
|
705
|
+
['Home / End', 'first and last column'],
|
|
706
|
+
['Esc', 'back to the query bar'],
|
|
707
|
+
],
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
/* Every shortcut the page itself binds, in the one place someone looks
|
|
711
|
+
for one. They were discoverable only by reading the source or by
|
|
712
|
+
pressing a key and seeing what happened — and `h` for home is the sort
|
|
713
|
+
of thing nobody guesses. A hint line under the header would cost the
|
|
714
|
+
space permanently; a section in the panel that answers "what can I
|
|
715
|
+
press" costs nothing until asked. */
|
|
716
|
+
title: 'Keys the page listens for',
|
|
717
|
+
note: 'Single keys work when the caret is not in a text field, and never through a dialog — the page under a modal is not taking keys.',
|
|
718
|
+
grammar: [
|
|
719
|
+
['h', 'home: the schema, your work, the diagram'],
|
|
720
|
+
['/', 'the query bar'],
|
|
721
|
+
['⌘/', 'the query bar as a panel, with room to write in'],
|
|
722
|
+
['t', 'the table filter in the sidebar'],
|
|
723
|
+
['⌘K', 'the command palette — tables, pages, actions, recent queries'],
|
|
724
|
+
['⌘B', 'show or hide the table list'],
|
|
725
|
+
['Backspace', 'back one step along the trail (⌘[ too)'],
|
|
726
|
+
['?', 'this panel'],
|
|
727
|
+
['Esc', 'leave the field, or close what is open'],
|
|
728
|
+
],
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
/* The address bar has a grammar of its own, and nothing on screen taught
|
|
732
|
+
it: `~30`, `!diagram` and `@page~root` were sigils you could only learn
|
|
733
|
+
by watching the bar change as you clicked. They are worth writing by
|
|
734
|
+
hand — a hash is a place you can send someone — so they are worth
|
|
735
|
+
documenting where the rest of the language lives. */
|
|
736
|
+
title: 'Links you can write',
|
|
737
|
+
note: 'The first segment is the connection, then the trail — each step a query, a row, or a page — so a link carries the walk, not just where it ended. Links written as #/… still resolve.',
|
|
738
|
+
grammar: [
|
|
739
|
+
['/erp/customer', 'a table — any query works, URL-encoded'],
|
|
740
|
+
['/erp/customer/~42', 'a row of the table before it, by key'],
|
|
741
|
+
['/erp/customer/~42/~invoice:9', "another table's row names its table"],
|
|
742
|
+
['/erp/customer!diagram', 'a tab: !rows, !ddl or !diagram'],
|
|
743
|
+
['/erp/@customer~42', 'a page by id, rooted at a record'],
|
|
744
|
+
],
|
|
745
|
+
},
|
|
746
|
+
];
|
|
747
|
+
|
|
748
|
+
let panel = null;
|
|
749
|
+
let restoreFocus = null;
|
|
750
|
+
/** Undoes the Tab trap. The focus restore below is this module's own. */
|
|
751
|
+
let release = null;
|
|
752
|
+
|
|
753
|
+
function closeHelp() {
|
|
754
|
+
if (!panel) return;
|
|
755
|
+
release?.();
|
|
756
|
+
release = null;
|
|
757
|
+
panel.remove();
|
|
758
|
+
panel = null;
|
|
759
|
+
document.removeEventListener('keydown', onPanelKey, true);
|
|
760
|
+
restoreFocus?.focus?.();
|
|
761
|
+
restoreFocus = null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function onPanelKey(e) {
|
|
765
|
+
if (e.key === 'Escape' || e.key === '?') {
|
|
766
|
+
e.preventDefault();
|
|
767
|
+
e.stopPropagation();
|
|
768
|
+
closeHelp();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
/* The panel swallows the single-key shortcuts underneath it. `h` goes home
|
|
772
|
+
and `t` jumps to the table filter, and a page that navigates behind an
|
|
773
|
+
open dialog reads as the dialog having done it. Modified keys are left
|
|
774
|
+
alone on purpose: ⌘K and ⌘B still work from here. */
|
|
775
|
+
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) e.stopPropagation();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function section(spec, examples) {
|
|
779
|
+
/* A section with no `keys` is static reference — keyboard moves, link
|
|
780
|
+
grammar — and must not crash the panel reaching for examples it never
|
|
781
|
+
claimed to have. */
|
|
782
|
+
const found = (spec.keys ?? []).map((key) => examples[key]).filter(Boolean);
|
|
783
|
+
/* A section with nothing to demonstrate says why. Dropping it silently
|
|
784
|
+
would leave a reference that quietly disagrees with itself — the dot-walk
|
|
785
|
+
grammar is real even on a schema with no foreign keys to use it on. */
|
|
786
|
+
const body = found.length
|
|
787
|
+
? el('div', { class: 'help-examples' }, found.map((example) => exampleButton(example, closeHelp)))
|
|
788
|
+
: spec.absent
|
|
789
|
+
? el('p', { class: 'help-absent', text: spec.absent })
|
|
790
|
+
: null;
|
|
791
|
+
|
|
792
|
+
return el('section', { class: 'help-section' }, [
|
|
793
|
+
el('h3', { class: 'help-section-title', text: spec.title }),
|
|
794
|
+
el('p', { class: 'help-note', text: spec.note }),
|
|
795
|
+
el('dl', { class: 'help-grammar' }, spec.grammar.map(([syntax, meaning]) => el('div', {}, [
|
|
796
|
+
el('dt', {}, el('code', { text: syntax })),
|
|
797
|
+
el('dd', { text: meaning }),
|
|
798
|
+
]))),
|
|
799
|
+
body,
|
|
800
|
+
].filter(Boolean));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function openHelp() {
|
|
804
|
+
if (panel) {
|
|
805
|
+
closeHelp();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
restoreFocus = document.activeElement;
|
|
809
|
+
|
|
810
|
+
const body = el('div', { class: 'help-body' });
|
|
811
|
+
const close = el('button', {
|
|
812
|
+
type: 'button',
|
|
813
|
+
class: 'help-close',
|
|
814
|
+
'aria-label': 'Close',
|
|
815
|
+
title: 'Close (Esc)',
|
|
816
|
+
onclick: closeHelp,
|
|
817
|
+
}, el('span', { 'aria-hidden': 'true', text: '×' }));
|
|
818
|
+
|
|
819
|
+
const box = el('div', {
|
|
820
|
+
class: 'help-panel',
|
|
821
|
+
role: 'dialog',
|
|
822
|
+
'aria-modal': 'true',
|
|
823
|
+
'aria-label': 'Query language',
|
|
824
|
+
}, [
|
|
825
|
+
el('header', { class: 'help-head' }, [
|
|
826
|
+
el('div', {}, [
|
|
827
|
+
el('h2', { class: 'help-title', text: 'Query language' }),
|
|
828
|
+
el('p', {
|
|
829
|
+
class: 'help-sub',
|
|
830
|
+
text: `Every example below runs against ${state.schema?.label ?? 'this database'}. Click one.`,
|
|
831
|
+
}),
|
|
832
|
+
]),
|
|
833
|
+
close,
|
|
834
|
+
]),
|
|
835
|
+
body,
|
|
836
|
+
]);
|
|
837
|
+
|
|
838
|
+
panel = el('div', {
|
|
839
|
+
class: 'help-backdrop',
|
|
840
|
+
onpointerdown: (e) => {
|
|
841
|
+
if (e.target === panel) closeHelp();
|
|
842
|
+
},
|
|
843
|
+
}, box);
|
|
844
|
+
|
|
845
|
+
withSamples(body, (built) => {
|
|
846
|
+
body.replaceChildren(...SECTIONS.map((spec) => section(spec, built.examples)));
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
document.addEventListener('keydown', onPanelKey, true);
|
|
850
|
+
document.body.append(panel);
|
|
851
|
+
release = trapFocus(panel);
|
|
852
|
+
close.focus();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Register the panel, and bind `?` to it.
|
|
857
|
+
*
|
|
858
|
+
* One registration covers the overflow menu and ⌘K both, which is the whole
|
|
859
|
+
* point of the registry in menu.js.
|
|
860
|
+
*
|
|
861
|
+
* `?` is a printable character, so unlike ⌘K it has to stand aside for text:
|
|
862
|
+
* typing a question mark into the query bar must produce a question mark and
|
|
863
|
+
* not a dialog over the field you are typing in. The same test app.js uses for
|
|
864
|
+
* `/` and `t`, plus contenteditable, which no field here uses today and one
|
|
865
|
+
* might tomorrow.
|
|
866
|
+
*/
|
|
867
|
+
export function initHelp() {
|
|
868
|
+
addMenuItem({
|
|
869
|
+
label: 'Query language…',
|
|
870
|
+
detail: 'the grammar, with examples (?)',
|
|
871
|
+
onSelect: openHelp,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
document.addEventListener('keydown', (e) => {
|
|
875
|
+
if (e.key !== '?' || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
876
|
+
const target = document.activeElement;
|
|
877
|
+
if (/input|textarea|select/i.test(target?.tagName ?? '') || target?.isContentEditable) return;
|
|
878
|
+
e.preventDefault();
|
|
879
|
+
openHelp();
|
|
880
|
+
});
|
|
881
|
+
}
|