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,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages: a record layout with a front door.
|
|
3
|
+
*
|
|
4
|
+
* A record template lays out a row you have already found. A page starts one
|
|
5
|
+
* step earlier — it asks *which* customer, and then shows everything that
|
|
6
|
+
* customer is: their own fields, how many orders they have placed, what those
|
|
7
|
+
* orders were, and the lines inside those orders. One screen assembled from
|
|
8
|
+
* one root, instead of a walk you have to remember the way back through.
|
|
9
|
+
*
|
|
10
|
+
* The content model is deliberately the record template's, extended rather
|
|
11
|
+
* than replaced. Two systems that both lay out sections keyed by foreign
|
|
12
|
+
* key would drift within a month, and the second one would be the one that
|
|
13
|
+
* forgot composite keys. What pages add is:
|
|
14
|
+
*
|
|
15
|
+
* - **metric** sections — one number, from an aggregate over a relationship
|
|
16
|
+
* - **lists that reach further than one hop**, via a path rather than a
|
|
17
|
+
* single constraint, so "the lines of this customer's orders" is a
|
|
18
|
+
* section rather than two clicks
|
|
19
|
+
* - **a filter on a list**, so "recent open orders" is a section and not
|
|
20
|
+
* the whole of them
|
|
21
|
+
* - **a width**, so three counts sit in a row instead of stacked
|
|
22
|
+
*
|
|
23
|
+
* Everything here is pure: parsing, validating, and turning a page plus a
|
|
24
|
+
* root key into the requests its sections need. Nothing fetches. That is what
|
|
25
|
+
* lets the hard part — path resolution against a real schema, which is where
|
|
26
|
+
* the mistakes live — be tested without a browser or a database.
|
|
27
|
+
*/
|
|
28
|
+
import { findTable, labelColumn, primaryKey, referencesTo, } from './schema.js';
|
|
29
|
+
import { MAX_PATH_DEPTH, parseViewQuery } from './view.js';
|
|
30
|
+
import { quoteValue } from './query.js';
|
|
31
|
+
export const WIDTHS = ['full', 'half', 'third'];
|
|
32
|
+
export const OFFSETS = ['quarter', 'third', 'half', 'two-thirds'];
|
|
33
|
+
export const FIELD_COLUMNS = [1, 2, 3];
|
|
34
|
+
/** Rows a list section shows before it needs a page of its own. */
|
|
35
|
+
export const DEFAULT_PAGE_LIST_LIMIT = 10;
|
|
36
|
+
/** Sections a page may hold. A page is a screen, not a report. */
|
|
37
|
+
export const MAX_SECTIONS = 24;
|
|
38
|
+
/* How a suggested page divides the relationships pointing at its root: the
|
|
39
|
+
first few as lists, the next few as counts, the rest not shown. A page that
|
|
40
|
+
listed all twelve things pointing at `customer` would be a report. */
|
|
41
|
+
const LISTED = 2;
|
|
42
|
+
const COUNTED = 3;
|
|
43
|
+
/**
|
|
44
|
+
* Follow `a.b.c` from a table, hop by hop, and say where it lands.
|
|
45
|
+
*
|
|
46
|
+
* Each segment names a foreign key *on the current table* — by constraint
|
|
47
|
+
* name when one is given, otherwise by its column. Constraint name first,
|
|
48
|
+
* because a table can point at the same parent twice and `parent_id` and
|
|
49
|
+
* `supersedes_id` are two different paths that a column-only lookup would
|
|
50
|
+
* happily confuse.
|
|
51
|
+
*/
|
|
52
|
+
export function resolvePath(schema, from, path) {
|
|
53
|
+
const segments = path.split('.').map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
if (!segments.length)
|
|
55
|
+
return { error: 'A path cannot be empty.' };
|
|
56
|
+
if (segments.length > MAX_PATH_DEPTH) {
|
|
57
|
+
return { error: `"${path}" follows more than ${MAX_PATH_DEPTH} references.` };
|
|
58
|
+
}
|
|
59
|
+
let current = from;
|
|
60
|
+
const hops = [];
|
|
61
|
+
for (const segment of segments) {
|
|
62
|
+
const table = findTable(schema, current);
|
|
63
|
+
if (!table)
|
|
64
|
+
return { error: `"${current}" is not a table on this connection.` };
|
|
65
|
+
const outgoing = schema.foreignKeys.filter((fk) => fk.from.table === table.id);
|
|
66
|
+
const hop = outgoing.find((fk) => fk.name === segment)
|
|
67
|
+
?? outgoing.find((fk) => fk.from.columns.length === 1 && fk.from.columns[0] === segment)
|
|
68
|
+
?? outgoing.find((fk) => fk.from.columns.join(',') === segment);
|
|
69
|
+
if (!hop) {
|
|
70
|
+
return {
|
|
71
|
+
error: `"${table.name}" has no reference called "${segment}".`
|
|
72
|
+
+ (outgoing.length
|
|
73
|
+
? ` It points at: ${outgoing.map((fk) => fk.from.columns.join('+')).join(', ')}.`
|
|
74
|
+
: ' It points at nothing.'),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
hops.push(hop);
|
|
78
|
+
current = hop.to.table;
|
|
79
|
+
}
|
|
80
|
+
return { path: { hops, endsAt: current } };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The filter that ties a section to one root record.
|
|
84
|
+
*
|
|
85
|
+
* Built as a structured `ViewFilter` rather than as text, for the reason the
|
|
86
|
+
* whole tool is built that way: a key value that happens to contain a quote
|
|
87
|
+
* or the word `and` is a value, and text would make it grammar.
|
|
88
|
+
*
|
|
89
|
+
* The path is written into the column, so the compiler does the joining —
|
|
90
|
+
* `invoice_id.customer_id` is a path it already knows how to walk, and the
|
|
91
|
+
* one place that knows how to join is better than a second one here.
|
|
92
|
+
*/
|
|
93
|
+
export function rootFilter(path, key, resolved) {
|
|
94
|
+
const last = resolved.hops[resolved.hops.length - 1];
|
|
95
|
+
const prefix = path.split('.').slice(0, -1).join('.');
|
|
96
|
+
/* Every column of the root's key, compared through the same path. A
|
|
97
|
+
composite key compared on one column matches every row that shares it,
|
|
98
|
+
which on `invoice_line` is most of the table. */
|
|
99
|
+
const conditions = last.to.columns.map((rootColumn, i) => {
|
|
100
|
+
const childColumn = last.from.columns[i] ?? last.from.columns[0];
|
|
101
|
+
return {
|
|
102
|
+
path: prefix ? `${prefix}.${childColumn}` : childColumn,
|
|
103
|
+
op: '=',
|
|
104
|
+
value: key[rootColumn],
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return { groups: [conditions] };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a section's `of` — the forward reference naming which record the
|
|
111
|
+
* section is about.
|
|
112
|
+
*
|
|
113
|
+
* One hop, deliberately. `of: 'site_id.address_id'` is expressible in the
|
|
114
|
+
* type and refused here: each extra hop is another row the planner must read
|
|
115
|
+
* before any section can run, and nobody has yet asked for a section about
|
|
116
|
+
* the address of the site of the record they are looking at. The error says
|
|
117
|
+
* the limit rather than pretending the path is malformed.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveOf(schema, baseId, of) {
|
|
120
|
+
const resolved = resolvePath(schema, baseId, of);
|
|
121
|
+
if (resolved.error)
|
|
122
|
+
return { error: resolved.error };
|
|
123
|
+
if (resolved.path.hops.length !== 1) {
|
|
124
|
+
return { error: `"of" reaches one reference from ${baseId}; "${of}" is ${resolved.path.hops.length} hops.` };
|
|
125
|
+
}
|
|
126
|
+
const fk = resolved.path.hops[0];
|
|
127
|
+
const table = findTable(schema, resolved.path.endsAt);
|
|
128
|
+
if (!table)
|
|
129
|
+
return { error: `"${of}" leads to a table this connection does not have.` };
|
|
130
|
+
return { fk, table };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The section's own narrowing, ANDed onto the root's.
|
|
134
|
+
*
|
|
135
|
+
* `filter` has been in the model and in the config parser since the start and
|
|
136
|
+
* was never applied: a page saying `"filter": "status = open"` listed every
|
|
137
|
+
* order, silently. Documented, accepted, ignored — the worst shape a bug can
|
|
138
|
+
* take, because the file says the behaviour is there.
|
|
139
|
+
*
|
|
140
|
+
* Written in the query language and parsed against the *child*, which is what
|
|
141
|
+
* "recent open orders" means: the condition is about the order, not about the
|
|
142
|
+
* customer the page is on. Errors come back rather than throwing, so a
|
|
143
|
+
* mistyped filter names its section instead of blanking the page.
|
|
144
|
+
*/
|
|
145
|
+
function withSectionFilter(schema, from, root, filter, now) {
|
|
146
|
+
if (!filter)
|
|
147
|
+
return { filter: root };
|
|
148
|
+
const parsed = parseViewQuery(schema, from, filter, now);
|
|
149
|
+
if (parsed.errors.length)
|
|
150
|
+
return { error: parsed.errors[0].message };
|
|
151
|
+
const extra = parsed.filter.groups;
|
|
152
|
+
if (!extra.length)
|
|
153
|
+
return { filter: root };
|
|
154
|
+
/* ANDed into every root group rather than appended as another group: groups
|
|
155
|
+
are OR-ed, so appending would have widened the result instead of
|
|
156
|
+
narrowing it — a filter that added rows. With one root group, which is
|
|
157
|
+
what `rootFilter` always builds, this is the ordinary case of "and also".
|
|
158
|
+
|
|
159
|
+
A filter with its own OR becomes a product, which is what AND-of-ORs
|
|
160
|
+
means when both sides have alternatives. */
|
|
161
|
+
const groups = [];
|
|
162
|
+
for (const rootGroup of root.groups) {
|
|
163
|
+
for (const extraGroup of extra)
|
|
164
|
+
groups.push([...rootGroup, ...extraGroup]);
|
|
165
|
+
}
|
|
166
|
+
return { filter: { groups } };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* What a list section needs to run, as a view the existing compiler can take.
|
|
170
|
+
*
|
|
171
|
+
* A page adds no query machinery of its own. Every section is a view — that
|
|
172
|
+
* is the point of having built one — so a page is an arrangement of views
|
|
173
|
+
* around a shared root rather than a second way to ask a database questions.
|
|
174
|
+
*/
|
|
175
|
+
export function listView(schema, page, section, key, index, now, ofKeys) {
|
|
176
|
+
const root = sectionRoot(schema, page, section, key, ofKeys);
|
|
177
|
+
if (root.error)
|
|
178
|
+
return { error: root.error };
|
|
179
|
+
if (root.empty)
|
|
180
|
+
return { empty: true };
|
|
181
|
+
const resolved = resolvePath(schema, section.from, section.path);
|
|
182
|
+
if (resolved.error)
|
|
183
|
+
return { error: resolved.error };
|
|
184
|
+
if (resolved.path.endsAt !== root.table) {
|
|
185
|
+
return {
|
|
186
|
+
error: `"${section.path}" leads from ${section.from} to `
|
|
187
|
+
+ `${resolved.path.endsAt}, not to ${root.table}.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const table = findTable(schema, section.from);
|
|
191
|
+
if (!table)
|
|
192
|
+
return { error: `"${section.from}" is not a table on this connection.` };
|
|
193
|
+
const columns = (section.columns?.length
|
|
194
|
+
? section.columns
|
|
195
|
+
: defaultColumns(table, section.path, resolved.path)).map((p) => ({ path: p }));
|
|
196
|
+
const narrowed = withSectionFilter(schema, table.id, rootFilter(section.path, root.key, resolved.path), section.filter, now);
|
|
197
|
+
if (narrowed.error)
|
|
198
|
+
return { error: narrowed.error };
|
|
199
|
+
return {
|
|
200
|
+
view: {
|
|
201
|
+
id: `page:${page.id}:${index}`,
|
|
202
|
+
name: section.title ?? table.name,
|
|
203
|
+
base: table.id,
|
|
204
|
+
columns,
|
|
205
|
+
filter: narrowed.filter,
|
|
206
|
+
orderBy: section.orderBy?.map((o) => ({ path: o.column, direction: o.direction })),
|
|
207
|
+
limit: section.limit ?? DEFAULT_PAGE_LIST_LIMIT,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The record a section is actually about: the root, or — through `of` — a
|
|
213
|
+
* record the root points at, whose key the planner has already read.
|
|
214
|
+
*/
|
|
215
|
+
function sectionRoot(schema, page, section, key, ofKeys) {
|
|
216
|
+
if (!section.of)
|
|
217
|
+
return { table: page.base, key };
|
|
218
|
+
const of = resolveOf(schema, page.base, section.of);
|
|
219
|
+
if (of.error)
|
|
220
|
+
return { error: of.error };
|
|
221
|
+
const referenced = ofKeys?.[section.of];
|
|
222
|
+
if (referenced === undefined) {
|
|
223
|
+
/* The planner read the root row before building sections, so a missing
|
|
224
|
+
entry is the caller's bug, not the page's. Said as what it is. */
|
|
225
|
+
return { error: `The key for "${section.of}" was not resolved before planning.` };
|
|
226
|
+
}
|
|
227
|
+
if (referenced === null)
|
|
228
|
+
return { empty: true };
|
|
229
|
+
return { table: of.table.id, key: referenced };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* What a metric section needs to run.
|
|
233
|
+
*
|
|
234
|
+
* Expressed as a one-row view over the *root* table with a single aggregate,
|
|
235
|
+
* rather than as a count over the child: the aggregate machinery already
|
|
236
|
+
* guarantees that two counts over different children cannot inflate each
|
|
237
|
+
* other, and doing it any other way here would give up that guarantee for a
|
|
238
|
+
* number nobody would think to check.
|
|
239
|
+
*/
|
|
240
|
+
/**
|
|
241
|
+
* How the query bar would say what a section shows — when it can say it.
|
|
242
|
+
*
|
|
243
|
+
* A count on a page is a number, and a number you cannot get behind is a
|
|
244
|
+
* number you have to take on trust. Clicking one should open the rows it
|
|
245
|
+
* counted, narrowed the same way — and for a long time it opened the child
|
|
246
|
+
* table with no filter at all, because the plan carried no query and the
|
|
247
|
+
* client fell back to the table's own name. The count said 3 and the grid
|
|
248
|
+
* said 20.
|
|
249
|
+
*
|
|
250
|
+
* The query language has no dotted paths: `invoice_line invoice_id.customer_id
|
|
251
|
+
* = 4` is not a thing it can parse, and a page two hops out produces exactly
|
|
252
|
+
* that. So this answers `undefined` rather than something that will not run —
|
|
253
|
+
* the caller opens the composer on the view itself in that case, which can
|
|
254
|
+
* say it. The distinction is deliberate: it is better to be told the language
|
|
255
|
+
* cannot express this than to be shown a filter that quietly is not the one.
|
|
256
|
+
*
|
|
257
|
+
* `is empty` for a null test, because that is the grammar's spelling; `in` is
|
|
258
|
+
* left out, since a list needs bracket syntax the parser writes differently
|
|
259
|
+
* from how it reads it.
|
|
260
|
+
*/
|
|
261
|
+
const QUERY_OPS = {
|
|
262
|
+
'=': '=',
|
|
263
|
+
'!=': '!=',
|
|
264
|
+
'<': '<',
|
|
265
|
+
'<=': '<=',
|
|
266
|
+
'>': '>',
|
|
267
|
+
'>=': '>=',
|
|
268
|
+
contains: 'contains',
|
|
269
|
+
startsWith: 'startswith',
|
|
270
|
+
endsWith: 'endswith',
|
|
271
|
+
};
|
|
272
|
+
export function sectionQuery(view) {
|
|
273
|
+
if (!view?.base)
|
|
274
|
+
return undefined;
|
|
275
|
+
const groups = view.filter?.groups?.filter((g) => g.length) ?? [];
|
|
276
|
+
if (!groups.length)
|
|
277
|
+
return view.base;
|
|
278
|
+
const parts = [];
|
|
279
|
+
for (const group of groups) {
|
|
280
|
+
const conditions = [];
|
|
281
|
+
for (const c of group) {
|
|
282
|
+
/* A path, not a column: the language filters the table in front of it
|
|
283
|
+
and nothing further out. */
|
|
284
|
+
if (!c.path || c.path.includes('.'))
|
|
285
|
+
return undefined;
|
|
286
|
+
if (c.op === 'isNull' || c.op === 'isNotNull') {
|
|
287
|
+
conditions.push(`${c.path} is ${c.op === 'isNull' ? '' : 'not '}empty`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const op = QUERY_OPS[c.op];
|
|
291
|
+
if (!op || c.value === undefined || c.value === null || Array.isArray(c.value))
|
|
292
|
+
return undefined;
|
|
293
|
+
conditions.push(`${c.path} ${op} ${quoteValue(c.value)}`);
|
|
294
|
+
}
|
|
295
|
+
parts.push(conditions.join(' and '));
|
|
296
|
+
}
|
|
297
|
+
return `${view.base} ${parts.join(' or ')}`;
|
|
298
|
+
}
|
|
299
|
+
export function metricView(schema, page, section, key, index, now, ofKeys) {
|
|
300
|
+
const root = sectionRoot(schema, page, section, key, ofKeys);
|
|
301
|
+
if (root.error)
|
|
302
|
+
return { error: root.error };
|
|
303
|
+
if (root.empty)
|
|
304
|
+
return { empty: true, counts: section.fn === 'count' };
|
|
305
|
+
const resolved = resolvePath(schema, section.from, section.path);
|
|
306
|
+
if (resolved.error)
|
|
307
|
+
return { error: resolved.error };
|
|
308
|
+
if (resolved.path.endsAt !== root.table) {
|
|
309
|
+
return {
|
|
310
|
+
error: `"${section.path}" leads from ${section.from} to `
|
|
311
|
+
+ `${resolved.path.endsAt}, not to ${root.table}.`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (section.fn !== 'count' && !section.column) {
|
|
315
|
+
return { error: `"${section.fn}" needs a column to ${section.fn}.` };
|
|
316
|
+
}
|
|
317
|
+
const table = findTable(schema, section.from);
|
|
318
|
+
if (!table)
|
|
319
|
+
return { error: `"${section.from}" is not a table on this connection.` };
|
|
320
|
+
if (section.column && !table.columns.some((c) => c.name === section.column)) {
|
|
321
|
+
return { error: `"${table.name}" has no column called "${section.column}".` };
|
|
322
|
+
}
|
|
323
|
+
/* The record this section is about — the page's base, or the table `of`
|
|
324
|
+
reaches. The aggregate view below anchors on it, so a sum "of its site"
|
|
325
|
+
is a one-row view over site exactly as a sum over the root is one over
|
|
326
|
+
the root. */
|
|
327
|
+
const base = findTable(schema, root.table);
|
|
328
|
+
if (!base)
|
|
329
|
+
return { error: `"${root.table}" is not a table on this connection.` };
|
|
330
|
+
/**
|
|
331
|
+
* A count is a filtered count of the child, not an aggregate on the root.
|
|
332
|
+
*
|
|
333
|
+
* It used to be an aggregate, with `via` taken from the *first* hop — which
|
|
334
|
+
* is the right constraint only when there is one. For anything further out
|
|
335
|
+
* the first hop names a key that does not point at the root at all: on a
|
|
336
|
+
* customer page, `invoice_line via invoice_id.customer_id` handed the
|
|
337
|
+
* aggregate `line_invoice_fk`, which joins lines to invoices and knows
|
|
338
|
+
* nothing about customers. The number that came back was not the number
|
|
339
|
+
* asked for, and looked like one.
|
|
340
|
+
*
|
|
341
|
+
* Counting the child directly is correct at any depth, and it is what the
|
|
342
|
+
* list beside it already does — so a count and the list it summarises can
|
|
343
|
+
* no longer disagree. The view returns one row and its `total`, which is
|
|
344
|
+
* exact: a view only ever joins to-one, so counting it is counting the
|
|
345
|
+
* table it started from.
|
|
346
|
+
*/
|
|
347
|
+
if (section.fn === 'count') {
|
|
348
|
+
const narrowed = withSectionFilter(schema, table.id, rootFilter(section.path, root.key, resolved.path), section.filter, now);
|
|
349
|
+
if (narrowed.error)
|
|
350
|
+
return { error: narrowed.error };
|
|
351
|
+
return {
|
|
352
|
+
counts: true,
|
|
353
|
+
view: {
|
|
354
|
+
id: `page:${page.id}:${index}`,
|
|
355
|
+
name: section.title ?? table.name,
|
|
356
|
+
base: table.id,
|
|
357
|
+
columns: (primaryKey(table)[0] ? [primaryKey(table)[0]] : [table.columns[0].name])
|
|
358
|
+
.map((path) => ({ path })),
|
|
359
|
+
filter: narrowed.filter,
|
|
360
|
+
limit: 1,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/* `sum`, `avg`, `min` and `max` still go through the aggregate compiler,
|
|
365
|
+
which is the only thing that can produce them — and which reaches exactly
|
|
366
|
+
one hop. Refused rather than answered wrongly: a sum over a relationship
|
|
367
|
+
it cannot express would be a number with no way to tell it was the wrong
|
|
368
|
+
one. */
|
|
369
|
+
if (resolved.path.hops.length > 1) {
|
|
370
|
+
return {
|
|
371
|
+
error: `"${section.fn}" only works on something that points straight at `
|
|
372
|
+
+ `${base.name}. ${table.name} is ${resolved.path.hops.length} references away — `
|
|
373
|
+
+ 'count it instead, or add the column to the table in between.',
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (section.filter) {
|
|
377
|
+
/* The aggregate compiler takes no filter of its own, and quietly dropping
|
|
378
|
+
one would be the bug this whole change is about. */
|
|
379
|
+
return {
|
|
380
|
+
error: `A "${section.fn}" cannot be narrowed by a filter. `
|
|
381
|
+
+ 'Count it instead, or make a list and read the total.',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
view: {
|
|
386
|
+
id: `page:${page.id}:${index}`,
|
|
387
|
+
name: section.title ?? `${section.fn} of ${table.name}`,
|
|
388
|
+
base: base.id,
|
|
389
|
+
columns: primaryKey(base).map((c) => ({ path: c })),
|
|
390
|
+
aggregates: [{
|
|
391
|
+
fn: section.fn,
|
|
392
|
+
via: resolved.path.hops[0].name,
|
|
393
|
+
column: section.column,
|
|
394
|
+
alias: 'value',
|
|
395
|
+
}],
|
|
396
|
+
filter: { groups: [primaryKey(base).map((c) => ({ path: c, op: '=', value: root.key[c] }))] },
|
|
397
|
+
limit: 1,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Columns worth showing in a list nobody has configured.
|
|
403
|
+
*
|
|
404
|
+
* The path back to the root is dropped: every row in the section has the same
|
|
405
|
+
* value for it, so a column of forty identical numbers is the one column that
|
|
406
|
+
* cannot tell two rows apart. The key and the label come first, because they
|
|
407
|
+
* are what a row is called.
|
|
408
|
+
*/
|
|
409
|
+
export function defaultColumns(table, path, resolved) {
|
|
410
|
+
/* The columns the first hop actually uses, not the segment that named it.
|
|
411
|
+
|
|
412
|
+
A path segment can be a constraint name — which is how a suggested page
|
|
413
|
+
writes it, and the only way to name a composite hop — so splitting the
|
|
414
|
+
path for a column name found `address_country_fk`, matched nothing, and
|
|
415
|
+
left `country_code` in the table: a column reading NZ on every row of a
|
|
416
|
+
section that is *about* NZ. The one column that cannot tell two rows
|
|
417
|
+
apart, kept in the one place it is guaranteed useless. */
|
|
418
|
+
const pathHead = resolved
|
|
419
|
+
? resolved.hops[0].from.columns
|
|
420
|
+
: [path.split('.')[0]];
|
|
421
|
+
const key = primaryKey(table);
|
|
422
|
+
const label = labelColumn(table);
|
|
423
|
+
const ordered = [
|
|
424
|
+
...key,
|
|
425
|
+
...(label && !key.includes(label) ? [label] : []),
|
|
426
|
+
...table.columns.map((c) => c.name).filter((n) => !key.includes(n) && n !== label),
|
|
427
|
+
];
|
|
428
|
+
return ordered.filter((name) => !pathHead.includes(name)).slice(0, 6);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Check a page against a real schema, and say what is wrong with it.
|
|
432
|
+
*
|
|
433
|
+
* Returns the page with an `error` rather than throwing, because a page that
|
|
434
|
+
* names a table this connection does not have is still a page — it belongs
|
|
435
|
+
* in the list, greyed, with its reason. A config file shared by a team will
|
|
436
|
+
* always describe some connection someone is not on.
|
|
437
|
+
*/
|
|
438
|
+
export function validatePage(schema, page) {
|
|
439
|
+
const base = findTable(schema, page.base);
|
|
440
|
+
if (!base)
|
|
441
|
+
return { ...page, error: `"${page.base}" is not a table on this connection.` };
|
|
442
|
+
if (!primaryKey(base).length) {
|
|
443
|
+
return {
|
|
444
|
+
...page,
|
|
445
|
+
error: `"${base.name}" has no primary key, so a single record cannot be identified.`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
for (const [i, section] of page.sections.entries()) {
|
|
449
|
+
const at = section.title ? `"${section.title}"` : `section ${i + 1}`;
|
|
450
|
+
if (section.kind === 'fields')
|
|
451
|
+
continue;
|
|
452
|
+
let rootTable = page.base;
|
|
453
|
+
if (section.of) {
|
|
454
|
+
const of = resolveOf(schema, page.base, section.of);
|
|
455
|
+
if (of.error)
|
|
456
|
+
return { ...page, error: `${at}: ${of.error}` };
|
|
457
|
+
rootTable = of.table.id;
|
|
458
|
+
}
|
|
459
|
+
const resolved = resolvePath(schema, section.from, section.path);
|
|
460
|
+
if (resolved.error)
|
|
461
|
+
return { ...page, error: `${at}: ${resolved.error}` };
|
|
462
|
+
if (resolved.path.endsAt !== rootTable) {
|
|
463
|
+
return {
|
|
464
|
+
...page,
|
|
465
|
+
error: `${at}: "${section.path}" leads from ${section.from} to `
|
|
466
|
+
+ `${resolved.path.endsAt}, not to ${rootTable}.`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (section.kind === 'metric' && section.fn !== 'count' && !section.column) {
|
|
470
|
+
return { ...page, error: `${at}: "${section.fn}" needs a column to ${section.fn}.` };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { ...page, error: undefined };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* A page for a table nobody has written one for.
|
|
477
|
+
*
|
|
478
|
+
* Not a placeholder: it is the page most people would have built by hand —
|
|
479
|
+
* the record, a count of each thing that points at it, and the two biggest of
|
|
480
|
+
* those as lists. Offering it means a page exists for every table on the
|
|
481
|
+
* first run, which is the difference between a feature people try and a
|
|
482
|
+
* feature people configure first.
|
|
483
|
+
*/
|
|
484
|
+
export function suggestPage(schema, baseId) {
|
|
485
|
+
const base = findTable(schema, baseId);
|
|
486
|
+
if (!base || !primaryKey(base).length)
|
|
487
|
+
return null;
|
|
488
|
+
const incoming = referencesTo(schema, base.id);
|
|
489
|
+
const sections = [{ kind: 'fields', width: 'full' }];
|
|
490
|
+
/* Lists for the first few, counts for the rest — never both for the same
|
|
491
|
+
relationship.
|
|
492
|
+
|
|
493
|
+
A count above a list of the rows it counted says nothing the list does
|
|
494
|
+
not: `ROOM 2` over a table of two rooms is a number and its own proof,
|
|
495
|
+
taking a card to do it. The count belongs *on* the list, where it says
|
|
496
|
+
how many there are beyond the ones shown; a card earns its place only for
|
|
497
|
+
a relationship you cannot see, which is what the ones past the listed
|
|
498
|
+
few are. Show the big ones, count the rest. */
|
|
499
|
+
const listed = incoming.slice(0, LISTED);
|
|
500
|
+
const counted = incoming.slice(LISTED, LISTED + COUNTED);
|
|
501
|
+
for (const fk of counted) {
|
|
502
|
+
const child = findTable(schema, fk.from.table);
|
|
503
|
+
if (!child)
|
|
504
|
+
continue;
|
|
505
|
+
sections.push({
|
|
506
|
+
kind: 'metric',
|
|
507
|
+
fn: 'count',
|
|
508
|
+
from: child.id,
|
|
509
|
+
path: fk.name,
|
|
510
|
+
title: metricTitle(counted, fk, child.name, base.id),
|
|
511
|
+
width: 'third',
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
for (const fk of listed) {
|
|
515
|
+
const child = findTable(schema, fk.from.table);
|
|
516
|
+
if (!child)
|
|
517
|
+
continue;
|
|
518
|
+
sections.push({
|
|
519
|
+
kind: 'list',
|
|
520
|
+
from: child.id,
|
|
521
|
+
path: fk.name,
|
|
522
|
+
title: metricTitle(listed, fk, child.name, base.id),
|
|
523
|
+
limit: DEFAULT_PAGE_LIST_LIMIT,
|
|
524
|
+
width: listed.length > 1 ? 'half' : 'full',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
id: `suggested:${base.id}`,
|
|
529
|
+
name: base.name,
|
|
530
|
+
base: base.id,
|
|
531
|
+
sections,
|
|
532
|
+
source: 'suggested',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* What to call a count.
|
|
537
|
+
*
|
|
538
|
+
* The child's name, which is what someone reading a summary expects — until
|
|
539
|
+
* that is not enough to tell two counts apart. A table pointing at the same
|
|
540
|
+
* parent twice gives two counts both labelled `work_order`, and a
|
|
541
|
+
* self-reference gives a count labelled with the table you are already on:
|
|
542
|
+
* `employee` on an employee page, which is the count of their reports and
|
|
543
|
+
* says nothing of the sort. Both cases are named by their foreign key
|
|
544
|
+
* instead, which is the thing that actually differs.
|
|
545
|
+
*/
|
|
546
|
+
function metricTitle(chosen, fk, childName, baseId) {
|
|
547
|
+
const ambiguous = chosen.filter((other) => other.from.table === fk.from.table).length > 1;
|
|
548
|
+
const selfReference = fk.from.table === baseId;
|
|
549
|
+
return ambiguous || selfReference ? `${childName} via ${fk.from.columns.join(', ')}` : childName;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Every way a table could reach this root, within a couple of hops.
|
|
553
|
+
*
|
|
554
|
+
* The one field nobody guesses when writing a page by hand is the path —
|
|
555
|
+
* `invoice_id.customer_id` is obvious only once you already know it. So it is
|
|
556
|
+
* not typed: the schema is asked what actually points here, directly and one
|
|
557
|
+
* table further out, and each answer comes back with the path already
|
|
558
|
+
* written.
|
|
559
|
+
*
|
|
560
|
+
* Two hops rather than the full depth limit. Three is a real relationship and
|
|
561
|
+
* a list nobody can read: on a schema of this size it runs to hundreds of
|
|
562
|
+
* entries, most of them routes no one would choose, and a menu that long is a
|
|
563
|
+
* menu people close.
|
|
564
|
+
*/
|
|
565
|
+
export function relationsTo(schema, baseId, maxHops = 2) {
|
|
566
|
+
const base = findTable(schema, baseId);
|
|
567
|
+
if (!base)
|
|
568
|
+
return [];
|
|
569
|
+
const found = [];
|
|
570
|
+
const seen = new Set();
|
|
571
|
+
/** Every spelling of each path already built, so deeper hops can extend them. */
|
|
572
|
+
const aliasesOf = new Map([['', ['']]]);
|
|
573
|
+
const walk = (targetId, path, via, hops) => {
|
|
574
|
+
if (hops > maxHops)
|
|
575
|
+
return;
|
|
576
|
+
for (const fk of schema.foreignKeys) {
|
|
577
|
+
if (fk.to.table !== targetId)
|
|
578
|
+
continue;
|
|
579
|
+
const child = findTable(schema, fk.from.table);
|
|
580
|
+
if (!child)
|
|
581
|
+
continue;
|
|
582
|
+
/* Named by the constraint, always. A column name is shorter and works
|
|
583
|
+
until a table points at the same parent twice, or points at it with
|
|
584
|
+
two columns — and both are common enough that the readable spelling
|
|
585
|
+
is the one that breaks. */
|
|
586
|
+
const next = [fk.name, ...path];
|
|
587
|
+
const route = next.join('.');
|
|
588
|
+
const id = `${child.id}|${route}`;
|
|
589
|
+
if (seen.has(id))
|
|
590
|
+
continue;
|
|
591
|
+
seen.add(id);
|
|
592
|
+
const target = findTable(schema, targetId);
|
|
593
|
+
/* Both spellings of this hop, crossed with every spelling of the rest.
|
|
594
|
+
Bounded: two names per hop and two hops is four, and the depth cap
|
|
595
|
+
keeps it there. A composite key has only its constraint name, since
|
|
596
|
+
no single column identifies it. */
|
|
597
|
+
const hopNames = [fk.name];
|
|
598
|
+
if (fk.from.columns.length === 1)
|
|
599
|
+
hopNames.push(fk.from.columns[0]);
|
|
600
|
+
const tailAliases = aliasesOf.get(path.join('.')) ?? [path.join('.')];
|
|
601
|
+
const aliases = hopNames.flatMap((name) => tailAliases.map((tail) => (tail ? `${name}.${tail}` : name)));
|
|
602
|
+
aliasesOf.set(route, aliases);
|
|
603
|
+
found.push({
|
|
604
|
+
from: child.id,
|
|
605
|
+
path: route,
|
|
606
|
+
aliases,
|
|
607
|
+
label: child.name,
|
|
608
|
+
hops: hops + 1,
|
|
609
|
+
/* The route in the words on the screen: table names and the columns
|
|
610
|
+
that join them. A constraint name is the only reliable *identifier*
|
|
611
|
+
for a hop, which is why the path is written in them — and it is the
|
|
612
|
+
one thing nobody recognises, which is why it is not what is shown.
|
|
613
|
+
Innermost first, so it reads outward from the root. */
|
|
614
|
+
via: [`${child.name}.${fk.from.columns.join(', ')} \u2192 ${target?.name ?? targetId}`, ...via],
|
|
615
|
+
});
|
|
616
|
+
/* Not through the root, and not through itself. A self-referencing key
|
|
617
|
+
is a real relationship at one hop and an infinite regress past it. */
|
|
618
|
+
if (child.id !== baseId && child.id !== targetId) {
|
|
619
|
+
walk(child.id, next, [`${child.name}.${fk.from.columns.join(', ')} \u2192 ${findTable(schema, targetId)?.name ?? targetId}`, ...via], hops + 1);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
walk(base.id, [], [], 0);
|
|
624
|
+
/* Disambiguated after the fact rather than while walking, because whether
|
|
625
|
+
`invoice` needs qualifying depends on whether a *second* invoice route
|
|
626
|
+
turned up later.
|
|
627
|
+
|
|
628
|
+
Qualified by the columns, not by the path: `invoice via sold_by` is a
|
|
629
|
+
sentence someone can check against the schema in their head, where
|
|
630
|
+
`invoice via invoice_fk_2` is a string only the database has ever seen. */
|
|
631
|
+
/* Qualified only as far as it takes, and as far as it takes.
|
|
632
|
+
|
|
633
|
+
One pass was not enough. `work_log through ticket` was three entries on
|
|
634
|
+
the employee page — work_log reaches employee through ticket by
|
|
635
|
+
`assigned_to`, by `raised_by` and by `closed_by` — and all three were
|
|
636
|
+
rendered with the same words and the same detail line underneath. Three
|
|
637
|
+
identical rows in a menu is a menu you cannot use: whichever is picked,
|
|
638
|
+
nobody can tell which was picked.
|
|
639
|
+
|
|
640
|
+
So each name is refined until it is unique, one step at a time, and only
|
|
641
|
+
the ones that collide take the next step. `invoice` stays `invoice` where
|
|
642
|
+
nothing else is called that. */
|
|
643
|
+
const steps = new Map(found.map((r) => [r, 0]));
|
|
644
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
645
|
+
const groups = new Map();
|
|
646
|
+
for (const r of found) {
|
|
647
|
+
const label = qualify(r, steps.get(r) ?? 0);
|
|
648
|
+
const group = groups.get(label);
|
|
649
|
+
if (group)
|
|
650
|
+
group.push(r);
|
|
651
|
+
else
|
|
652
|
+
groups.set(label, [r]);
|
|
653
|
+
}
|
|
654
|
+
let refined = false;
|
|
655
|
+
for (const group of groups.values()) {
|
|
656
|
+
if (group.length < 2)
|
|
657
|
+
continue;
|
|
658
|
+
for (const r of group) {
|
|
659
|
+
steps.set(r, (steps.get(r) ?? 0) + 1);
|
|
660
|
+
refined = true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!refined)
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
for (const r of found)
|
|
667
|
+
r.label = qualify(r, steps.get(r) ?? 0);
|
|
668
|
+
return found.sort((a, b) => a.hops - b.hops || a.label.localeCompare(b.label));
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* The columns of one hop, out of the sentence it is written as.
|
|
672
|
+
*
|
|
673
|
+
* A `via` entry reads `work_log.ticket_id \u2192 ticket`, which is the form the
|
|
674
|
+
* screen wants; this is the one place that takes it apart again.
|
|
675
|
+
*/
|
|
676
|
+
const hopColumns = (via) => via?.split(' \u2192 ')[0]?.split('.').slice(1).join('.') ?? '';
|
|
677
|
+
const hopTarget = (via) => via?.split('\u2192 ')[1] ?? '\u2026';
|
|
678
|
+
/**
|
|
679
|
+
* A relationship's name at a given degree of qualification.
|
|
680
|
+
*
|
|
681
|
+
* Step 0 is the child table's name, which is right whenever nothing else on
|
|
682
|
+
* the page is called that. Each step after it adds the next thing that could
|
|
683
|
+
* tell two of them apart, in the order a reader would ask: which column, then
|
|
684
|
+
* which table in the middle, then which column *that* one reaches the root
|
|
685
|
+
* by. The last step falls back to the path — unreadable, and unique by
|
|
686
|
+
* construction, which is what a last step is for.
|
|
687
|
+
*/
|
|
688
|
+
function qualify(r, step) {
|
|
689
|
+
const name = r.label.split(' via ')[0].split(' through ')[0];
|
|
690
|
+
if (step <= 0)
|
|
691
|
+
return name;
|
|
692
|
+
if (r.hops === 1) {
|
|
693
|
+
return step === 1 ? `${name} via ${hopColumns(r.via[0])}` : `${name} (${r.path})`;
|
|
694
|
+
}
|
|
695
|
+
/* Two hops are told apart by the table in the middle — `contract_line
|
|
696
|
+
through contract` and `contract_line through asset` are two genuinely
|
|
697
|
+
different lists. Naming the root instead would label both of them
|
|
698
|
+
"through customer", which is the thing they have in common. */
|
|
699
|
+
if (step === 1)
|
|
700
|
+
return `${name} through ${hopTarget(r.via[0])}`;
|
|
701
|
+
/* And where the middle table is the same, by how *it* reaches the root:
|
|
702
|
+
`work_log through ticket.assigned_to` against `\u2026 through
|
|
703
|
+
ticket.raised_by`. `via` runs innermost first, so the hop that lands on
|
|
704
|
+
the root is the last one. */
|
|
705
|
+
if (step === 2 && r.hops === 2) {
|
|
706
|
+
/* Only at exactly two hops, where the table in the middle is also the one
|
|
707
|
+
holding the column that reaches the root. Three hops out they are
|
|
708
|
+
different tables, and `work_log through ticket.lead_employee_id` would
|
|
709
|
+
name a column that is on `team` — a sentence that reads as fact and is
|
|
710
|
+
not one. Those go straight to the full spelling below. */
|
|
711
|
+
return `${name} through ${hopTarget(r.via[0])}.${hopColumns(r.via[r.via.length - 1])}`;
|
|
712
|
+
}
|
|
713
|
+
if (step === 2 || step === 3) {
|
|
714
|
+
return `${name} through ${r.via.map((v) => hopColumns(v)).join(' \u2192 ')}`;
|
|
715
|
+
}
|
|
716
|
+
return `${name} (${r.path})`;
|
|
717
|
+
}
|
|
718
|
+
/** Columns of a table that a metric could summarise. `count` needs none. */
|
|
719
|
+
export function metricColumns(schema, tableId) {
|
|
720
|
+
const table = findTable(schema, tableId);
|
|
721
|
+
if (!table)
|
|
722
|
+
return [];
|
|
723
|
+
/* Only the numeric ones. `sum` of a name is not a question, and offering it
|
|
724
|
+
produces a page that fails at run time for a reason the builder knew at
|
|
725
|
+
build time. */
|
|
726
|
+
return table.columns
|
|
727
|
+
.filter((c) => /int|numeric|decimal|real|double|float|money|serial/i.test(c.type))
|
|
728
|
+
.map((c) => c.name);
|
|
729
|
+
}
|
|
730
|
+
/* ---------- config ---------- */
|
|
731
|
+
const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'page';
|
|
732
|
+
/**
|
|
733
|
+
* Read the `pages` section of a tablewalk.json.
|
|
734
|
+
*
|
|
735
|
+
* Throws on a malformed file rather than skipping the bad entry: a page that
|
|
736
|
+
* silently did not appear is a bug report that starts "I definitely wrote
|
|
737
|
+
* it". Everything a config file can get wrong is named with the page it is
|
|
738
|
+
* in and, where there is one, the fix.
|
|
739
|
+
*/
|
|
740
|
+
export function parsePagesConfig(raw, where) {
|
|
741
|
+
const pages = raw?.pages;
|
|
742
|
+
if (pages === undefined)
|
|
743
|
+
return [];
|
|
744
|
+
if (!Array.isArray(pages))
|
|
745
|
+
throw new Error(`${where}: "pages" must be an array.`);
|
|
746
|
+
const seen = new Set();
|
|
747
|
+
return pages.map((entry, i) => {
|
|
748
|
+
const at = `${where}: page ${i + 1}`;
|
|
749
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
750
|
+
throw new Error(`${at} is not an object.`);
|
|
751
|
+
}
|
|
752
|
+
const p = entry;
|
|
753
|
+
if (typeof p.name !== 'string' || !p.name.trim())
|
|
754
|
+
throw new Error(`${at} has no "name".`);
|
|
755
|
+
const name = p.name.trim();
|
|
756
|
+
if (typeof p.base !== 'string' || !p.base.trim()) {
|
|
757
|
+
throw new Error(`${at} ("${name}") has no "base" table.`);
|
|
758
|
+
}
|
|
759
|
+
const id = p.id === undefined ? `config-${slug(name)}` : String(p.id);
|
|
760
|
+
if (p.id !== undefined && (typeof p.id !== 'string' || !p.id.trim())) {
|
|
761
|
+
throw new Error(`${at} ("${name}") has an "id" that is not a non-empty string.`);
|
|
762
|
+
}
|
|
763
|
+
if (seen.has(id))
|
|
764
|
+
throw new Error(`${at} ("${name}") repeats the id "${id}".`);
|
|
765
|
+
seen.add(id);
|
|
766
|
+
if (p.connection !== undefined && typeof p.connection !== 'string') {
|
|
767
|
+
throw new Error(`${at} ("${name}") has a "connection" that is not a string.`);
|
|
768
|
+
}
|
|
769
|
+
/* Refused rather than coerced, like every other boolean in this file:
|
|
770
|
+
`"listTabs": "false"` is a truthy string. */
|
|
771
|
+
if (p.listTabs !== undefined && typeof p.listTabs !== 'boolean') {
|
|
772
|
+
throw new Error(`${at} ("${name}") has "listTabs": ${JSON.stringify(p.listTabs)}. It must be true or false, without quotes.`);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
id,
|
|
776
|
+
name,
|
|
777
|
+
base: p.base.trim(),
|
|
778
|
+
sections: parseSections(p.sections, at, name),
|
|
779
|
+
connection: p.connection,
|
|
780
|
+
/* Stored only when it is `false`, since `true` is the default and a
|
|
781
|
+
file full of `"listTabs": true` suggests the flag is doing
|
|
782
|
+
something. */
|
|
783
|
+
...(p.listTabs === false ? { listTabs: false } : {}),
|
|
784
|
+
source: 'config',
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
function parseSections(raw, at, name) {
|
|
789
|
+
if (raw === undefined)
|
|
790
|
+
return [{ kind: 'fields', width: 'full' }];
|
|
791
|
+
if (!Array.isArray(raw))
|
|
792
|
+
throw new Error(`${at} ("${name}") has "sections" that are not an array.`);
|
|
793
|
+
if (raw.length > MAX_SECTIONS) {
|
|
794
|
+
throw new Error(`${at} ("${name}") has more than ${MAX_SECTIONS} sections.`);
|
|
795
|
+
}
|
|
796
|
+
return raw.map((entry, i) => parseSection(entry, `${at} ("${name}") section ${i + 1}`));
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* A column count, refused rather than clamped.
|
|
800
|
+
*
|
|
801
|
+
* Clamping `"columns": 7` to 3 would render a layout nobody asked for and say
|
|
802
|
+
* nothing about it; the file is the thing being edited, so the file is where
|
|
803
|
+
* the mistake belongs.
|
|
804
|
+
*/
|
|
805
|
+
function parseFieldColumns(value, at) {
|
|
806
|
+
if (value === undefined || value === null)
|
|
807
|
+
return undefined;
|
|
808
|
+
const n = Number(value);
|
|
809
|
+
if (!FIELD_COLUMNS.includes(n)) {
|
|
810
|
+
throw new Error(`${at} has "columns" of ${JSON.stringify(value)}. Use 1, 2 or 3.`);
|
|
811
|
+
}
|
|
812
|
+
return n;
|
|
813
|
+
}
|
|
814
|
+
function parseSection(entry, at) {
|
|
815
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
816
|
+
throw new Error(`${at} is not an object.`);
|
|
817
|
+
}
|
|
818
|
+
const s = entry;
|
|
819
|
+
const kind = s.kind === undefined ? 'fields' : String(s.kind);
|
|
820
|
+
const width = parseWidth(s.width, at);
|
|
821
|
+
const offsetLeft = parseOffset(s.offsetLeft, at, 'offsetLeft');
|
|
822
|
+
const offsetRight = parseOffset(s.offsetRight, at, 'offsetRight');
|
|
823
|
+
const title = s.title === undefined ? undefined : String(s.title);
|
|
824
|
+
const hidden = s.hidden === undefined ? undefined : Boolean(s.hidden) || undefined;
|
|
825
|
+
if (kind === 'fields') {
|
|
826
|
+
if (s.paths !== undefined && !Array.isArray(s.paths)) {
|
|
827
|
+
throw new Error(`${at} has "paths" that are not an array.`);
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
kind: 'fields',
|
|
831
|
+
title,
|
|
832
|
+
width,
|
|
833
|
+
offsetLeft,
|
|
834
|
+
offsetRight,
|
|
835
|
+
hidden,
|
|
836
|
+
paths: s.paths?.map(String),
|
|
837
|
+
columns: parseFieldColumns(s.columns, at),
|
|
838
|
+
collapsed: s.collapsed === undefined ? undefined : Boolean(s.collapsed) || undefined,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (kind !== 'metric' && kind !== 'list') {
|
|
842
|
+
throw new Error(`${at} has an unknown kind "${kind}". Use "fields", "metric" or "list".`);
|
|
843
|
+
}
|
|
844
|
+
const from = typeof s.from === 'string' ? s.from.trim() : '';
|
|
845
|
+
if (!from)
|
|
846
|
+
throw new Error(`${at} has no "from" table.`);
|
|
847
|
+
const path = typeof s.path === 'string' ? s.path.trim() : '';
|
|
848
|
+
if (!path) {
|
|
849
|
+
throw new Error(`${at} has no "path" saying how ${from} reaches the page's record — `
|
|
850
|
+
+ 'e.g. "customer_id", or "invoice_id.customer_id" for something two references away.');
|
|
851
|
+
}
|
|
852
|
+
const filter = s.filter === undefined ? undefined : String(s.filter).trim() || undefined;
|
|
853
|
+
if (kind === 'metric') {
|
|
854
|
+
const fn = String(s.fn ?? 'count');
|
|
855
|
+
if (!['count', 'sum', 'min', 'max', 'avg'].includes(fn)) {
|
|
856
|
+
throw new Error(`${at} has an unknown "fn" of "${fn}".`);
|
|
857
|
+
}
|
|
858
|
+
if (fn !== 'count' && (typeof s.column !== 'string' || !s.column.trim())) {
|
|
859
|
+
throw new Error(`${at} is a "${fn}" with no "column" to ${fn}.`);
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
kind: 'metric',
|
|
863
|
+
title,
|
|
864
|
+
width,
|
|
865
|
+
offsetLeft,
|
|
866
|
+
offsetRight,
|
|
867
|
+
hidden,
|
|
868
|
+
fn: fn,
|
|
869
|
+
from,
|
|
870
|
+
path,
|
|
871
|
+
column: s.column === undefined ? undefined : String(s.column).trim(),
|
|
872
|
+
filter,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
|
|
876
|
+
throw new Error(`${at} has a "limit" that is not a positive whole number.`);
|
|
877
|
+
}
|
|
878
|
+
if (s.columns !== undefined && !Array.isArray(s.columns)) {
|
|
879
|
+
throw new Error(`${at} has "columns" that are not an array.`);
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
kind: 'list',
|
|
883
|
+
title,
|
|
884
|
+
width,
|
|
885
|
+
offsetLeft,
|
|
886
|
+
offsetRight,
|
|
887
|
+
hidden,
|
|
888
|
+
from,
|
|
889
|
+
path,
|
|
890
|
+
columns: s.columns?.map(String),
|
|
891
|
+
filter,
|
|
892
|
+
limit: s.limit,
|
|
893
|
+
orderBy: parseOrderBy(s.orderBy, at),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function parseWidth(raw, at) {
|
|
897
|
+
if (raw === undefined)
|
|
898
|
+
return undefined;
|
|
899
|
+
const width = String(raw);
|
|
900
|
+
if (!WIDTHS.includes(width)) {
|
|
901
|
+
throw new Error(`${at} has a "width" of "${width}". Use ${WIDTHS.join(', ')}.`);
|
|
902
|
+
}
|
|
903
|
+
return width;
|
|
904
|
+
}
|
|
905
|
+
function parseOffset(raw, at, side) {
|
|
906
|
+
if (raw === undefined)
|
|
907
|
+
return undefined;
|
|
908
|
+
const offset = String(raw);
|
|
909
|
+
if (!OFFSETS.includes(offset)) {
|
|
910
|
+
throw new Error(`${at} has an "${side}" of "${offset}". Use ${OFFSETS.join(', ')}.`);
|
|
911
|
+
}
|
|
912
|
+
return offset;
|
|
913
|
+
}
|
|
914
|
+
function parseOrderBy(raw, at) {
|
|
915
|
+
if (raw === undefined)
|
|
916
|
+
return undefined;
|
|
917
|
+
if (!Array.isArray(raw))
|
|
918
|
+
throw new Error(`${at} has an "orderBy" that is not an array.`);
|
|
919
|
+
return raw.map((entry) => {
|
|
920
|
+
if (!entry || typeof entry !== 'object')
|
|
921
|
+
throw new Error(`${at} has an "orderBy" entry that is not an object.`);
|
|
922
|
+
const o = entry;
|
|
923
|
+
if (typeof o.column !== 'string' || !o.column.trim()) {
|
|
924
|
+
throw new Error(`${at} has an "orderBy" entry with no "column".`);
|
|
925
|
+
}
|
|
926
|
+
const direction = o.direction === undefined ? 'asc' : String(o.direction).toLowerCase();
|
|
927
|
+
if (direction !== 'asc' && direction !== 'desc') {
|
|
928
|
+
throw new Error(`${at} has an "orderBy" direction of "${direction}". Use asc or desc.`);
|
|
929
|
+
}
|
|
930
|
+
return { column: o.column.trim(), direction: direction };
|
|
931
|
+
});
|
|
932
|
+
}
|