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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A connection that hands out shape and never rows.
|
|
3
|
+
*
|
|
4
|
+
* The README's boundary — "rows entering an agent's context leave the
|
|
5
|
+
* machine, so point it at development databases" — was a sentence asking to
|
|
6
|
+
* be believed. `"rows": false` makes it a property of the connection: the
|
|
7
|
+
* catalog, the counts, the lint and the plans still answer, and every path
|
|
8
|
+
* that would return a value from a row refuses.
|
|
9
|
+
*
|
|
10
|
+
* Enforced by wrapping the adapter rather than by checking at each endpoint.
|
|
11
|
+
* There are eight endpoints and fifteen tools today and there will be more,
|
|
12
|
+
* and a rule spelled out in eighteen places is a rule with a hole in it. The
|
|
13
|
+
* wrapper is the choke point every read already goes through, so a feature
|
|
14
|
+
* added later inherits the boundary instead of having to remember it.
|
|
15
|
+
*
|
|
16
|
+
* What still answers is deliberate: shape is what an agent is here for. Which
|
|
17
|
+
* tables exist, what points at what, how many rows there are, how much of a
|
|
18
|
+
* column is null, what a statement's plan looks like. What it cannot have is
|
|
19
|
+
* a value out of a row — including the ones `profile` reports, since a
|
|
20
|
+
* minimum, a maximum and the most common values are row content however
|
|
21
|
+
* aggregated.
|
|
22
|
+
*/
|
|
23
|
+
import { Refusal } from '../adapters/adapter.js';
|
|
24
|
+
const why = (name, what) => new Refusal(`"${name}" is shape-only: ${what} would return data out of its rows. `
|
|
25
|
+
+ 'The catalog, row counts, null fractions, plans and lint still answer. '
|
|
26
|
+
+ 'Remove "rows": false from its entry in your tablewalk.json to read rows from it.');
|
|
27
|
+
/**
|
|
28
|
+
* The same adapter with every row-returning method refused.
|
|
29
|
+
*
|
|
30
|
+
* Methods are copied rather than proxied through a Proxy so that the optional
|
|
31
|
+
* ones stay honestly absent: an adapter with no `runSql` must still look like
|
|
32
|
+
* an adapter with no `runSql`, or callers that check for it before offering
|
|
33
|
+
* SQL start offering it and failing.
|
|
34
|
+
*/
|
|
35
|
+
export function shapeOnly(adapter, name) {
|
|
36
|
+
const wrapped = Object.create(Object.getPrototypeOf(adapter));
|
|
37
|
+
Object.assign(wrapped, adapter);
|
|
38
|
+
/* Bound to the original: the copies above are the original's methods, and
|
|
39
|
+
calling them on `wrapped` would work by accident on a plain object and
|
|
40
|
+
break on a class instance holding private state. */
|
|
41
|
+
const from = (key) => {
|
|
42
|
+
const method = adapter[key];
|
|
43
|
+
return typeof method === 'function'
|
|
44
|
+
? method.bind(adapter)
|
|
45
|
+
: undefined;
|
|
46
|
+
};
|
|
47
|
+
wrapped.introspect = from('introspect');
|
|
48
|
+
wrapped.close = from('close');
|
|
49
|
+
wrapped.count = from('count');
|
|
50
|
+
wrapped.query = () => { throw why(name, 'reading rows'); };
|
|
51
|
+
if (adapter.runSql)
|
|
52
|
+
wrapped.runSql = () => { throw why(name, 'running SQL'); };
|
|
53
|
+
if (adapter.runView)
|
|
54
|
+
wrapped.runView = () => { throw why(name, 'running a view'); };
|
|
55
|
+
if (adapter.countView)
|
|
56
|
+
wrapped.countView = from('countView');
|
|
57
|
+
if (adapter.insert)
|
|
58
|
+
wrapped.insert = () => { throw why(name, 'inserting'); };
|
|
59
|
+
if (adapter.update)
|
|
60
|
+
wrapped.update = () => { throw why(name, 'updating'); };
|
|
61
|
+
if (adapter.remove)
|
|
62
|
+
wrapped.remove = () => { throw why(name, 'deleting'); };
|
|
63
|
+
/* A transaction hands out `insert` and `remove` of its own, from inside the
|
|
64
|
+
adapter, where the refusals above cannot reach them. Refused whole. */
|
|
65
|
+
if (adapter.transaction)
|
|
66
|
+
wrapped.transaction = () => { throw why(name, 'writing'); };
|
|
67
|
+
/* A breakdown's group keys *are* row values — `by status` answers with the
|
|
68
|
+
statuses. The counts beside them would be safe and are not worth the
|
|
69
|
+
surface: an answer with its labels blanked is not an answer. Counting the
|
|
70
|
+
groups is a cardinality fact and stays, for the same reason `profile`
|
|
71
|
+
keeps its distinct count. */
|
|
72
|
+
if (adapter.runBreakdown)
|
|
73
|
+
wrapped.runBreakdown = () => { throw why(name, 'grouping rows'); };
|
|
74
|
+
if (adapter.countBreakdown)
|
|
75
|
+
wrapped.countBreakdown = from('countBreakdown');
|
|
76
|
+
/* Profile keeps the numbers and loses the values. "52% null, 4,100
|
|
77
|
+
distinct" is the fact that decides a LEFT join and carries nothing out
|
|
78
|
+
of any row; a minimum, a maximum and the most common values carry rows. */
|
|
79
|
+
if (adapter.profile) {
|
|
80
|
+
const profile = from('profile');
|
|
81
|
+
wrapped.profile = async (table, limits) => {
|
|
82
|
+
const result = await profile(table, limits);
|
|
83
|
+
return {
|
|
84
|
+
...result,
|
|
85
|
+
columns: result.columns.map(({ min, max, common, ...rest }) => rest),
|
|
86
|
+
redacted: 'Values withheld: this connection is shape-only.',
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return wrapped;
|
|
91
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counting things by something: GROUP BY, as a shape rather than as text.
|
|
3
|
+
*
|
|
4
|
+
* This was the last real hole in the query language. "How many invoices per
|
|
5
|
+
* month", "which countries have the most customers", "what does each status
|
|
6
|
+
* add up to" are the questions anybody asks of a database in the first ten
|
|
7
|
+
* minutes, and until now the only way to ask them here was to drop into `sql`
|
|
8
|
+
* — which means an agent leaves the checked path exactly where the question
|
|
9
|
+
* gets interesting, and a person leaves the tool.
|
|
10
|
+
*
|
|
11
|
+
* A breakdown is deliberately *not* a view with a `groupBy` bolted on. A view
|
|
12
|
+
* promises one row per base record, and every part of it — the correlated
|
|
13
|
+
* aggregates, the LEFT JOIN rule, the row-click that opens a record — leans on
|
|
14
|
+
* that promise. Grouping breaks it: the rows that come back are not records
|
|
15
|
+
* and cannot be opened, and a column that is neither a key nor an aggregate is
|
|
16
|
+
* a bug on two engines and a silent wrong answer on the third. So it is a
|
|
17
|
+
* sibling type with its own compiler, sharing the parts that are genuinely the
|
|
18
|
+
* same: path resolution, the join planner, and `buildWhere`.
|
|
19
|
+
*
|
|
20
|
+
* invoice count by status
|
|
21
|
+
* invoice sum total by month invoice_date
|
|
22
|
+
* invoice count, avg total by customer_id.country_code.name sort count desc
|
|
23
|
+
*
|
|
24
|
+
* Two rules the shape enforces, both of which SQL leaves to the author:
|
|
25
|
+
*
|
|
26
|
+
* - **Every selected expression is a key or a measure.** There is nowhere to
|
|
27
|
+
* put a third thing, so `SELECT status, name, COUNT(*) … GROUP BY status`
|
|
28
|
+
* — which MySQL will happily answer with an arbitrary `name` — cannot be
|
|
29
|
+
* written.
|
|
30
|
+
* - **The result is bounded like every other read.** A breakdown over a
|
|
31
|
+
* high-cardinality column is a million-row answer, and `sort count desc
|
|
32
|
+
* limit 20` is what somebody meant anyway.
|
|
33
|
+
*/
|
|
34
|
+
import { buildWhere, clampLimit, clampOffset, } from '../adapters/adapter.js';
|
|
35
|
+
import { findTable } from './schema.js';
|
|
36
|
+
import { JoinPlan, PathError, qualifiedTable, resolvePath, sqlLiteral, } from './view.js';
|
|
37
|
+
export const MEASURE_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'];
|
|
38
|
+
/** The periods a date key can be cut into. */
|
|
39
|
+
export const DATE_UNITS = ['day', 'month', 'quarter', 'year'];
|
|
40
|
+
/** How many group keys are worth having. */
|
|
41
|
+
export const MAX_KEYS = 3;
|
|
42
|
+
/**
|
|
43
|
+
* Is this column something a date bucket can be taken of?
|
|
44
|
+
*
|
|
45
|
+
* Read off the declared type rather than the values, because the answer has
|
|
46
|
+
* to be the same before and after the table has any rows in it. SQLite's
|
|
47
|
+
* `DATE` is a convention rather than a type, which is exactly why the check is
|
|
48
|
+
* on the declaration: a column somebody called `DATE` is one they intend to
|
|
49
|
+
* hold dates, and `strftime` over text that is not one returns null — a group
|
|
50
|
+
* of its own rather than an error, which is the honest outcome.
|
|
51
|
+
*/
|
|
52
|
+
const isDateish = (type) => /date|time/i.test(type ?? '');
|
|
53
|
+
/**
|
|
54
|
+
* Compile a breakdown to one parameterised SELECT.
|
|
55
|
+
*
|
|
56
|
+
* The order of work matches `compileView`'s and for the same reason: keys,
|
|
57
|
+
* then measures, then the filter, then the sort — so aliases are handed out in
|
|
58
|
+
* first-use order and the same breakdown always compiles to the same text.
|
|
59
|
+
*/
|
|
60
|
+
export function compileBreakdown(schema, def, style, offset = 0) {
|
|
61
|
+
const base = findTable(schema, def.base);
|
|
62
|
+
if (!base) {
|
|
63
|
+
throw new PathError(`No table called "${def.base}" in this database, so there is nothing to break down.`, def.base, def.base, 0);
|
|
64
|
+
}
|
|
65
|
+
if (!def.by?.length) {
|
|
66
|
+
throw new PathError('A breakdown needs something to group by — `by status`, or `by month invoice_date`.', '', '', 0);
|
|
67
|
+
}
|
|
68
|
+
if (def.by.length > MAX_KEYS) {
|
|
69
|
+
/* A cap rather than a warning. Three keys is already a table nobody reads
|
|
70
|
+
across; the fourth is how a breakdown becomes the base table again, one
|
|
71
|
+
row per record, with the count column always saying 1. */
|
|
72
|
+
throw new PathError(`A breakdown groups by at most ${MAX_KEYS} things; this one names ${def.by.length}.`, def.by[MAX_KEYS].path, def.by[MAX_KEYS].path, 0);
|
|
73
|
+
}
|
|
74
|
+
if (!def.measures?.length) {
|
|
75
|
+
throw new PathError('A breakdown needs something to measure — `count`, or `sum total`.', '', '', 0);
|
|
76
|
+
}
|
|
77
|
+
const plan = new JoinPlan(schema, base, style);
|
|
78
|
+
const qualify = (path) => {
|
|
79
|
+
const resolved = resolvePath(schema, def.base, path);
|
|
80
|
+
let prefix = '';
|
|
81
|
+
for (let i = 0; i < resolved.hops.length; i++) {
|
|
82
|
+
prefix = prefix ? `${prefix}.${resolved.hopNames[i]}` : resolved.hopNames[i];
|
|
83
|
+
plan.record(prefix, resolved.hops[i]);
|
|
84
|
+
}
|
|
85
|
+
return plan.qualify(resolved);
|
|
86
|
+
};
|
|
87
|
+
const seen = new Map();
|
|
88
|
+
/** Output names are unique or the result set loses a column silently. */
|
|
89
|
+
const nameFor = (wanted) => {
|
|
90
|
+
const count = seen.get(wanted) ?? 0;
|
|
91
|
+
seen.set(wanted, count + 1);
|
|
92
|
+
return count ? `${wanted}_${count + 1}` : wanted;
|
|
93
|
+
};
|
|
94
|
+
const columns = [];
|
|
95
|
+
const select = [];
|
|
96
|
+
const grouping = [];
|
|
97
|
+
for (const key of def.by) {
|
|
98
|
+
const resolved = resolvePath(schema, def.base, key.path);
|
|
99
|
+
let sql = qualify(key.path);
|
|
100
|
+
if (key.bucket) {
|
|
101
|
+
if (!isDateish(resolved.column.type)) {
|
|
102
|
+
throw new PathError(`"${key.path}" is ${resolved.column.type || 'untyped'}, so it cannot be grouped by ${key.bucket}. `
|
|
103
|
+
+ 'Group by it directly, or pick a date column.', key.path, key.path, resolved.hops.length);
|
|
104
|
+
}
|
|
105
|
+
sql = style.dateBucket(sql, key.bucket);
|
|
106
|
+
}
|
|
107
|
+
const name = nameFor(key.alias?.trim() || (key.bucket ? `${key.bucket}(${resolved.path})` : resolved.path));
|
|
108
|
+
select.push(`${sql} AS ${style.quote(name)}`);
|
|
109
|
+
/* Grouped by the *expression*, not by the output name. Postgres and MySQL
|
|
110
|
+
would take the alias; SQLite would too — but a bucketed key aliased to
|
|
111
|
+
`month(invoice_date)` and grouped by that name is one identifier away
|
|
112
|
+
from grouping by a column that happens to share it. The expression is
|
|
113
|
+
never ambiguous. */
|
|
114
|
+
grouping.push(sql);
|
|
115
|
+
columns.push({
|
|
116
|
+
name,
|
|
117
|
+
kind: 'key',
|
|
118
|
+
path: resolved.path,
|
|
119
|
+
/* A bucket makes text out of whatever it was, and the interface should
|
|
120
|
+
render `2026-08` as the label it is rather than as a date it is not. */
|
|
121
|
+
type: key.bucket ? 'text' : resolved.column.type,
|
|
122
|
+
bucket: key.bucket,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const measure of def.measures) {
|
|
126
|
+
if (measure.fn === 'count') {
|
|
127
|
+
if (measure.distinct && !measure.path) {
|
|
128
|
+
throw new PathError('`count distinct` needs a column to count distinct values of.', '', '', 0);
|
|
129
|
+
}
|
|
130
|
+
/* COUNT(*) rather than COUNT(column) when no column is named: a column
|
|
131
|
+
count skips nulls, which turns "how many invoices" into "how many
|
|
132
|
+
invoices have a total" without saying so. Naming a column is how you
|
|
133
|
+
ask the other question, and then the skipping is the point. */
|
|
134
|
+
const expression = measure.path
|
|
135
|
+
? `COUNT(${measure.distinct ? 'DISTINCT ' : ''}${qualify(measure.path)})`
|
|
136
|
+
: 'COUNT(*)';
|
|
137
|
+
const name = nameFor(measure.alias?.trim() || (measure.path ? `count(${measure.path})` : 'count'));
|
|
138
|
+
select.push(`${expression} AS ${style.quote(name)}`);
|
|
139
|
+
columns.push({ name, kind: 'measure', fn: 'count', path: measure.path, type: 'integer' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!measure.path) {
|
|
143
|
+
throw new PathError(`\`${measure.fn}\` needs a column: \`${measure.fn} total\`.`, '', '', 0);
|
|
144
|
+
}
|
|
145
|
+
if (measure.distinct) {
|
|
146
|
+
throw new PathError(`\`distinct\` only means something with \`count\`, not with \`${measure.fn}\`.`, measure.path, measure.path, 0);
|
|
147
|
+
}
|
|
148
|
+
const resolved = resolvePath(schema, def.base, measure.path);
|
|
149
|
+
const name = nameFor(measure.alias?.trim() || `${measure.fn}(${resolved.path})`);
|
|
150
|
+
select.push(`${measure.fn.toUpperCase()}(${qualify(measure.path)}) AS ${style.quote(name)}`);
|
|
151
|
+
columns.push({
|
|
152
|
+
name,
|
|
153
|
+
kind: 'measure',
|
|
154
|
+
fn: measure.fn,
|
|
155
|
+
path: resolved.path,
|
|
156
|
+
/* `avg` is a fraction of whatever it averaged — an average of integers
|
|
157
|
+
is not an integer, and saying otherwise makes the interface round a
|
|
158
|
+
number the database did not. */
|
|
159
|
+
type: measure.fn === 'avg' ? 'numeric' : resolved.column.type,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const conditions = def.filter?.groups.length
|
|
163
|
+
? {
|
|
164
|
+
groups: def.filter.groups.map((group) => group.map((c) => ({ column: c.path, op: c.op, value: c.value }))),
|
|
165
|
+
}
|
|
166
|
+
: undefined;
|
|
167
|
+
const where = buildWhere(conditions, style, 1, qualify);
|
|
168
|
+
/* Sorted by output name, and only by output name.
|
|
169
|
+
|
|
170
|
+
Every column of a breakdown is either a key or a measure, and both are in
|
|
171
|
+
the SELECT list under a name — so there is nothing else it could sort by,
|
|
172
|
+
and ORDER BY runs late enough to see the aliases on all three engines.
|
|
173
|
+
Sorting by an expression instead would let `sort total desc` mean the
|
|
174
|
+
ungrouped column on a statement where that column no longer exists. */
|
|
175
|
+
const names = new Set(columns.map((c) => c.name));
|
|
176
|
+
const order = (def.orderBy ?? []).map((o) => {
|
|
177
|
+
if (!names.has(o.name)) {
|
|
178
|
+
throw new PathError(`Nothing called "${o.name}" is in this breakdown. It shows: ${[...names].join(', ')}.`, o.name, o.name, 0);
|
|
179
|
+
}
|
|
180
|
+
return `${style.quote(o.name)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`;
|
|
181
|
+
});
|
|
182
|
+
const limit = clampLimit(def.limit);
|
|
183
|
+
const skip = clampOffset(offset);
|
|
184
|
+
const n = where.params.length;
|
|
185
|
+
const lines = [
|
|
186
|
+
`SELECT ${select.join(',\n ')}`,
|
|
187
|
+
` FROM ${qualifiedTable(schema, base.id, style)} ${style.quote(plan.baseAlias)}`,
|
|
188
|
+
...plan.clauses(),
|
|
189
|
+
];
|
|
190
|
+
if (where.text)
|
|
191
|
+
lines.push(where.text);
|
|
192
|
+
lines.push(` GROUP BY ${grouping.join(', ')}`);
|
|
193
|
+
if (order.length)
|
|
194
|
+
lines.push(` ORDER BY ${order.join(', ')}`);
|
|
195
|
+
lines.push(` LIMIT ${style.placeholder(n + 1)} OFFSET ${style.placeholder(n + 2)}`);
|
|
196
|
+
return { text: lines.join('\n'), params: [...where.params, limit, skip], columns, limit };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* How many groups there are, ignoring the page.
|
|
200
|
+
*
|
|
201
|
+
* `SELECT COUNT(*) FROM (the grouped statement)` — the only honest way to
|
|
202
|
+
* count groups, because the number is a property of the grouping rather than
|
|
203
|
+
* of the base table. Without the limit and offset, which is the whole point of
|
|
204
|
+
* asking.
|
|
205
|
+
*/
|
|
206
|
+
export function compileBreakdownCount(schema, def, style) {
|
|
207
|
+
const inner = compileBreakdown(schema, def, style);
|
|
208
|
+
/* The LIMIT/OFFSET line is the last one and carries the last two
|
|
209
|
+
parameters. Dropped by rebuilding rather than by string surgery would be
|
|
210
|
+
nicer; it is the last line by construction, and the test says so. */
|
|
211
|
+
const lines = inner.text.split('\n');
|
|
212
|
+
const body = lines.slice(0, -1).join('\n');
|
|
213
|
+
return {
|
|
214
|
+
text: `SELECT COUNT(*) AS n FROM (\n${body}\n) AS grouped`,
|
|
215
|
+
params: inner.params.slice(0, -2),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Render a breakdown as the SQL it will become, values inline.
|
|
220
|
+
*
|
|
221
|
+
* The exact counterpart of `explainView`, and here for the same reason: the
|
|
222
|
+
* SQL pane is how somebody learns what a line of the language did on their
|
|
223
|
+
* behalf, and a statement full of `?` teaches nothing about the filter that
|
|
224
|
+
* produced it. It also has to be a statement that runs, because the pane
|
|
225
|
+
* offers to open it in the SQL editor.
|
|
226
|
+
*/
|
|
227
|
+
export function explainBreakdown(schema, def, style, offset = 0) {
|
|
228
|
+
const compiled = compileBreakdown(schema, def, style, offset);
|
|
229
|
+
let i = 0;
|
|
230
|
+
return compiled.text.replace(/\$\d+|\?/g, () => sqlLiteral(compiled.params[i++]));
|
|
231
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A breakdown, written down.
|
|
3
|
+
*
|
|
4
|
+
* The same argument the view text form makes, and it applies harder here: a
|
|
5
|
+
* breakdown is the shape most likely to be pasted into a conversation, saved
|
|
6
|
+
* in a config file, or handed to an agent as a string. A grouping that can
|
|
7
|
+
* only be expressed as JSON is one nobody writes by hand.
|
|
8
|
+
*
|
|
9
|
+
* invoice count by status
|
|
10
|
+
* invoice count, sum total as billed by month invoice_date sort billed desc limit 12
|
|
11
|
+
* invoice where total > 100 count by customer_id.country_code.name as country
|
|
12
|
+
*
|
|
13
|
+
* The grammar is the query language plus two clauses. Measures come first —
|
|
14
|
+
* `count`, `count distinct sold_by`, `sum total`, each optionally `as name` —
|
|
15
|
+
* then `by`, then the things to group by, each optionally cut down to a period
|
|
16
|
+
* (`by month invoice_date`) and optionally named. Everything else in the line
|
|
17
|
+
* is the ordinary language: `where`, `sort`, `limit`, parsed by
|
|
18
|
+
* `parseViewQuery` exactly as the composer's filter box parses it. Two parsers
|
|
19
|
+
* for one language is how a language starts disagreeing with itself.
|
|
20
|
+
*
|
|
21
|
+
* One deliberate asymmetry with a view: **`sort` names an output column**, not
|
|
22
|
+
* a path. Every column of a breakdown is a key or a measure and both are in
|
|
23
|
+
* the answer under a name, so there is nothing else it could mean — and `sort
|
|
24
|
+
* total desc` on a grouped statement, where `total` is no longer a column,
|
|
25
|
+
* should not quietly compile into something else.
|
|
26
|
+
*/
|
|
27
|
+
import { parseViewQuery, tryResolvePath } from './view.js';
|
|
28
|
+
import { findTable } from './schema.js';
|
|
29
|
+
import { DATE_UNITS, MEASURE_FUNCTIONS, } from './breakdown.js';
|
|
30
|
+
/** Whether a line is asking for a breakdown at all. */
|
|
31
|
+
export function looksLikeBreakdown(text) {
|
|
32
|
+
/* The `by` keyword, standing alone and not the tail of `order by`. That is
|
|
33
|
+
the whole tell: no other clause in the language uses it, which is why it
|
|
34
|
+
was available to mean this. */
|
|
35
|
+
return /(?<!\border\s)\bby\s+\S/i.test(String(text ?? ''));
|
|
36
|
+
}
|
|
37
|
+
const MEASURE_RE = new RegExp(String.raw `^(${MEASURE_FUNCTIONS.join('|')})` +
|
|
38
|
+
String.raw `(\s+distinct)?` +
|
|
39
|
+
String.raw `(?:\s+([\w.$+]+))?` +
|
|
40
|
+
String.raw `(?:\s+as\s+([\w$]+))?$`, 'i');
|
|
41
|
+
const KEY_RE = new RegExp(String.raw `^(?:(${DATE_UNITS.join('|')})\s+)?([\w.$+]+)(?:\s+as\s+([\w$]+))?$`, 'i');
|
|
42
|
+
/** Where the clauses after the group keys begin. */
|
|
43
|
+
const TAIL_CLAUSE = /\b(sort|order\s+by|limit)\b/i;
|
|
44
|
+
/**
|
|
45
|
+
* Split `a, b, c` on commas, dropping the empties a trailing comma leaves.
|
|
46
|
+
*/
|
|
47
|
+
const commas = (text) => text.split(',').map((part) => part.trim()).filter(Boolean);
|
|
48
|
+
/**
|
|
49
|
+
* Find the `by` that pivots the line.
|
|
50
|
+
*
|
|
51
|
+
* The *first* standalone `by` not preceded by `order`, because a breakdown's
|
|
52
|
+
* `sort` clause comes after the keys and may legitimately be written `order
|
|
53
|
+
* by`. Searching from the front means `invoice count by status order by count`
|
|
54
|
+
* splits where it should.
|
|
55
|
+
*/
|
|
56
|
+
function pivot(text) {
|
|
57
|
+
for (const match of text.matchAll(/\bby\b/gi)) {
|
|
58
|
+
const at = match.index ?? 0;
|
|
59
|
+
if (/\border\s+$/i.test(text.slice(0, at)))
|
|
60
|
+
continue;
|
|
61
|
+
return at;
|
|
62
|
+
}
|
|
63
|
+
return -1;
|
|
64
|
+
}
|
|
65
|
+
export function textToBreakdown(schema, text, options = {}) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const body = String(text ?? '').trim();
|
|
68
|
+
if (!body)
|
|
69
|
+
return { errors: [{ message: 'Start with a table name.' }] };
|
|
70
|
+
const baseName = body.split(/\s+/)[0];
|
|
71
|
+
const base = findTable(schema, baseName) ?? schema.tables.find((t) => t.name === baseName);
|
|
72
|
+
if (!base) {
|
|
73
|
+
return { errors: [{ message: `No table called "${baseName}" in this database.`, at: 0 }] };
|
|
74
|
+
}
|
|
75
|
+
const rest = body.slice(baseName.length);
|
|
76
|
+
const at = pivot(rest);
|
|
77
|
+
if (at === -1) {
|
|
78
|
+
return {
|
|
79
|
+
errors: [{
|
|
80
|
+
message: 'A breakdown needs `by` — try `count by status`, or `sum total by month invoice_date`.',
|
|
81
|
+
}],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const head = rest.slice(0, at);
|
|
85
|
+
const afterBy = rest.slice(at + 2);
|
|
86
|
+
/* The keys run to the first clause that is not a key. Split here rather
|
|
87
|
+
than by counting commas, because a key list and a sort clause are both
|
|
88
|
+
comma-separated and only a keyword tells them apart. */
|
|
89
|
+
const tailAt = afterBy.search(TAIL_CLAUSE);
|
|
90
|
+
const keyText = tailAt === -1 ? afterBy : afterBy.slice(0, tailAt);
|
|
91
|
+
const tail = tailAt === -1 ? '' : afterBy.slice(tailAt);
|
|
92
|
+
/* The measures are the longest suffix of `head` that reads as a list of
|
|
93
|
+
them; everything before that is the ordinary language, and is handed on
|
|
94
|
+
untouched. Found by trying each word boundary from the left and keeping
|
|
95
|
+
the first that works, which is what makes `invoice where total > 100
|
|
96
|
+
count by status` split correctly without this module knowing what `where`
|
|
97
|
+
means.
|
|
98
|
+
|
|
99
|
+
Splitting on commas alone was the obvious version and it was wrong: `where
|
|
100
|
+
total > 100 count` holds no comma at all, so the whole clause read as one
|
|
101
|
+
piece, failed to match, and the line came back "nothing to measure" with
|
|
102
|
+
a `count` plainly in it. */
|
|
103
|
+
const words = [...head.matchAll(/\S+/g)].map((m) => ({ text: m[0], at: m.index ?? 0 }));
|
|
104
|
+
const measures = [];
|
|
105
|
+
let prefix = head;
|
|
106
|
+
for (const word of words) {
|
|
107
|
+
const candidate = head.slice(word.at);
|
|
108
|
+
const pieces = commas(candidate);
|
|
109
|
+
if (!pieces.length || !pieces.every((piece) => MEASURE_RE.test(piece)))
|
|
110
|
+
continue;
|
|
111
|
+
for (const piece of pieces) {
|
|
112
|
+
const [, fn, distinct, path, alias] = MEASURE_RE.exec(piece);
|
|
113
|
+
measures.push({
|
|
114
|
+
fn: fn.toLowerCase(),
|
|
115
|
+
path: path || undefined,
|
|
116
|
+
distinct: distinct ? true : undefined,
|
|
117
|
+
alias: alias || undefined,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
prefix = head.slice(0, word.at);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (!measures.length) {
|
|
124
|
+
errors.push({
|
|
125
|
+
message: 'Nothing to measure before `by`. Try `count by …`, or `sum total by …`.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
for (const measure of measures) {
|
|
129
|
+
if (!measure.path)
|
|
130
|
+
continue;
|
|
131
|
+
const { error } = tryResolvePath(schema, base.id, measure.path);
|
|
132
|
+
if (error)
|
|
133
|
+
errors.push({ message: error.message });
|
|
134
|
+
}
|
|
135
|
+
const keys = [];
|
|
136
|
+
for (const piece of commas(keyText)) {
|
|
137
|
+
const match = KEY_RE.exec(piece);
|
|
138
|
+
if (!match) {
|
|
139
|
+
errors.push({
|
|
140
|
+
message: `"${piece}" is not something to group by. A column, a path like customer_id.name, `
|
|
141
|
+
+ `or a period like \`month invoice_date\`.`,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const [, unit, path, alias] = match;
|
|
146
|
+
/* Resolved here rather than left to the compiler. This runs on a
|
|
147
|
+
keystroke and the compiler runs on a request, so a path caught here is
|
|
148
|
+
a message under the caret and a path caught there is a failed call. */
|
|
149
|
+
const { error } = tryResolvePath(schema, base.id, path);
|
|
150
|
+
if (error) {
|
|
151
|
+
errors.push({ message: error.message });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
keys.push({
|
|
155
|
+
path,
|
|
156
|
+
bucket: unit ? unit.toLowerCase() : undefined,
|
|
157
|
+
alias: alias || undefined,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (!keys.length && !errors.length) {
|
|
161
|
+
errors.push({ message: '`by` needs something after it — a column, or `month <date column>`.' });
|
|
162
|
+
}
|
|
163
|
+
/* The names this breakdown will answer under, so `sort billed desc` parses.
|
|
164
|
+
Derived the same way the compiler derives them, because a sort that
|
|
165
|
+
validates against one spelling and compiles against another is a sort
|
|
166
|
+
that works until somebody adds an alias. */
|
|
167
|
+
const names = [
|
|
168
|
+
...keys.map((k) => k.alias?.trim() || (k.bucket ? `${k.bucket}(${k.path})` : k.path)),
|
|
169
|
+
...measures.map((m) => m.alias?.trim()
|
|
170
|
+
|| (m.fn === 'count' ? (m.path ? `count(${m.path})` : 'count') : `${m.fn}(${m.path ?? ''})`)),
|
|
171
|
+
];
|
|
172
|
+
const parsed = parseViewQuery(schema, base.id, `${prefix} ${tail}`.trim(), options.now, {
|
|
173
|
+
/* Every output name joins the synthetic table for the sort clause. A
|
|
174
|
+
breakdown has no `show`, so nothing else here can reach them. */
|
|
175
|
+
aggregateAliases: names,
|
|
176
|
+
});
|
|
177
|
+
errors.push(...parsed.errors);
|
|
178
|
+
if (parsed.columns.length) {
|
|
179
|
+
errors.push({
|
|
180
|
+
message: '`show` does not apply to a breakdown — its columns are the things it groups by and measures.',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const orderBy = parsed.orderBy.map((o) => ({ name: o.path, direction: o.direction }));
|
|
184
|
+
const breakdown = {
|
|
185
|
+
base: base.id,
|
|
186
|
+
by: keys,
|
|
187
|
+
measures,
|
|
188
|
+
filter: parsed.filter.groups.length ? parsed.filter : undefined,
|
|
189
|
+
orderBy: orderBy.length ? orderBy : undefined,
|
|
190
|
+
limit: parsed.limit,
|
|
191
|
+
text: body,
|
|
192
|
+
};
|
|
193
|
+
return { breakdown: errors.length ? undefined : breakdown, errors };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Render a breakdown as text — the inverse, and the round trip is tested.
|
|
197
|
+
*
|
|
198
|
+
* One line, unlike a view. A breakdown is a phrase: two measures and a key is
|
|
199
|
+
* the normal size of one, and the whole point of the form is that it fits in
|
|
200
|
+
* a query bar.
|
|
201
|
+
*/
|
|
202
|
+
export function breakdownToText(schema, def) {
|
|
203
|
+
const base = findTable(schema, def.base);
|
|
204
|
+
const parts = [base?.name ?? def.base];
|
|
205
|
+
const conditions = (def.filter?.groups ?? []).filter((g) => g.length);
|
|
206
|
+
if (conditions.length) {
|
|
207
|
+
parts.push('where');
|
|
208
|
+
parts.push(conditions
|
|
209
|
+
.map((group) => group.map(conditionToText).join(' and '))
|
|
210
|
+
.join(' or '));
|
|
211
|
+
}
|
|
212
|
+
parts.push(def.measures.map(measureToText).join(', '));
|
|
213
|
+
parts.push('by');
|
|
214
|
+
parts.push(def.by.map(keyToText).join(', '));
|
|
215
|
+
if (def.orderBy?.length) {
|
|
216
|
+
parts.push('sort');
|
|
217
|
+
parts.push(def.orderBy.map((o) => `${o.name}${o.direction === 'desc' ? ' desc' : ''}`).join(', '));
|
|
218
|
+
}
|
|
219
|
+
if (def.limit !== undefined)
|
|
220
|
+
parts.push(`limit ${def.limit}`);
|
|
221
|
+
return parts.join(' ');
|
|
222
|
+
}
|
|
223
|
+
const measureToText = (m) => {
|
|
224
|
+
const words = [m.fn];
|
|
225
|
+
if (m.distinct)
|
|
226
|
+
words.push('distinct');
|
|
227
|
+
if (m.path)
|
|
228
|
+
words.push(m.path);
|
|
229
|
+
if (m.alias)
|
|
230
|
+
words.push('as', m.alias);
|
|
231
|
+
return words.join(' ');
|
|
232
|
+
};
|
|
233
|
+
const keyToText = (k) => {
|
|
234
|
+
const words = [];
|
|
235
|
+
if (k.bucket)
|
|
236
|
+
words.push(k.bucket);
|
|
237
|
+
words.push(k.path);
|
|
238
|
+
if (k.alias)
|
|
239
|
+
words.push('as', k.alias);
|
|
240
|
+
return words.join(' ');
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* One condition, back in words.
|
|
244
|
+
*
|
|
245
|
+
* Deliberately small: the operators a breakdown's filter uses are the ones the
|
|
246
|
+
* query language spells the same way in both directions. Anything else is
|
|
247
|
+
* rendered by its own name, which round-trips because that is how it parses.
|
|
248
|
+
*/
|
|
249
|
+
function conditionToText(c) {
|
|
250
|
+
if (c.op === 'isNull')
|
|
251
|
+
return `${c.path} is null`;
|
|
252
|
+
if (c.op === 'isNotNull')
|
|
253
|
+
return `${c.path} is not null`;
|
|
254
|
+
const value = Array.isArray(c.value) ? c.value.join(', ') : String(c.value ?? '');
|
|
255
|
+
const spelled = c.op === '=' ? '=' : c.op;
|
|
256
|
+
return `${c.path} ${spelled} ${value}`;
|
|
257
|
+
}
|