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,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a query, as things you can take off.
|
|
3
|
+
*
|
|
4
|
+
* The query bar is the source of truth, which is a good design and leaves one
|
|
5
|
+
* gap: undoing part of what you built means editing the text by hand. Having
|
|
6
|
+
* clicked a column header to sort and a filter menu to narrow, going back to
|
|
7
|
+
* "the same thing but unsorted" meant finding `sort contract_id` in a line of
|
|
8
|
+
* text and deleting exactly it — the interface could write the language but
|
|
9
|
+
* not un-write it.
|
|
10
|
+
*
|
|
11
|
+
* So the clauses are shown as chips and removing one rewrites the text. The
|
|
12
|
+
* text stays authoritative; this is a second way to edit it, not a second
|
|
13
|
+
* place to keep it.
|
|
14
|
+
*
|
|
15
|
+
* The split is by keyword, which the grammar makes safe: a query is a table,
|
|
16
|
+
* then conditions, then any of `show`, `sort`/`order by` and `limit` in that
|
|
17
|
+
* order. Only the trailing keywords are looked for, so a *value* containing
|
|
18
|
+
* the word "limit" cannot be mistaken for the clause — the boundary is found
|
|
19
|
+
* from the right, and only outside quotes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Trailing clause keywords, longest first so `order by` wins over `order`. */
|
|
23
|
+
const TAIL = [
|
|
24
|
+
{ key: 'limit', pattern: /\blimit\b/i },
|
|
25
|
+
{ key: 'sort', pattern: /\b(?:sort|order\s+by)\b/i },
|
|
26
|
+
{ key: 'show', pattern: /\bshow\b/i },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Positions of characters that are inside a quoted value.
|
|
31
|
+
*
|
|
32
|
+
* `status = "sort code"` contains the word `sort` and does not contain a sort
|
|
33
|
+
* clause. Scanning for quotes first is what keeps the two apart.
|
|
34
|
+
*/
|
|
35
|
+
function quotedSpans(text) {
|
|
36
|
+
const spans = [];
|
|
37
|
+
let quote = null;
|
|
38
|
+
let start = 0;
|
|
39
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
+
const c = text[i];
|
|
41
|
+
if (quote) {
|
|
42
|
+
if (c === '\\') { i += 1; continue; }
|
|
43
|
+
if (c === quote) { spans.push([start, i]); quote = null; }
|
|
44
|
+
} else if (c === '"' || c === "'") {
|
|
45
|
+
quote = c;
|
|
46
|
+
start = i;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (quote) spans.push([start, text.length]);
|
|
50
|
+
return spans;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const inside = (spans, at) => spans.some(([a, b]) => at > a && at < b);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A copy of the query with the contents of quoted values replaced by spaces.
|
|
57
|
+
*
|
|
58
|
+
* Mirrors `maskQuotedValues` in src/shared/query.ts — same trick, same reason.
|
|
59
|
+
* Same length as the input and the quote characters left in place, so an index
|
|
60
|
+
* into this string is an index into the original.
|
|
61
|
+
*
|
|
62
|
+
* Anything that scans a query for keywords needs this. Scanning raw text turns
|
|
63
|
+
* a value into syntax: `name contains "show me"` was cut at the value's own
|
|
64
|
+
* word and the filter came back holding `"sort name show id, name` — a string
|
|
65
|
+
* assembled out of the query's own punctuation.
|
|
66
|
+
*/
|
|
67
|
+
export function maskQuoted(text) {
|
|
68
|
+
let out = '';
|
|
69
|
+
let quote = null;
|
|
70
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
71
|
+
const ch = text[i];
|
|
72
|
+
if (quote) {
|
|
73
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
74
|
+
out += ' ';
|
|
75
|
+
i += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
out += ch === quote ? ((quote = null), ch) : ' ';
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === '"' || ch === "'") {
|
|
82
|
+
quote = ch;
|
|
83
|
+
out += ch;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out += ch;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Break a query into its table, filter and trailing clauses.
|
|
93
|
+
*
|
|
94
|
+
* @returns {{table: string, filter: string, show?: string, sort?: string, limit?: string}}
|
|
95
|
+
*/
|
|
96
|
+
export function splitQuery(text) {
|
|
97
|
+
const query = String(text ?? '').trim();
|
|
98
|
+
const spans = quotedSpans(query);
|
|
99
|
+
|
|
100
|
+
const firstSpace = query.search(/\s/);
|
|
101
|
+
const table = firstSpace < 0 ? query : query.slice(0, firstSpace);
|
|
102
|
+
let rest = firstSpace < 0 ? '' : query.slice(firstSpace + 1);
|
|
103
|
+
let offset = firstSpace + 1;
|
|
104
|
+
|
|
105
|
+
const parts = { table, filter: '' };
|
|
106
|
+
|
|
107
|
+
/* Boundaries are found first, then sorted by *position* — not peeled off in
|
|
108
|
+
grammar order.
|
|
109
|
+
|
|
110
|
+
Peeling assumed the clauses arrive in the order the grammar lists them. The
|
|
111
|
+
parser does not require that, and `customer limit 5 sort name` is a query
|
|
112
|
+
someone will type. Peeling `limit` first took everything from `limit` to
|
|
113
|
+
the end, so the sort clause was swallowed into it: the chip read "5 sort
|
|
114
|
+
name" and removing the limit removed the sort with it.
|
|
115
|
+
|
|
116
|
+
The last occurrence of each keyword wins, because a clause word can appear
|
|
117
|
+
earlier as an unquoted value; quoted ones are excluded outright. */
|
|
118
|
+
const marks = [];
|
|
119
|
+
for (const { key, pattern } of TAIL) {
|
|
120
|
+
let at = -1;
|
|
121
|
+
let from = 0;
|
|
122
|
+
for (;;) {
|
|
123
|
+
const found = rest.slice(from).search(pattern);
|
|
124
|
+
if (found < 0) break;
|
|
125
|
+
const absolute = from + found;
|
|
126
|
+
if (!inside(spans, offset + absolute)) at = absolute;
|
|
127
|
+
from = absolute + 1;
|
|
128
|
+
}
|
|
129
|
+
if (at >= 0) marks.push({ key, at });
|
|
130
|
+
}
|
|
131
|
+
marks.sort((a, b) => a.at - b.at);
|
|
132
|
+
|
|
133
|
+
marks.forEach(({ key, at }, i) => {
|
|
134
|
+
const until = marks[i + 1]?.at ?? rest.length;
|
|
135
|
+
parts[key] = rest.slice(at, until).trim();
|
|
136
|
+
});
|
|
137
|
+
if (marks.length) rest = rest.slice(0, marks[0].at);
|
|
138
|
+
|
|
139
|
+
parts.filter = rest.trim();
|
|
140
|
+
return parts;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The row count the query itself asks for, if any. A number, or null.
|
|
145
|
+
*
|
|
146
|
+
* Read rather than parsed properly, because the client has no parser — the
|
|
147
|
+
* language lives on the server. Reading it *here*, off the same split as the
|
|
148
|
+
* chips, is what makes it right for a limit that is not the last thing typed:
|
|
149
|
+
* the table view matched `limit N` anchored at the end of the string, which
|
|
150
|
+
* found nothing at all in `customer limit 40000 sort name`, so the page size
|
|
151
|
+
* went out instead and forty thousand rows came back as twenty — the same lie
|
|
152
|
+
* that anchoring was supposed to have fixed, in the clause order nobody tried.
|
|
153
|
+
* It lived beside its caller in the browser, where nothing could test it.
|
|
154
|
+
*
|
|
155
|
+
* The split also settles what a *value* containing the word means, which no
|
|
156
|
+
* regex here could: `name = "limit 5"` is a name.
|
|
157
|
+
*
|
|
158
|
+
* `limit = 5`, on a table with a column called `limit`, is a filter — no
|
|
159
|
+
* number follows the keyword, so this hands back nothing and the caller falls
|
|
160
|
+
* back to the page size, which is what being wrong has always cost.
|
|
161
|
+
*/
|
|
162
|
+
export function explicitLimit(query) {
|
|
163
|
+
const match = splitQuery(query).limit?.match(/^limit\s+(\d+)\b/i);
|
|
164
|
+
if (!match) return null;
|
|
165
|
+
const n = Number(match[1]);
|
|
166
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Rebuild a query from its parts, dropping the empty ones. */
|
|
170
|
+
export function joinQuery(parts) {
|
|
171
|
+
return [parts.table, parts.filter, parts.show, parts.sort, parts.limit]
|
|
172
|
+
.filter((p) => p && String(p).trim())
|
|
173
|
+
.join(' ')
|
|
174
|
+
.trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The query with one clause removed.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} text
|
|
181
|
+
* @param {'filter'|'show'|'sort'|'limit'} clause
|
|
182
|
+
*/
|
|
183
|
+
export function withoutClause(text, clause) {
|
|
184
|
+
const parts = splitQuery(text);
|
|
185
|
+
delete parts[clause];
|
|
186
|
+
if (clause === 'filter') parts.filter = '';
|
|
187
|
+
return joinQuery(parts);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Clauses present in a query, as {key, label, text} — for rendering chips. */
|
|
191
|
+
export function activeClauses(text) {
|
|
192
|
+
const parts = splitQuery(text);
|
|
193
|
+
const out = [];
|
|
194
|
+
/* One chip per condition rather than one for the whole filter. A query built
|
|
195
|
+
from three narrowings should be undoable one narrowing at a time — the
|
|
196
|
+
interface wrote them separately, so it should be able to take them off
|
|
197
|
+
separately. */
|
|
198
|
+
splitConditions(parts.filter).forEach((condition, i) => {
|
|
199
|
+
out.push({ key: 'filter', index: i, label: i === 0 ? 'where' : 'and', text: condition });
|
|
200
|
+
});
|
|
201
|
+
if (parts.show) out.push({ key: 'show', label: 'showing', text: parts.show.replace(/^show\s+/i, '') });
|
|
202
|
+
if (parts.sort) out.push({ key: 'sort', label: 'sorted by', text: parts.sort.replace(/^(?:sort|order\s+by)\s+/i, '') });
|
|
203
|
+
if (parts.limit) out.push({ key: 'limit', label: 'limit', text: parts.limit.replace(/^limit\s+/i, '') });
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Split a filter into the conditions joined by top-level `and`.
|
|
209
|
+
*
|
|
210
|
+
* Top-level is the important word. `active = true and (priority = 1 or
|
|
211
|
+
* priority = 2)` is two conditions, not three: the bracketed group is one
|
|
212
|
+
* thing you either want or do not, and splitting inside it would produce
|
|
213
|
+
* chips that cannot be removed independently without changing what the query
|
|
214
|
+
* means. Depth and quoting are both tracked, so `name = "a and b"` is one
|
|
215
|
+
* condition whose value happens to contain the word.
|
|
216
|
+
*
|
|
217
|
+
* `or` is never split on. A filter that is `a or b` comes back as a single
|
|
218
|
+
* condition, which is right — removing half of an `or` is not a smaller
|
|
219
|
+
* filter, it is a different one.
|
|
220
|
+
*/
|
|
221
|
+
export function splitConditions(filter) {
|
|
222
|
+
const text = String(filter ?? '').trim();
|
|
223
|
+
if (!text) return [];
|
|
224
|
+
|
|
225
|
+
const parts = [];
|
|
226
|
+
let depth = 0;
|
|
227
|
+
let quote = null;
|
|
228
|
+
let start = 0;
|
|
229
|
+
/* Set when a `between` is open and its `and` has not been seen yet.
|
|
230
|
+
|
|
231
|
+
`between 2026-08-17 and 2026-08-27` contains an `and` that joins a range
|
|
232
|
+
rather than two conditions. Without this the filter bar drew it as two
|
|
233
|
+
chips — `WHERE hired_on between 2026-08-17` and `AND 2026-08-27` — and
|
|
234
|
+
dismissing either produced `hired_on between 2026-08-17`, which is not a
|
|
235
|
+
condition and came back as an error. One range, cut in half by the thing
|
|
236
|
+
that was only supposed to be listing it.
|
|
237
|
+
|
|
238
|
+
`splitTopLevel` in shared/query.ts has tracked this since `between`
|
|
239
|
+
shipped. This copy did not, which is the whole hazard of a mirror: each
|
|
240
|
+
side looks right on its own. */
|
|
241
|
+
let pendingBetween = false;
|
|
242
|
+
|
|
243
|
+
const wordAt = (word, i) => new RegExp(`^${word}\\b`, 'i').test(text.slice(i))
|
|
244
|
+
&& (i === 0 || /[\s)]/.test(text[i - 1]));
|
|
245
|
+
|
|
246
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
247
|
+
const c = text[i];
|
|
248
|
+
if (quote) {
|
|
249
|
+
if (c === '\\') { i += 1; continue; }
|
|
250
|
+
if (c === quote) quote = null;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
254
|
+
if (c === '(') { depth += 1; continue; }
|
|
255
|
+
if (c === ')') { depth = Math.max(0, depth - 1); continue; }
|
|
256
|
+
if (depth > 0) continue;
|
|
257
|
+
|
|
258
|
+
if ((c === 'b' || c === 'B') && wordAt('between', i)) {
|
|
259
|
+
pendingBetween = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A word boundary on both sides, so a column called `and_then` survives.
|
|
264
|
+
if ((c === 'a' || c === 'A') && wordAt('and', i)) {
|
|
265
|
+
// The first top-level `and` after a `between` belongs to it.
|
|
266
|
+
if (pendingBetween) {
|
|
267
|
+
pendingBetween = false;
|
|
268
|
+
i += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
parts.push(text.slice(start, i).trim());
|
|
272
|
+
i += 3;
|
|
273
|
+
start = i;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
parts.push(text.slice(start).trim());
|
|
277
|
+
|
|
278
|
+
/* A split that yields one piece is not a split.
|
|
279
|
+
|
|
280
|
+
`last_name contains and` — where the value *is* the keyword — splits at
|
|
281
|
+
that `and` into `last_name contains` and nothing. Dropping the empty half
|
|
282
|
+
and keeping the first left a chip with its value chopped off, which is a
|
|
283
|
+
misdescription of the filter and a dismiss button wired to the wrong
|
|
284
|
+
text.
|
|
285
|
+
|
|
286
|
+
`splitTopLevel` in shared/query.ts survives this by accident of shape: it
|
|
287
|
+
drops empty parts, finds fewer than two left, and falls through to
|
|
288
|
+
treating the whole term as one condition — which is why the server reads
|
|
289
|
+
`LIKE '%and%'` where the browser read a truncated condition. Same rule
|
|
290
|
+
here, deliberately: if the keyword did not actually stand between two
|
|
291
|
+
conditions, it belonged to the one. */
|
|
292
|
+
const kept = parts.filter(Boolean);
|
|
293
|
+
if (kept.length > 1) return kept;
|
|
294
|
+
return text ? [text] : [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The query with one condition of its filter removed, by index. */
|
|
298
|
+
export function withoutCondition(text, index) {
|
|
299
|
+
const parts = splitQuery(text);
|
|
300
|
+
const conditions = splitConditions(parts.filter);
|
|
301
|
+
conditions.splice(index, 1);
|
|
302
|
+
parts.filter = conditions.join(' and ');
|
|
303
|
+
return joinQuery(parts);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The sort clause's fields, split on commas, keyword already removed.
|
|
308
|
+
*
|
|
309
|
+
* `sort a, b desc` is two fields, not one field called "a, b desc". Splitting
|
|
310
|
+
* here rather than at each caller is what keeps the multi-field case from
|
|
311
|
+
* being everyone's separate problem.
|
|
312
|
+
*/
|
|
313
|
+
function sortFields(query) {
|
|
314
|
+
const body = splitQuery(query).sort?.replace(/^(?:sort|order\s+by)\s+/i, '').trim();
|
|
315
|
+
if (!body) return [];
|
|
316
|
+
return body.split(',').map((part) => {
|
|
317
|
+
const [column, ...rest] = part.trim().split(/\s+/);
|
|
318
|
+
return { column, direction: /^desc$/i.test(rest[0] ?? '') ? 'desc' : 'asc' };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Which way a query sorts by a column, or null if it does not.
|
|
324
|
+
*
|
|
325
|
+
* Answered by splitting rather than by matching, because the caller's column
|
|
326
|
+
* name goes into the comparison and a reference hop contains characters a
|
|
327
|
+
* regex reads as instructions: `invoice_id+line_no.sku` interpolated into a
|
|
328
|
+
* pattern means "one or more `d`, then any character" — a question about a
|
|
329
|
+
* different column entirely.
|
|
330
|
+
*/
|
|
331
|
+
export function sortDirection(query, column) {
|
|
332
|
+
return sortFields(query).find((f) => f.column === column)?.direction ?? null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The query you get by clicking a column header.
|
|
337
|
+
*
|
|
338
|
+
* Cycles ascending → descending → ascending on the column already sorted by,
|
|
339
|
+
* and replaces the clause otherwise. An explicit `direction` skips the cycle:
|
|
340
|
+
* it means "write down what is already happening", which is what adopting the
|
|
341
|
+
* default order does, and toggling it would flip the very thing being adopted.
|
|
342
|
+
*
|
|
343
|
+
* "Already sorted by this" means this column and nothing else. A click on one
|
|
344
|
+
* header of a multi-field sort replaces the whole clause rather than toggling
|
|
345
|
+
* one field of it — reversing one column of `sort a, b` is not something a
|
|
346
|
+
* single click can express, and guessing would be worse than starting fresh.
|
|
347
|
+
*/
|
|
348
|
+
/**
|
|
349
|
+
* Add a column to the sort rather than replacing it.
|
|
350
|
+
*
|
|
351
|
+
* `sort a, b desc` is expressible in the language and was reachable only by
|
|
352
|
+
* typing it: every header click replaced the whole clause, so a two-column
|
|
353
|
+
* sort could not be built by clicking. "Sort by title, then by hire date" is
|
|
354
|
+
* an ordinary thing to want of a table.
|
|
355
|
+
*
|
|
356
|
+
* A column already in the clause has its direction changed **in place** rather
|
|
357
|
+
* than being moved to the end. Sort order is precedence, and silently
|
|
358
|
+
* re-prioritising the column somebody was only reversing would change the
|
|
359
|
+
* answer in a way nothing on screen explains.
|
|
360
|
+
*/
|
|
361
|
+
export function addSort(query, column, direction) {
|
|
362
|
+
const parts = splitQuery(query);
|
|
363
|
+
const fields = sortFields(query);
|
|
364
|
+
const desc = direction === 'desc';
|
|
365
|
+
|
|
366
|
+
const at = fields.findIndex((f) => f.column === column);
|
|
367
|
+
if (at === -1) fields.push({ column, direction: desc ? 'desc' : 'asc' });
|
|
368
|
+
else fields[at] = { column, direction: desc ? 'desc' : 'asc' };
|
|
369
|
+
|
|
370
|
+
parts.sort = `sort ${fields.map((f) => `${f.column}${f.direction === 'desc' ? ' desc' : ''}`).join(', ')}`;
|
|
371
|
+
return joinQuery(parts);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** How many columns the query sorts by — the menu asks, to word itself. */
|
|
375
|
+
export function sortCount(query) {
|
|
376
|
+
return sortFields(query).length;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function nextSort(query, column, direction) {
|
|
380
|
+
const parts = splitQuery(query);
|
|
381
|
+
const fields = sortFields(query);
|
|
382
|
+
const onlyThis = fields.length === 1 && fields[0].column === column;
|
|
383
|
+
|
|
384
|
+
const desc = direction
|
|
385
|
+
? direction === 'desc'
|
|
386
|
+
: onlyThis && fields[0].direction === 'asc';
|
|
387
|
+
|
|
388
|
+
parts.sort = `sort ${column}${desc ? ' desc' : ''}`;
|
|
389
|
+
return joinQuery(parts);
|
|
390
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a column should be drawn.
|
|
3
|
+
*
|
|
4
|
+
* Decided per column, once per result set, from the declared type *and* the
|
|
5
|
+
* values actually on the page. Type alone is not enough: SQLite has no
|
|
6
|
+
* boolean, so a flag column arrives as INTEGER and would otherwise render as
|
|
7
|
+
* a column of 1s and 0s. Values alone are not enough either — a page where
|
|
8
|
+
* every quantity happens to be 0 or 1 is not a column of checkboxes.
|
|
9
|
+
*
|
|
10
|
+
* The rule throughout: formatting may never hide the stored value. Anything
|
|
11
|
+
* reformatted carries the raw value in its title, because in a database tool
|
|
12
|
+
* the difference between `5000` and `5,000` is sometimes the whole question.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { columnKind } from './core.js';
|
|
16
|
+
|
|
17
|
+
/** Columns whose name says "flag" loudly enough to trust 0/1 as a boolean. */
|
|
18
|
+
const FLAG_NAME = /^(is|has|can|should|was)_|_(flag|enabled|disabled)$|^(active|enabled|disabled|deleted|archived|verified|published|visible|locked|hidden|default|primary|required|readonly)$/i;
|
|
19
|
+
|
|
20
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Below this many rows, sorting any column is cheap enough to offer freely.
|
|
24
|
+
*
|
|
25
|
+
* `ESTIMATE_ABOVE` from adapters/adapter.ts, which is where this codebase
|
|
26
|
+
* already draws the line between "counting is free" and "counting is a scan
|
|
27
|
+
* worth avoiding". A sort is the same kind of work as a count, so it belongs
|
|
28
|
+
* on the same side of the same line rather than getting a second threshold to
|
|
29
|
+
* disagree with the first.
|
|
30
|
+
*/
|
|
31
|
+
export const SORT_SCAN_BELOW = 50_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* May the header offer to sort this column?
|
|
35
|
+
*
|
|
36
|
+
* A header click is a one-click action with no visible cost, and on an
|
|
37
|
+
* unindexed column of a large table it is a full scan and a sort of
|
|
38
|
+
* everything to show twenty rows. So the offer is made when an index leads on
|
|
39
|
+
* the column — or when the table is small enough that the scan costs nothing
|
|
40
|
+
* anybody would notice.
|
|
41
|
+
*
|
|
42
|
+
* That second clause is the whole point. The rule used to be the first alone,
|
|
43
|
+
* which is right on a table of millions and far too careful on one of a
|
|
44
|
+
* hundred and seventy: `employee.last_name` had no index, so there was no way
|
|
45
|
+
* to sort by it from the interface at all.
|
|
46
|
+
*
|
|
47
|
+
* `approxRows` unknown is not treated as small. A table nobody could count is
|
|
48
|
+
* not one to assume is cheap.
|
|
49
|
+
*
|
|
50
|
+
* Pure, and exported, so the rule can be tested directly rather than through a
|
|
51
|
+
* browser — no fixture in this repo has fifty thousand rows in it, so the
|
|
52
|
+
* large-table half is otherwise a branch nothing ever exercises.
|
|
53
|
+
*/
|
|
54
|
+
export function sortableFromHeader(table) {
|
|
55
|
+
if (!table) return false;
|
|
56
|
+
return table.approxRows != null && table.approxRows < SORT_SCAN_BELOW;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function planColumn(meta, name, rows) {
|
|
60
|
+
const values = rows.map((r) => r[name]).filter((v) => v !== null && v !== undefined);
|
|
61
|
+
const declared = columnKind(meta?.type);
|
|
62
|
+
const plan = { align: 'left', kind: declared, mono: false };
|
|
63
|
+
|
|
64
|
+
const allNumeric = values.length > 0 && values.every((v) => typeof v === 'number');
|
|
65
|
+
|
|
66
|
+
// Boolean: declared as one, or an integer flag column whose name says so.
|
|
67
|
+
if (declared === 'boolean' || (allNumeric && FLAG_NAME.test(name) && values.every((v) => v === 0 || v === 1))) {
|
|
68
|
+
return { ...plan, kind: 'boolean', align: 'center' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (declared === 'number' || allNumeric) {
|
|
72
|
+
/* Keys and references are identifiers that happen to be numeric. Grouping
|
|
73
|
+
them turns invoice 1001 into "1,001", which reads as a quantity and is
|
|
74
|
+
not what the value means. */
|
|
75
|
+
const identifier = Boolean(meta?.primaryKey || meta?.references);
|
|
76
|
+
const decimals = identifier ? 0 : Math.max(0, ...values.map(countDecimals));
|
|
77
|
+
const group = !identifier && values.some((v) => Math.abs(Number(v)) >= 10000);
|
|
78
|
+
return { ...plan, kind: 'number', align: 'right', decimals, group, identifier };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (declared === 'date' || (values.length && values.every((v) => typeof v === 'string' && ISO_DATE.test(v)))) {
|
|
82
|
+
// A column of timestamps all at midnight is a date column wearing a
|
|
83
|
+
// timestamp's clothes; showing ` 00:00:00` on every row is pure noise.
|
|
84
|
+
const anyTime = values.some((v) => /[ T]\d{2}:\d{2}/.test(String(v)) && !/[ T]00:00:00(\.0+)?$/.test(String(v)));
|
|
85
|
+
return { ...plan, kind: 'date', mono: true, showTime: anyTime };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Short, uniform text — codes, statuses, currencies — reads better in a
|
|
89
|
+
narrow monospace column than stretched across a wide proportional one. */
|
|
90
|
+
const longest = Math.max(0, ...values.map((v) => String(v).length));
|
|
91
|
+
return { ...plan, kind: 'text', mono: longest <= 12 && values.length > 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function countDecimals(value) {
|
|
95
|
+
const text = String(value);
|
|
96
|
+
const dot = text.indexOf('.');
|
|
97
|
+
return dot === -1 ? 0 : Math.min(6, text.length - dot - 1);
|
|
98
|
+
}
|