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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two schemas, and what is different about them.
|
|
3
|
+
*
|
|
4
|
+
* The question is always the same and always urgent: *is staging the same
|
|
5
|
+
* shape as production?* Asked before a deploy, after a migration, or when one
|
|
6
|
+
* environment behaves differently for reasons nobody can name. The usual
|
|
7
|
+
* answer is two schema dumps and a text diff, which reports every difference
|
|
8
|
+
* in formatting as loudly as the missing column and buries it.
|
|
9
|
+
*
|
|
10
|
+
* This compares the model instead of the text: tables, columns and their
|
|
11
|
+
* types, nullability and defaults, foreign keys and their delete rules. So
|
|
12
|
+
* `varchar(40)` written on two lines is not a difference, and a column that
|
|
13
|
+
* became nullable is — which is the point.
|
|
14
|
+
*
|
|
15
|
+
* Direction matters and is named: `left` is what you have, `right` is what
|
|
16
|
+
* you are comparing against. "Only in production" and "only in staging" are
|
|
17
|
+
* different sentences with different consequences, and a diff that says
|
|
18
|
+
* "differs" is a diff that makes you go and look.
|
|
19
|
+
*/
|
|
20
|
+
import { primaryKey } from './schema.js';
|
|
21
|
+
const describeKey = (fk) => `${fk.from.columns.join(', ')} → ${fk.to.table}(${fk.to.columns.join(', ')})`;
|
|
22
|
+
/** A column's facts as strings, so a difference is a difference in one of them. */
|
|
23
|
+
function facts(column, table) {
|
|
24
|
+
return {
|
|
25
|
+
type: column.type,
|
|
26
|
+
nullable: String(column.nullable),
|
|
27
|
+
default: column.default ?? '—',
|
|
28
|
+
key: String(primaryKey(table).includes(column.name)),
|
|
29
|
+
allowed: column.allowed?.length ? column.allowed.join(', ') : '—',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function diffSchemas(left, right) {
|
|
33
|
+
const byId = (schema) => new Map(schema.tables.map((t) => [t.id, t]));
|
|
34
|
+
const leftTables = byId(left);
|
|
35
|
+
const rightTables = byId(right);
|
|
36
|
+
const tables = [];
|
|
37
|
+
/* Union of ids, in one order both sides agree on: a diff whose sections
|
|
38
|
+
move around between runs cannot be diffed itself. */
|
|
39
|
+
const ids = [...new Set([...leftTables.keys(), ...rightTables.keys()])].sort();
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const a = leftTables.get(id);
|
|
42
|
+
const b = rightTables.get(id);
|
|
43
|
+
if (!a || !b) {
|
|
44
|
+
tables.push({ table: id, onlyIn: a ? 'left' : 'right' });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const diff = { table: id };
|
|
48
|
+
const aColumns = new Map(a.columns.map((c) => [c.name, c]));
|
|
49
|
+
const bColumns = new Map(b.columns.map((c) => [c.name, c]));
|
|
50
|
+
const onlyLeft = [...aColumns.keys()].filter((n) => !bColumns.has(n));
|
|
51
|
+
const onlyRight = [...bColumns.keys()].filter((n) => !aColumns.has(n));
|
|
52
|
+
if (onlyLeft.length)
|
|
53
|
+
diff.columnsOnlyLeft = onlyLeft;
|
|
54
|
+
if (onlyRight.length)
|
|
55
|
+
diff.columnsOnlyRight = onlyRight;
|
|
56
|
+
const changed = [];
|
|
57
|
+
for (const [name, column] of aColumns) {
|
|
58
|
+
const other = bColumns.get(name);
|
|
59
|
+
if (!other)
|
|
60
|
+
continue;
|
|
61
|
+
const mine = facts(column, a);
|
|
62
|
+
const theirs = facts(other, b);
|
|
63
|
+
for (const what of Object.keys(mine)) {
|
|
64
|
+
if (mine[what] !== theirs[what]) {
|
|
65
|
+
changed.push({ column: name, what, left: mine[what], right: theirs[what] });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (changed.length)
|
|
70
|
+
diff.changed = changed;
|
|
71
|
+
const keysOf = (schema, table) => new Map(schema.foreignKeys.filter((fk) => fk.from.table === table).map((fk) => [describeKey(fk), fk]));
|
|
72
|
+
const aKeys = keysOf(left, id);
|
|
73
|
+
const bKeys = keysOf(right, id);
|
|
74
|
+
const keysOnlyLeft = [...aKeys.keys()].filter((k) => !bKeys.has(k));
|
|
75
|
+
const keysOnlyRight = [...bKeys.keys()].filter((k) => !aKeys.has(k));
|
|
76
|
+
if (keysOnlyLeft.length)
|
|
77
|
+
diff.keysOnlyLeft = keysOnlyLeft;
|
|
78
|
+
if (keysOnlyRight.length)
|
|
79
|
+
diff.keysOnlyRight = keysOnlyRight;
|
|
80
|
+
/* A delete rule that changed is a foreign key that still exists and now
|
|
81
|
+
does something else on delete — invisible to a "which keys exist"
|
|
82
|
+
comparison, and the difference that turns a safe delete into a
|
|
83
|
+
cascade. */
|
|
84
|
+
const ruleChanges = [...aKeys.entries()]
|
|
85
|
+
.filter(([key, fk]) => bKeys.has(key) && (fk.onDelete ?? 'no action') !== (bKeys.get(key).onDelete ?? 'no action'))
|
|
86
|
+
.map(([key, fk]) => ({
|
|
87
|
+
key,
|
|
88
|
+
left: fk.onDelete ?? 'no action',
|
|
89
|
+
right: bKeys.get(key).onDelete ?? 'no action',
|
|
90
|
+
}));
|
|
91
|
+
if (ruleChanges.length)
|
|
92
|
+
diff.deleteRuleChanged = ruleChanges;
|
|
93
|
+
if (Object.keys(diff).length > 1)
|
|
94
|
+
tables.push(diff);
|
|
95
|
+
}
|
|
96
|
+
return { left: left.label, right: right.label, tables, same: tables.length === 0 };
|
|
97
|
+
}
|
|
98
|
+
/** The diff as a person reads it. */
|
|
99
|
+
export function diffReport(diff) {
|
|
100
|
+
if (diff.same)
|
|
101
|
+
return `${diff.left} and ${diff.right} have the same shape.`;
|
|
102
|
+
const lines = [
|
|
103
|
+
`# ${diff.left} → ${diff.right}`,
|
|
104
|
+
'',
|
|
105
|
+
`${diff.tables.length} table${diff.tables.length === 1 ? ' differs' : 's differ'}. `
|
|
106
|
+
+ `"Only in ${diff.left}" means it is missing from ${diff.right}.`,
|
|
107
|
+
];
|
|
108
|
+
for (const table of diff.tables) {
|
|
109
|
+
lines.push('', `## ${table.table}`, '');
|
|
110
|
+
if (table.onlyIn) {
|
|
111
|
+
lines.push(`- Table exists only in **${table.onlyIn === 'left' ? diff.left : diff.right}**.`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
for (const column of table.columnsOnlyLeft ?? [])
|
|
115
|
+
lines.push(`- Column \`${column}\` only in ${diff.left}.`);
|
|
116
|
+
for (const column of table.columnsOnlyRight ?? [])
|
|
117
|
+
lines.push(`- Column \`${column}\` only in ${diff.right}.`);
|
|
118
|
+
for (const change of table.changed ?? []) {
|
|
119
|
+
lines.push(`- \`${change.column}\` ${change.what}: **${change.left}** → **${change.right}**`);
|
|
120
|
+
}
|
|
121
|
+
for (const key of table.keysOnlyLeft ?? [])
|
|
122
|
+
lines.push(`- Foreign key ${key} only in ${diff.left}.`);
|
|
123
|
+
for (const key of table.keysOnlyRight ?? [])
|
|
124
|
+
lines.push(`- Foreign key ${key} only in ${diff.right}.`);
|
|
125
|
+
for (const rule of table.deleteRuleChanged ?? []) {
|
|
126
|
+
lines.push(`- ${rule.key} on delete: **${rule.left}** → **${rule.right}**`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pattern behind `contains`, `starts with` and `ends with`.
|
|
3
|
+
*
|
|
4
|
+
* A user typing `contains 50%` means a literal percent sign. Interpolated
|
|
5
|
+
* straight into a LIKE pattern it becomes a wildcard, so the search matches
|
|
6
|
+
* every row and the tool reports a hit on data that does not contain the
|
|
7
|
+
* value — the worst shape of wrong answer, because nothing about it looks
|
|
8
|
+
* like a failure. `a_c` matching `abc` is the same bug, quieter.
|
|
9
|
+
*
|
|
10
|
+
* So the value is escaped and the statement declares an escape character.
|
|
11
|
+
* `#` rather than the conventional backslash: MySQL parses backslash escapes
|
|
12
|
+
* inside string literals unless NO_BACKSLASH_ESCAPES is set, which would make
|
|
13
|
+
* the `ESCAPE` clause itself dialect-specific text.
|
|
14
|
+
*
|
|
15
|
+
* Lives in shared/ because the SQL the adapters compile and the SQL the query
|
|
16
|
+
* bar *shows* you have to agree — a preview that promises a wildcard the
|
|
17
|
+
* compiler escapes is a lie in the one place the tool is teaching.
|
|
18
|
+
*/
|
|
19
|
+
export const LIKE_ESCAPE = '#';
|
|
20
|
+
/** The wildcards, plus the escape character itself, which must escape too. */
|
|
21
|
+
const SPECIAL = /[#%_]/g;
|
|
22
|
+
export function likePattern(value, op) {
|
|
23
|
+
const literal = String(value ?? '').replace(SPECIAL, (ch) => `${LIKE_ESCAPE}${ch}`);
|
|
24
|
+
if (op === 'startsWith')
|
|
25
|
+
return `${literal}%`;
|
|
26
|
+
if (op === 'endsWith')
|
|
27
|
+
return `%${literal}`;
|
|
28
|
+
return `%${literal}%`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the shape says about itself.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a fact tablewalk already holds — the tables, the keys,
|
|
5
|
+
* the indexes' leading columns, the delete rules — asked a different
|
|
6
|
+
* question: not "what is this" but "what will this cost the person who has
|
|
7
|
+
* to work with it". A foreign key with no index behind it is the reverse
|
|
8
|
+
* walk this whole tool is built on, run as a full scan every time. A table
|
|
9
|
+
* with no primary key is a row nobody can edit or link to. Neither is
|
|
10
|
+
* visible while browsing, because browsing a small table hides both.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately narrow. Every rule is checkable from the catalog with no
|
|
13
|
+
* queries and no heuristics about intent, and each one names the consequence
|
|
14
|
+
* rather than the rule — "a schema lint that only cites style" is a lint
|
|
15
|
+
* people turn off. Anything requiring judgement about the domain (a column
|
|
16
|
+
* that should have been an enum, a table that should have been two) is left
|
|
17
|
+
* out on purpose: it would be guessing, and guessing at scale is noise.
|
|
18
|
+
*/
|
|
19
|
+
import { primaryKey, referencesFrom, } from './schema.js';
|
|
20
|
+
/** The cost of each rule, said once. */
|
|
21
|
+
export const WHY = {
|
|
22
|
+
'no-primary-key': 'No row here can be linked to, edited or deleted by this tool, and nothing else can point at it either.',
|
|
23
|
+
'unindexed-foreign-key': 'Every walk back from the referenced table scans this whole table, and so does deleting a referenced row.',
|
|
24
|
+
'no-delete-rule': 'Deleting a parent fails at the moment someone tries it rather than saying what it would do. Restrict, cascade or set null states the intent.',
|
|
25
|
+
'timestamp-without-zone': 'The same stored text is a different instant to a reader in another zone, and nothing records which one wrote it.',
|
|
26
|
+
'unreferenced-table': 'It cannot be reached by walking, only by naming it — either a lookup nobody wired up, or something meant to stand alone.',
|
|
27
|
+
};
|
|
28
|
+
/** `timestamp` without a zone: the same text means a different instant per reader. */
|
|
29
|
+
const NAIVE_TIMESTAMP = /^timestamp( without time zone)?$/i;
|
|
30
|
+
export function lintSchema(schema, options = {}) {
|
|
31
|
+
const skip = new Set(options.skip ?? []);
|
|
32
|
+
const findings = [];
|
|
33
|
+
const add = (finding) => {
|
|
34
|
+
if (!skip.has(finding.rule))
|
|
35
|
+
findings.push(finding);
|
|
36
|
+
};
|
|
37
|
+
for (const table of schema.tables) {
|
|
38
|
+
/* A view has no key, no index and no delete rule by definition, and
|
|
39
|
+
saying so about each one would bury the tables that can be fixed. */
|
|
40
|
+
if (table.isView)
|
|
41
|
+
continue;
|
|
42
|
+
if (!primaryKey(table).length) {
|
|
43
|
+
add({
|
|
44
|
+
rule: 'no-primary-key',
|
|
45
|
+
severity: 'high',
|
|
46
|
+
table: table.id,
|
|
47
|
+
message: `${table.name} has no primary key.`,
|
|
48
|
+
why: WHY['no-primary-key'],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const fk of referencesFrom(schema, table.id)) {
|
|
52
|
+
const lead = fk.from.columns[0];
|
|
53
|
+
/* `indexed` carries only each index's *leading* column, which is
|
|
54
|
+
exactly the question here: an index on (a, b) makes a lookup by `a`
|
|
55
|
+
free and does nothing for a lookup by `b` alone. Absent means the
|
|
56
|
+
adapter does not report indexes — that is "we did not look", not
|
|
57
|
+
"there are none", so it is not a finding. */
|
|
58
|
+
if (table.indexed && !table.indexed.includes(lead)) {
|
|
59
|
+
add({
|
|
60
|
+
rule: 'unindexed-foreign-key',
|
|
61
|
+
severity: 'high',
|
|
62
|
+
table: table.id,
|
|
63
|
+
columns: fk.from.columns,
|
|
64
|
+
message: `${lead} → ${fk.to.table}, with no index leading on ${lead}.`,
|
|
65
|
+
why: WHY['unindexed-foreign-key'],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!fk.onDelete || /^no action$/i.test(fk.onDelete)) {
|
|
69
|
+
add({
|
|
70
|
+
rule: 'no-delete-rule',
|
|
71
|
+
severity: 'medium',
|
|
72
|
+
table: table.id,
|
|
73
|
+
columns: fk.from.columns,
|
|
74
|
+
message: `${fk.from.columns.join(', ')} → ${fk.to.table}, with no ON DELETE rule.`,
|
|
75
|
+
why: WHY['no-delete-rule'],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const column of table.columns) {
|
|
80
|
+
if (NAIVE_TIMESTAMP.test(column.type)) {
|
|
81
|
+
add({
|
|
82
|
+
rule: 'timestamp-without-zone',
|
|
83
|
+
severity: 'medium',
|
|
84
|
+
table: table.id,
|
|
85
|
+
columns: [column.name],
|
|
86
|
+
message: `${column.name} is a timestamp with no time zone.`,
|
|
87
|
+
why: WHY['timestamp-without-zone'],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/* Islands last, and low: a table nothing points at and which points at
|
|
93
|
+
nothing is sometimes a lookup nobody wired up, and sometimes a log
|
|
94
|
+
that is meant to stand alone. Worth seeing, not worth alarming about. */
|
|
95
|
+
for (const table of schema.tables) {
|
|
96
|
+
if (table.isView)
|
|
97
|
+
continue;
|
|
98
|
+
if (referencesFrom(schema, table.id).length)
|
|
99
|
+
continue;
|
|
100
|
+
if (schema.foreignKeys.some((fk) => fk.to.table === table.id))
|
|
101
|
+
continue;
|
|
102
|
+
add({
|
|
103
|
+
rule: 'unreferenced-table',
|
|
104
|
+
severity: 'low',
|
|
105
|
+
table: table.id,
|
|
106
|
+
message: `Nothing points at ${table.name} and it points at nothing.`,
|
|
107
|
+
why: WHY['unreferenced-table'],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
const ORDER = { high: 0, medium: 1, low: 2 };
|
|
113
|
+
/** Findings as a person reads them: worst first, grouped under their table. */
|
|
114
|
+
export function lintReport(findings) {
|
|
115
|
+
if (!findings.length)
|
|
116
|
+
return 'Nothing to report: no missing keys, unindexed references or naive timestamps.';
|
|
117
|
+
/* By table, then by severity within it. Sorting the other way round splits
|
|
118
|
+
every table across three places in the document, and a schema is read
|
|
119
|
+
one table at a time. */
|
|
120
|
+
const sorted = [...findings].sort((a, b) => a.table.localeCompare(b.table) || ORDER[a.severity] - ORDER[b.severity]);
|
|
121
|
+
const counts = lintCounts(findings);
|
|
122
|
+
const lines = [
|
|
123
|
+
`${findings.length} finding${findings.length === 1 ? '' : 's'}: `
|
|
124
|
+
+ `${counts.high} high, ${counts.medium} medium, ${counts.low} low.`,
|
|
125
|
+
];
|
|
126
|
+
let table = '';
|
|
127
|
+
for (const finding of sorted) {
|
|
128
|
+
if (finding.table !== table) {
|
|
129
|
+
table = finding.table;
|
|
130
|
+
lines.push('', `## ${table}`, '');
|
|
131
|
+
}
|
|
132
|
+
lines.push(`- **${finding.severity}** ${finding.rule} — ${finding.message}`);
|
|
133
|
+
}
|
|
134
|
+
/* The costs once, at the end, rather than on every line. Seventeen
|
|
135
|
+
identical sentences is a list nobody reads past the third one. */
|
|
136
|
+
const rules = [...new Set(sorted.map((f) => f.rule))].sort();
|
|
137
|
+
lines.push('', '## Why these matter', '');
|
|
138
|
+
for (const rule of rules)
|
|
139
|
+
lines.push(`- **${rule}** — ${WHY[rule] ?? ''}`);
|
|
140
|
+
return lines.join('\n').trim();
|
|
141
|
+
}
|
|
142
|
+
/** How many of each, for a caller deciding whether to fail a build. */
|
|
143
|
+
export function lintCounts(findings) {
|
|
144
|
+
return {
|
|
145
|
+
high: findings.filter((f) => f.severity === 'high').length,
|
|
146
|
+
medium: findings.filter((f) => f.severity === 'medium').length,
|
|
147
|
+
low: findings.filter((f) => f.severity === 'low').length,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What has to exist first, and what comes apart in which order.
|
|
3
|
+
*
|
|
4
|
+
* Every foreign key is a statement about time: a row cannot point at a row
|
|
5
|
+
* that is not there yet, and a row that is pointed at cannot leave before the
|
|
6
|
+
* rows pointing at it. Building test data means knowing that order, and
|
|
7
|
+
* deriving it by hand from a catalog is exactly the work this tool already
|
|
8
|
+
* did to draw the graph.
|
|
9
|
+
*
|
|
10
|
+
* A topological sort, with the two things a topological sort usually leaves
|
|
11
|
+
* out said out loud:
|
|
12
|
+
*
|
|
13
|
+
* - **Cycles.** Two tables that point at each other have no order. Rather
|
|
14
|
+
* than picking one and pretending, the cycle is named — and where one of
|
|
15
|
+
* its edges is nullable, the way through is named too: insert with the
|
|
16
|
+
* column null, then update it once the other row exists.
|
|
17
|
+
* - **Self-references.** `work_order.parent_id → work_order` is not a
|
|
18
|
+
* cycle between tables and does not affect the order at all, but it does
|
|
19
|
+
* mean the rows inside that table have an order of their own. Reported
|
|
20
|
+
* separately, because it is a different problem with a different answer.
|
|
21
|
+
*
|
|
22
|
+
* Ties break alphabetically. A stable answer is worth more than a marginally
|
|
23
|
+
* shorter one: an order that changes between runs makes a fixture that
|
|
24
|
+
* "sometimes works", which is the failure this exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
import { findTable } from './schema.js';
|
|
27
|
+
/**
|
|
28
|
+
* @param tables The tables to order. Omitted means every table in the schema.
|
|
29
|
+
* Names are resolved through `findTable`, so a bare name works where the
|
|
30
|
+
* schema qualifies it.
|
|
31
|
+
*/
|
|
32
|
+
export function insertOrder(schema, tables) {
|
|
33
|
+
const wanted = tables?.length
|
|
34
|
+
? tables.map((name) => findTable(schema, name)?.id ?? name)
|
|
35
|
+
: schema.tables.filter((t) => !t.isView).map((t) => t.id);
|
|
36
|
+
const inSet = new Set(wanted);
|
|
37
|
+
const requires = {};
|
|
38
|
+
const selfReferencing = [];
|
|
39
|
+
/* `after.get(a)` holds the tables that must come *after* a — the edges of
|
|
40
|
+
the sort, pointing the way an insert travels. */
|
|
41
|
+
const after = new Map(wanted.map((id) => [id, new Set()]));
|
|
42
|
+
const waitingOn = new Map(wanted.map((id) => [id, 0]));
|
|
43
|
+
for (const fk of schema.foreignKeys) {
|
|
44
|
+
const from = fk.from.table;
|
|
45
|
+
const to = fk.to.table;
|
|
46
|
+
if (!inSet.has(from))
|
|
47
|
+
continue;
|
|
48
|
+
if (from === to) {
|
|
49
|
+
if (!selfReferencing.includes(from))
|
|
50
|
+
selfReferencing.push(from);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
/* A reference out of the set is still a requirement — the row it points
|
|
54
|
+
at has to exist — but it cannot be ordered, because it is not part of
|
|
55
|
+
what was asked about. It belongs in `requires` and not in the sort. */
|
|
56
|
+
const columns = findTable(schema, from)?.columns ?? [];
|
|
57
|
+
const optional = fk.from.columns.every((name) => columns.find((c) => c.name === name)?.nullable !== false);
|
|
58
|
+
(requires[from] ??= []).push({ table: to, via: fk.from.columns, optional });
|
|
59
|
+
if (!inSet.has(to))
|
|
60
|
+
continue;
|
|
61
|
+
if (after.get(to).has(from))
|
|
62
|
+
continue;
|
|
63
|
+
after.get(to).add(from);
|
|
64
|
+
waitingOn.set(from, (waitingOn.get(from) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
const insert = [];
|
|
67
|
+
const ready = wanted.filter((id) => !waitingOn.get(id)).sort();
|
|
68
|
+
while (ready.length) {
|
|
69
|
+
const id = ready.shift();
|
|
70
|
+
insert.push(id);
|
|
71
|
+
/* Sorted on the way in, so the answer does not depend on the order the
|
|
72
|
+
catalog happened to list the keys in. */
|
|
73
|
+
for (const next of [...after.get(id)].sort()) {
|
|
74
|
+
const left = (waitingOn.get(next) ?? 0) - 1;
|
|
75
|
+
waitingOn.set(next, left);
|
|
76
|
+
if (left === 0) {
|
|
77
|
+
ready.push(next);
|
|
78
|
+
ready.sort();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/* Whatever is left is in a cycle, or behind one. Grouped so the answer
|
|
83
|
+
names the knot rather than listing its members. */
|
|
84
|
+
const stuck = wanted.filter((id) => !insert.includes(id));
|
|
85
|
+
const cycles = groupCycles(stuck, after);
|
|
86
|
+
for (const list of Object.values(requires)) {
|
|
87
|
+
list.sort((a, b) => a.table.localeCompare(b.table));
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
insert,
|
|
91
|
+
remove: [...insert].reverse(),
|
|
92
|
+
requires,
|
|
93
|
+
cycles,
|
|
94
|
+
selfReferencing: selfReferencing.sort(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The knots, as knots.
|
|
99
|
+
*
|
|
100
|
+
* Two tables pointing at each other and a third waiting behind them are one
|
|
101
|
+
* problem with one fix, and three lines saying "could not be ordered" is
|
|
102
|
+
* three problems to work out are the same one.
|
|
103
|
+
*/
|
|
104
|
+
function groupCycles(stuck, after) {
|
|
105
|
+
const left = new Set(stuck);
|
|
106
|
+
const groups = [];
|
|
107
|
+
while (left.size) {
|
|
108
|
+
const start = [...left].sort()[0];
|
|
109
|
+
const group = new Set([start]);
|
|
110
|
+
const queue = [start];
|
|
111
|
+
while (queue.length) {
|
|
112
|
+
const id = queue.shift();
|
|
113
|
+
for (const next of after.get(id) ?? []) {
|
|
114
|
+
if (!left.has(next) || group.has(next))
|
|
115
|
+
continue;
|
|
116
|
+
group.add(next);
|
|
117
|
+
queue.push(next);
|
|
118
|
+
}
|
|
119
|
+
/* Both directions: a cycle is only visible if the walk can go back the
|
|
120
|
+
way the arrows came. */
|
|
121
|
+
for (const [other, targets] of after) {
|
|
122
|
+
if (!left.has(other) || group.has(other) || !targets.has(id))
|
|
123
|
+
continue;
|
|
124
|
+
group.add(other);
|
|
125
|
+
queue.push(other);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const id of group)
|
|
129
|
+
left.delete(id);
|
|
130
|
+
groups.push([...group].sort());
|
|
131
|
+
}
|
|
132
|
+
return groups;
|
|
133
|
+
}
|