dbtour 0.6.0 → 0.7.0
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/dist/cli.js +8 -4
- package/dist/mcp.js +33 -17
- package/dist/model.d.ts +24 -0
- package/dist/model.js +82 -10
- package/dist/notes.js +26 -21
- package/dist/render/markdown.d.ts +2 -0
- package/dist/render/markdown.js +24 -8
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { parseArgs } from 'node:util';
|
|
16
16
|
import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection } from "./config.js";
|
|
17
|
-
import { emptyModel, validate } from "./model.js";
|
|
17
|
+
import { emptyModel, scope, validate } from "./model.js";
|
|
18
18
|
import { applyNotes, parseNotes } from "./notes.js";
|
|
19
19
|
import { answerLine } from "./mcp.js";
|
|
20
20
|
import { loadAddon, loadSource } from "./plugins.js";
|
|
@@ -119,7 +119,10 @@ async function extract(io) {
|
|
|
119
119
|
function importer(cwd) {
|
|
120
120
|
return (specifier) => import(__rewriteRelativeImportExtension(specifier.startsWith('.') || specifier.startsWith('/') ? pathToFileURL(resolve(cwd, specifier)).href : specifier));
|
|
121
121
|
}
|
|
122
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* <out>/schema.json with the notes merged in, as render and mcp read it: scoped first, so with several
|
|
124
|
+
* databases the notes may name a table with its database or, where only one has it, without.
|
|
125
|
+
*/
|
|
123
126
|
function readModel(io, config) {
|
|
124
127
|
const modelPath = join(io.cwd, config.out, modelFile);
|
|
125
128
|
if (!existsSync(modelPath))
|
|
@@ -127,10 +130,11 @@ function readModel(io, config) {
|
|
|
127
130
|
const checked = validate(JSON.parse(readFileSync(modelPath, 'utf8')));
|
|
128
131
|
if (!checked.ok)
|
|
129
132
|
throw new Error(`${join(config.out, modelFile)} is not a model:\n ${checked.errors.join('\n ')}`);
|
|
133
|
+
const model = scope(checked.model);
|
|
130
134
|
const notesPath = join(io.cwd, config.notes);
|
|
131
135
|
if (!existsSync(notesPath))
|
|
132
|
-
return
|
|
133
|
-
const applied = applyNotes(
|
|
136
|
+
return model;
|
|
137
|
+
const applied = applyNotes(model, parseNotes(readFileSync(notesPath, 'utf8')));
|
|
134
138
|
for (const warning of applied.warnings)
|
|
135
139
|
io.error(warning);
|
|
136
140
|
return applied.model;
|
package/dist/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// table, read it, join two) instead of being handed all of it. JSON-RPC, one message per line; written here
|
|
3
3
|
// rather than taken from the SDK, since four methods are all it needs and core keeps its one dependency.
|
|
4
4
|
// Read-only and structure only, like the model: there are no rows to give.
|
|
5
|
-
import {
|
|
5
|
+
import { findTables, localRef, tableRef } from "./model.js";
|
|
6
6
|
import { driverName, formatCount, renderTableMarkdown } from "./render/markdown.js";
|
|
7
7
|
import { buildIndex, search } from "./search.js";
|
|
8
8
|
// The versions this server speaks, newest first. It uses only initialize, ping, tools/list and tools/call,
|
|
@@ -14,7 +14,7 @@ export const overviewLimit = 100;
|
|
|
14
14
|
// Enough hits to choose from without reading past the good ones; the agent can ask for up to the maximum.
|
|
15
15
|
const searchDefault = 20;
|
|
16
16
|
const searchMaximum = 100;
|
|
17
|
-
const instructions = `dbtour knows the structure of this project's databases: tables, views, columns, keys, relations, indexes and the descriptions people wrote. It never holds rows. Start with overview for the databases and their largest tables; search finds a table or column by any part of its name or description; table gives one table's columns, keys and relations as Markdown; path gives the joins between two tables. Name tables as schema.table, and read a table before writing SQL against it.`;
|
|
17
|
+
const instructions = `dbtour knows the structure of this project's databases: tables, views, columns, keys, relations, indexes and the descriptions people wrote. It never holds rows. Start with overview for the databases and their largest tables; search finds a table or column by any part of its name or description; table gives one table's columns, keys and relations as Markdown; path gives the joins between two tables. Name tables as overview and search do (schema.table, or Database:schema.table when there are several databases), and read a table before writing SQL against it.`;
|
|
18
18
|
const readOnly = { readOnlyHint: true, openWorldHint: false };
|
|
19
19
|
export const toolList = [
|
|
20
20
|
{
|
|
@@ -44,7 +44,7 @@ export const toolList = [
|
|
|
44
44
|
description: 'One table or view as Markdown: its columns with types, keys and descriptions, the relations both ways, indexes, constraints, a view\'s SQL and a diagram of its neighbours. Read it before writing SQL against the table.',
|
|
45
45
|
inputSchema: {
|
|
46
46
|
type: 'object',
|
|
47
|
-
properties: { ref: { type: 'string', description: 'schema.table, like dbo.Orders. A
|
|
47
|
+
properties: { ref: { type: 'string', description: 'schema.table, like dbo.Orders, or Sales:dbo.Orders with several databases. A shorter name works when only one table has it.' } },
|
|
48
48
|
required: ['ref'],
|
|
49
49
|
},
|
|
50
50
|
annotations: readOnly,
|
|
@@ -56,8 +56,8 @@ export const toolList = [
|
|
|
56
56
|
inputSchema: {
|
|
57
57
|
type: 'object',
|
|
58
58
|
properties: {
|
|
59
|
-
from: { type: 'string', description: 'The table to start from,
|
|
60
|
-
to: { type: 'string', description: 'The table to reach,
|
|
59
|
+
from: { type: 'string', description: 'The table to start from, named as table takes it.' },
|
|
60
|
+
to: { type: 'string', description: 'The table to reach, named as table takes it.' },
|
|
61
61
|
},
|
|
62
62
|
required: ['from', 'to'],
|
|
63
63
|
},
|
|
@@ -226,20 +226,34 @@ function searchText(model, query, limit) {
|
|
|
226
226
|
return `Nothing matches "${query}". Every word must match: try fewer words, or part of a name.`;
|
|
227
227
|
return hits.map((h) => `- ${h.kind} ${h.title}: ${h.detail}`).join('\n');
|
|
228
228
|
}
|
|
229
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* What an agent wrote, as the table it means: exact, then ignoring case, as the whole name, the name without
|
|
231
|
+
* its database, or a bare table name. A name several tables answer to is a problem that lists them.
|
|
232
|
+
*/
|
|
230
233
|
function resolve(model, written) {
|
|
231
234
|
const parts = written.trim().split('.');
|
|
232
235
|
// schema.table.column is the table; a bare name is looked up by name alone.
|
|
233
236
|
const wanted = parts.length === 3 ? parts.slice(0, 2).join('.') : parts.join('.');
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
const several = (refs) => ({ problem: `${refs.slice(0, -1).join(', ')} and ${refs.at(-1)} are ${refs.length === 2 ? 'both' : 'all'} called ${wanted}: ask for one by the name given here.` });
|
|
238
|
+
if (parts.length >= 2 && parts.length <= 3 && !parts.includes('')) {
|
|
239
|
+
const exact = findTables(model, wanted).map((f) => f.ref);
|
|
240
|
+
if (exact.length === 1)
|
|
241
|
+
return { ref: exact[0] };
|
|
242
|
+
if (exact.length > 1)
|
|
243
|
+
return several(exact);
|
|
244
|
+
}
|
|
245
|
+
const all = [...new Set(model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name)))))];
|
|
237
246
|
const lower = wanted.toLowerCase();
|
|
238
|
-
|
|
247
|
+
// The whole name, the name an agent writes when it does not know there are several databases, the table's.
|
|
248
|
+
const forms = (ref) => {
|
|
249
|
+
const local = localRef(model, ref);
|
|
250
|
+
return [ref, local, local.slice(local.indexOf('.') + 1)];
|
|
251
|
+
};
|
|
252
|
+
const matches = all.filter((ref) => forms(ref).some((form) => form.toLowerCase() === lower));
|
|
239
253
|
if (matches.length === 1)
|
|
240
254
|
return { ref: matches[0] };
|
|
241
255
|
if (matches.length > 1)
|
|
242
|
-
return
|
|
256
|
+
return several(matches);
|
|
243
257
|
const name = wanted.slice(wanted.lastIndexOf('.') + 1);
|
|
244
258
|
const near = [...new Set(hitsFor(model, name, searchDefault).filter((h) => h.kind === 'table' || h.kind === 'view').map((h) => h.ref))].slice(0, 5);
|
|
245
259
|
return { problem: `"${written}" is not a table. ${near.length > 0 ? `Did you mean ${near.join(', ')}?` : 'overview lists them, and search finds one by any part of its name.'}` };
|
|
@@ -258,9 +272,11 @@ function quote(driver, name) {
|
|
|
258
272
|
return `\`${name.replace(/`/g, '``')}\``;
|
|
259
273
|
return `"${name.replace(/"/g, '""')}"`;
|
|
260
274
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
275
|
+
/** schema.table as its database names it, quoted: the SQL runs in the database the path was found in. */
|
|
276
|
+
function quoteRef(model, driver, ref, column) {
|
|
277
|
+
const local = localRef(model, ref);
|
|
278
|
+
const dot = local.indexOf('.');
|
|
279
|
+
const names = [local.slice(0, dot), local.slice(dot + 1), ...(column === undefined ? [] : [column])];
|
|
264
280
|
return names.map((name) => quote(driver, name)).join('.');
|
|
265
281
|
}
|
|
266
282
|
function pathText(model, from, to) {
|
|
@@ -283,13 +299,13 @@ function pathText(model, from, to) {
|
|
|
283
299
|
return `No relation connects ${from} and ${to}, declared or in the notes. If they are related all the same, add the relation to dbtour.notes.yaml so the docs and this path know it.`;
|
|
284
300
|
}
|
|
285
301
|
const { driver } = best.database;
|
|
286
|
-
const lines = [`FROM ${quoteRef(driver, from)}`];
|
|
302
|
+
const lines = [`FROM ${quoteRef(model, driver, from)}`];
|
|
287
303
|
let at = from;
|
|
288
304
|
for (const step of best.steps) {
|
|
289
305
|
const mine = step.forward ? step.relation.to.columns : step.relation.from.columns;
|
|
290
306
|
const theirs = step.forward ? step.relation.from.columns : step.relation.to.columns;
|
|
291
|
-
const on = mine.map((column, i) => `${quoteRef(driver, step.table, column)} = ${quoteRef(driver, at, theirs[i] ?? theirs[0])}`).join(' AND ');
|
|
292
|
-
lines.push(`JOIN ${quoteRef(driver, step.table)} ON ${on}${step.relation.virtual ? ' -- from the notes, not a declared key' : ''}`);
|
|
307
|
+
const on = mine.map((column, i) => `${quoteRef(model, driver, step.table, column)} = ${quoteRef(model, driver, at, theirs[i] ?? theirs[0])}`).join(' AND ');
|
|
308
|
+
lines.push(`JOIN ${quoteRef(model, driver, step.table)} ON ${on}${step.relation.virtual ? ' -- from the notes, not a declared key' : ''}`);
|
|
293
309
|
at = step.table;
|
|
294
310
|
}
|
|
295
311
|
const where = model.databases.length > 1 ? `In ${best.database.name}, ` : '';
|
package/dist/model.d.ts
CHANGED
|
@@ -201,11 +201,35 @@ export interface TableRef {
|
|
|
201
201
|
}
|
|
202
202
|
export declare function tableRef(schema: string, table: string): string;
|
|
203
203
|
export declare function parseRef(ref: string): TableRef;
|
|
204
|
+
/** Between a database and its schema in a ref: not a dot, which already parts schema, table and column. */
|
|
205
|
+
export declare const databaseSeparator = ":";
|
|
206
|
+
/**
|
|
207
|
+
* The model as everything reads it. Refs carry no database, so two databases with a dbo.Customers would
|
|
208
|
+
* share one name; with several databases, each schema is named `Database:schema` and every relation, group
|
|
209
|
+
* and link, and every history table, names its tables that way. Whatever builds a ref from a schema and a table then builds a name
|
|
210
|
+
* that is unique, and one database keeps the names it has always had. A group or link naming a table in
|
|
211
|
+
* both databases is left for the notes to report. It runs once, as the model is read: a schema a source
|
|
212
|
+
* itself named Sales:dbo becomes Sales:Sales:dbo, apart from its dbo, so nothing is taken as scoped already.
|
|
213
|
+
*/
|
|
214
|
+
export declare function scope(model: Model): Model;
|
|
215
|
+
/**
|
|
216
|
+
* A ref as its own database names it, for SQL that runs there: Sales:dbo.Orders is dbo.Orders. Only a
|
|
217
|
+
* scoped model's database is taken off, so with one database a colon in a name stays part of it.
|
|
218
|
+
*/
|
|
219
|
+
export declare function localRef(model: Model, ref: string): string;
|
|
204
220
|
export interface Found {
|
|
205
221
|
database: Database;
|
|
206
222
|
schema: Schema;
|
|
207
223
|
table: Table;
|
|
224
|
+
/** The table's name as the model writes it, with its database when the model is scoped. */
|
|
225
|
+
ref: string;
|
|
208
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Every table a ref can mean. One written with its database, or in a model of one database, means at most
|
|
229
|
+
* one; `dbo.Customers` in a scoped model means each database's dbo.Customers.
|
|
230
|
+
*/
|
|
231
|
+
export declare function findTables(model: Model, ref: string): Found[];
|
|
232
|
+
/** The one table a ref means; nothing when it means none, or several. */
|
|
209
233
|
export declare function findTable(model: Model, ref: string): Found | undefined;
|
|
210
234
|
/** Unique on its own, by a constraint or an index, and not the primary key, which says so already. */
|
|
211
235
|
export declare function isUniqueColumn(table: Table, column: string): boolean;
|
package/dist/model.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The model: what every source writes and everything else reads. Kept to plain JSON so it can be committed,
|
|
2
2
|
// diffed and handed to a page or an agent. Refs name tables as `schema.table` and columns as
|
|
3
|
-
// `schema.table.column
|
|
3
|
+
// `schema.table.column`; read through scope(), a model with several databases names them
|
|
4
|
+
// `Database:schema.table`.
|
|
4
5
|
export const MODEL_VERSION = 1;
|
|
5
6
|
export function emptyModel() {
|
|
6
7
|
return { dbtour: MODEL_VERSION, databases: [], apis: [], links: [], groups: [] };
|
|
@@ -16,18 +17,77 @@ export function parseRef(ref) {
|
|
|
16
17
|
const [schema, table, column] = parts;
|
|
17
18
|
return column === undefined ? { schema, table } : { schema, table, column };
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** Between a database and its schema in a ref: not a dot, which already parts schema, table and column. */
|
|
21
|
+
export const databaseSeparator = ':';
|
|
22
|
+
/**
|
|
23
|
+
* The model as everything reads it. Refs carry no database, so two databases with a dbo.Customers would
|
|
24
|
+
* share one name; with several databases, each schema is named `Database:schema` and every relation, group
|
|
25
|
+
* and link, and every history table, names its tables that way. Whatever builds a ref from a schema and a table then builds a name
|
|
26
|
+
* that is unique, and one database keeps the names it has always had. A group or link naming a table in
|
|
27
|
+
* both databases is left for the notes to report. It runs once, as the model is read: a schema a source
|
|
28
|
+
* itself named Sales:dbo becomes Sales:Sales:dbo, apart from its dbo, so nothing is taken as scoped already.
|
|
29
|
+
*/
|
|
30
|
+
export function scope(model) {
|
|
31
|
+
if (model.databases.length < 2)
|
|
32
|
+
return model;
|
|
33
|
+
const scoped = structuredClone(model);
|
|
34
|
+
for (const database of scoped.databases) {
|
|
35
|
+
const prefix = database.name + databaseSeparator;
|
|
22
36
|
for (const schema of database.schemas) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
schema.name = prefix + schema.name;
|
|
38
|
+
for (const table of schema.tables) {
|
|
39
|
+
if (table.temporal)
|
|
40
|
+
table.temporal.history = prefix + table.temporal.history;
|
|
41
|
+
if (table.historyOf)
|
|
42
|
+
table.historyOf = prefix + table.historyOf;
|
|
43
|
+
}
|
|
28
44
|
}
|
|
45
|
+
for (const relation of database.relations)
|
|
46
|
+
for (const end of [relation.from, relation.to])
|
|
47
|
+
end.table = prefix + end.table;
|
|
29
48
|
}
|
|
30
|
-
|
|
49
|
+
const qualify = (ref) => findTable(scoped, ref)?.ref ?? ref;
|
|
50
|
+
for (const group of scoped.groups)
|
|
51
|
+
group.tables = group.tables.map(qualify);
|
|
52
|
+
for (const link of scoped.links)
|
|
53
|
+
link.tables = link.tables.map(qualify);
|
|
54
|
+
return scoped;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A ref as its own database names it, for SQL that runs there: Sales:dbo.Orders is dbo.Orders. Only a
|
|
58
|
+
* scoped model's database is taken off, so with one database a colon in a name stays part of it.
|
|
59
|
+
*/
|
|
60
|
+
export function localRef(model, ref) {
|
|
61
|
+
if (model.databases.length < 2)
|
|
62
|
+
return ref;
|
|
63
|
+
const database = model.databases.find((d) => ref.startsWith(d.name + databaseSeparator));
|
|
64
|
+
return database ? ref.slice(database.name.length + databaseSeparator.length) : ref;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Every table a ref can mean. One written with its database, or in a model of one database, means at most
|
|
68
|
+
* one; `dbo.Customers` in a scoped model means each database's dbo.Customers.
|
|
69
|
+
*/
|
|
70
|
+
export function findTables(model, ref) {
|
|
71
|
+
const { schema: schemaName, table: tableName } = parseRef(ref);
|
|
72
|
+
const found = [];
|
|
73
|
+
for (const exact of [true, false]) {
|
|
74
|
+
for (const database of model.databases) {
|
|
75
|
+
for (const schema of database.schemas) {
|
|
76
|
+
const named = exact ? schema.name === schemaName : schema.name === database.name + databaseSeparator + schemaName;
|
|
77
|
+
const table = named ? schema.tables.find((t) => t.name === tableName) : undefined;
|
|
78
|
+
if (table)
|
|
79
|
+
found.push({ database, schema, table, ref: tableRef(schema.name, table.name) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (found.length > 0)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
/** The one table a ref means; nothing when it means none, or several. */
|
|
88
|
+
export function findTable(model, ref) {
|
|
89
|
+
const found = findTables(model, ref);
|
|
90
|
+
return found.length === 1 ? found[0] : undefined;
|
|
31
91
|
}
|
|
32
92
|
/** Unique on its own, by a constraint or an index, and not the primary key, which says so already. */
|
|
33
93
|
export function isUniqueColumn(table, column) {
|
|
@@ -94,6 +154,18 @@ export function validate(value) {
|
|
|
94
154
|
if (errors.length > 0)
|
|
95
155
|
return { ok: false, errors };
|
|
96
156
|
const typed = value;
|
|
157
|
+
// Several databases are told apart by name in their refs (see scope), so each needs one of its own that a
|
|
158
|
+
// ref can carry.
|
|
159
|
+
if (typed.databases.length > 1) {
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
for (const { name } of typed.databases) {
|
|
162
|
+
if (seen.has(name))
|
|
163
|
+
errors.push(`two databases are named ${name}: give each source its own name in dbtour.yaml`);
|
|
164
|
+
seen.add(name);
|
|
165
|
+
if (/[.:/\\]/.test(name))
|
|
166
|
+
errors.push(`the database name ${name} has a dot, a colon or a slash, which a ref or a file name would read as a separator: give its source another name in dbtour.yaml`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
97
169
|
typed.databases.forEach((database, i) => {
|
|
98
170
|
const refs = new Set(database.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
|
|
99
171
|
database.relations.forEach((relation, j) => {
|
package/dist/notes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The notes file: what people add that the database cannot say. Merged into the model at render time and
|
|
2
2
|
// winning over it, so a production database nobody may alter still gets documented.
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
|
-
import {
|
|
4
|
+
import { findTables, parseRef } from "./model.js";
|
|
5
5
|
export function parseNotes(text) {
|
|
6
6
|
const raw = parse(text) ?? {};
|
|
7
7
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
@@ -64,12 +64,21 @@ export function parseNotes(text) {
|
|
|
64
64
|
export function applyNotes(input, notes) {
|
|
65
65
|
const model = structuredClone(input);
|
|
66
66
|
const warnings = [];
|
|
67
|
+
// The one table a name in the notes means, or a warning: a name two databases share wants its database.
|
|
68
|
+
const find = (ref, what) => {
|
|
69
|
+
const found = findTables(model, ref);
|
|
70
|
+
if (found.length === 1)
|
|
71
|
+
return found[0];
|
|
72
|
+
if (found.length === 0)
|
|
73
|
+
warnings.push(`notes: ${what}`);
|
|
74
|
+
else
|
|
75
|
+
warnings.push(`notes: ${parseRef(ref).schema}.${parseRef(ref).table} is in ${found.map((f) => f.database.name).join(' and ')}: write ${found.map((f) => f.ref).join(' or ')}`);
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
67
78
|
for (const [ref, tableNotes] of Object.entries(notes.tables)) {
|
|
68
|
-
const found =
|
|
69
|
-
if (!found)
|
|
70
|
-
warnings.push(`notes: ${ref} is not a table in the model`);
|
|
79
|
+
const found = find(ref, `${ref} is not a table in the model`);
|
|
80
|
+
if (!found)
|
|
71
81
|
continue;
|
|
72
|
-
}
|
|
73
82
|
if (tableNotes.description !== undefined)
|
|
74
83
|
found.table.description = tableNotes.description;
|
|
75
84
|
for (const [name, description] of Object.entries(tableNotes.columns ?? {})) {
|
|
@@ -82,30 +91,26 @@ export function applyNotes(input, notes) {
|
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
for (const note of notes.relations) {
|
|
85
|
-
const
|
|
86
|
-
const
|
|
94
|
+
const missing = `relation ${note.from} → ${note.to} names a table that is not in the model`;
|
|
95
|
+
const fromTable = find(note.from, missing);
|
|
96
|
+
const toTable = fromTable && find(note.to, missing);
|
|
97
|
+
if (!fromTable || !toTable)
|
|
98
|
+
continue;
|
|
99
|
+
if (fromTable.database !== toTable.database) {
|
|
100
|
+
warnings.push(`notes: relation ${note.from} → ${note.to} joins ${fromTable.database.name} to ${toTable.database.name}: relations stay inside one database`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
87
103
|
const relation = {
|
|
88
|
-
from: { table:
|
|
89
|
-
to: { table:
|
|
104
|
+
from: { table: fromTable.ref, columns: [parseRef(note.from).column] },
|
|
105
|
+
to: { table: toTable.ref, columns: [parseRef(note.to).column] },
|
|
90
106
|
virtual: true,
|
|
91
107
|
};
|
|
92
|
-
const fromTable = findTable(model, relation.from.table);
|
|
93
|
-
const toTable = findTable(model, relation.to.table);
|
|
94
|
-
if (!fromTable || !toTable) {
|
|
95
|
-
warnings.push(`notes: relation ${note.from} → ${note.to} names a table that is not in the model`);
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
108
|
const exists = fromTable.database.relations.some((r) => sameEnd(r.from, relation.from) && sameEnd(r.to, relation.to));
|
|
99
109
|
if (!exists)
|
|
100
110
|
fromTable.database.relations.push(relation);
|
|
101
111
|
}
|
|
102
112
|
for (const group of notes.groups) {
|
|
103
|
-
const tables = group.tables.
|
|
104
|
-
if (findTable(model, ref))
|
|
105
|
-
return true;
|
|
106
|
-
warnings.push(`notes: group ${group.name} lists ${ref}, which is not a table in the model`);
|
|
107
|
-
return false;
|
|
108
|
-
});
|
|
113
|
+
const tables = group.tables.flatMap((ref) => find(ref, `group ${group.name} lists ${ref}, which is not a table in the model`)?.ref ?? []);
|
|
109
114
|
model.groups.push({ ...group, tables });
|
|
110
115
|
}
|
|
111
116
|
return { model, warnings };
|
|
@@ -18,6 +18,8 @@ export declare function formatCount(n: number): string;
|
|
|
18
18
|
export declare function formatBytes(bytes: number): string;
|
|
19
19
|
/** The sentence under a table's title: the count is the database's estimate, so it says about, and when. */
|
|
20
20
|
export declare function statsLine(stats: TableStats, readAt?: string): string;
|
|
21
|
+
/** A table's page under tables/: Sales:dbo.Orders is in a folder for its database, dbo.Orders is not. */
|
|
22
|
+
export declare function tableFile(ref: string): string;
|
|
21
23
|
/** One table's page on its own: what the page hands to an agent. */
|
|
22
24
|
export declare function renderTableMarkdown(model: Model, ref: string): string;
|
|
23
25
|
export { findTable };
|
package/dist/render/markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Markdown GitHub shows: an index with every table, and one page per table with its columns, keys,
|
|
2
2
|
// relations both ways, indexes, constraints and the diagram of its neighbourhood.
|
|
3
|
-
import { findTable, isUniqueColumn, relationsOf, tableRef } from "../model.js";
|
|
3
|
+
import { databaseSeparator, findTable, isUniqueColumn, relationsOf, tableRef } from "../model.js";
|
|
4
4
|
import { erDiagram, neighbourhood } from "./mermaid.js";
|
|
5
5
|
// Mermaid lays out a whole database only while it is small; above this many tables the index shows the
|
|
6
6
|
// groups and each table page shows its own neighbourhood instead.
|
|
@@ -11,7 +11,7 @@ export function renderMarkdown(model) {
|
|
|
11
11
|
for (const schema of database.schemas) {
|
|
12
12
|
for (const table of schema.tables) {
|
|
13
13
|
const ref = tableRef(schema.name, table.name);
|
|
14
|
-
files[`tables/${ref}
|
|
14
|
+
files[`tables/${tableFile(ref)}`] = renderTable(model, ref, table, database);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -36,7 +36,7 @@ function renderIndex(model) {
|
|
|
36
36
|
for (const table of schema.tables) {
|
|
37
37
|
const ref = tableRef(schema.name, table.name);
|
|
38
38
|
const size = !sized ? '' : table.stats ? ` ${formatCount(table.stats.rows)} | ${formatBytes(table.stats.dataBytes + table.stats.indexBytes)} |` : ' | |';
|
|
39
|
-
parts.push(`| [${ref}](tables/${ref}
|
|
39
|
+
parts.push(`| [${ref}](${href(`tables/${tableFile(ref)}`)}) | ${table.kind} | ${table.columns.length} |${size} ${cell(table.description)} |`);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
parts.push('');
|
|
@@ -49,7 +49,7 @@ function renderIndex(model) {
|
|
|
49
49
|
parts.push(`## ${group.name}\n`);
|
|
50
50
|
if (group.description)
|
|
51
51
|
parts.push(`${group.description}\n`);
|
|
52
|
-
parts.push(group.tables.map((ref) => `- [${ref}](tables/${ref}
|
|
52
|
+
parts.push(group.tables.map((ref) => `- [${ref}](${href(`tables/${tableFile(ref)}`)})`).join('\n') + '\n');
|
|
53
53
|
parts.push(mermaid(erDiagram(model, group.tables)));
|
|
54
54
|
}
|
|
55
55
|
const refs = model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
|
|
@@ -166,12 +166,28 @@ export function statsLine(stats, readAt) {
|
|
|
166
166
|
const when = readAt ? `, as the database counted them on ${readAt.slice(0, 10)}` : '';
|
|
167
167
|
return `About ${formatCount(stats.rows)} rows · ${formatBytes(stats.dataBytes)} of data · ${formatBytes(stats.indexBytes)} of indexes${when}.`;
|
|
168
168
|
}
|
|
169
|
+
/** A table's page under tables/: Sales:dbo.Orders is in a folder for its database, dbo.Orders is not. */
|
|
170
|
+
export function tableFile(ref) {
|
|
171
|
+
return ref.replace(databaseSeparator, '/') + '.md';
|
|
172
|
+
}
|
|
173
|
+
/** A link from one table's page to another's: the file beside it, or up and across to another folder. */
|
|
174
|
+
function pageLink(from, to) {
|
|
175
|
+
const folder = (ref) => (ref.includes(databaseSeparator) ? ref.slice(0, ref.indexOf(databaseSeparator)) : '');
|
|
176
|
+
const file = tableFile(to);
|
|
177
|
+
if (folder(from) === folder(to))
|
|
178
|
+
return href(file.slice(file.indexOf('/') + 1));
|
|
179
|
+
return href((folder(from) ? '../' : '') + file);
|
|
180
|
+
}
|
|
181
|
+
/** A path as a link's destination: each part encoded, so a space or a # in a name still reaches its file. */
|
|
182
|
+
function href(path) {
|
|
183
|
+
return path.split('/').map((part) => (part === '..' ? part : encodeURIComponent(part))).join('/');
|
|
184
|
+
}
|
|
169
185
|
/** One table's page on its own: what the page hands to an agent. */
|
|
170
186
|
export function renderTableMarkdown(model, ref) {
|
|
171
187
|
const found = findTable(model, ref);
|
|
172
188
|
if (!found)
|
|
173
189
|
throw new Error(`${ref} is not a table in the model`);
|
|
174
|
-
return renderTable(model, ref, found.table, found.database);
|
|
190
|
+
return renderTable(model, found.ref, found.table, found.database);
|
|
175
191
|
}
|
|
176
192
|
function renderTable(model, ref, table, database) {
|
|
177
193
|
const parts = [`# ${ref}\n`];
|
|
@@ -183,7 +199,7 @@ function renderTable(model, ref, table, database) {
|
|
|
183
199
|
if (listed.length > 0) {
|
|
184
200
|
parts.push('## Features\n');
|
|
185
201
|
// A table the feature names gets a link when it is in the model, as a relation's would.
|
|
186
|
-
const linked = (f) => (f.ref && f.detail && findTable(model, f.ref) ? f.detail.replace(f.ref, `[${f.ref}](${f.ref}
|
|
202
|
+
const linked = (f) => (f.ref && f.detail && findTable(model, f.ref) ? f.detail.replace(f.ref, `[${f.ref}](${pageLink(ref, f.ref)})`) : f.detail);
|
|
187
203
|
parts.push(listed.map((f) => `- **${f.name}**${f.detail ? `: ${linked(f)}` : ''}`).join('\n') + '\n');
|
|
188
204
|
}
|
|
189
205
|
const relations = relationsOf(model, ref);
|
|
@@ -198,7 +214,7 @@ function renderTable(model, ref, table, database) {
|
|
|
198
214
|
continue;
|
|
199
215
|
const { table: toTable, columns } = relation.to;
|
|
200
216
|
const state = [relation.virtual ? 'notes' : '', ...enforcement(relation)].filter(Boolean);
|
|
201
|
-
keys.push(`FK → [${toTable}](${toTable}
|
|
217
|
+
keys.push(`FK → [${toTable}](${pageLink(ref, toTable)}).${columns[i] ?? columns.join(',')}${state.length ? ` (${state.join(', ')})` : ''}`);
|
|
202
218
|
}
|
|
203
219
|
if (isUniqueColumn(table, column.name))
|
|
204
220
|
keys.push('unique');
|
|
@@ -210,7 +226,7 @@ function renderTable(model, ref, table, database) {
|
|
|
210
226
|
if (relations.in.length > 0) {
|
|
211
227
|
parts.push('## Referenced by\n');
|
|
212
228
|
const lines = relations.in
|
|
213
|
-
.map((r) => `- [${r.from.table}](${r.from.table}
|
|
229
|
+
.map((r) => `- [${r.from.table}](${pageLink(ref, r.from.table)}).${r.from.columns.join(', ')}${r.virtual ? ' (notes)' : ''}`)
|
|
214
230
|
.sort();
|
|
215
231
|
parts.push(lines.join('\n') + '\n');
|
|
216
232
|
}
|
package/package.json
CHANGED