dbtour 0.2.4 → 0.4.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 +3 -0
- package/dist/model.d.ts +103 -0
- package/dist/model.js +93 -0
- package/dist/render/markdown.d.ts +16 -1
- package/dist/render/markdown.js +155 -15
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -89,6 +89,9 @@ async function extract(io) {
|
|
|
89
89
|
model.databases.push(result);
|
|
90
90
|
const tables = result.schemas.reduce((n, s) => n + s.tables.length, 0);
|
|
91
91
|
io.log(`${result.name}: ${tables} tables, ${result.relations.length} relations`);
|
|
92
|
+
// Not a failure: the rest was read, and an absence the model records is not taken for a no.
|
|
93
|
+
for (const reason of result.unread ?? [])
|
|
94
|
+
io.log(`${result.name}: not read: ${reason}. Grant it to read them, or leave them out.`);
|
|
92
95
|
}
|
|
93
96
|
else {
|
|
94
97
|
model.apis.push(result);
|
package/dist/model.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export declare const MODEL_VERSION = 1;
|
|
2
|
+
/**
|
|
3
|
+
* Extended properties other than MS_Description (which is `description`), by name: what a team records about
|
|
4
|
+
* an object in the database itself, such as its owner, source or retention.
|
|
5
|
+
*/
|
|
6
|
+
export type Properties = Record<string, string>;
|
|
7
|
+
/** A column's sensitivity classification, as SQL Server's data discovery or a team set it. */
|
|
8
|
+
export interface Sensitivity {
|
|
9
|
+
label?: string;
|
|
10
|
+
informationType?: string;
|
|
11
|
+
rank?: string;
|
|
12
|
+
}
|
|
2
13
|
export interface Column {
|
|
3
14
|
name: string;
|
|
4
15
|
/** As the database spells it, with its size: nvarchar(320), decimal(18,2). */
|
|
@@ -8,19 +19,73 @@ export interface Column {
|
|
|
8
19
|
/** The expression of a computed column. */
|
|
9
20
|
computed?: string;
|
|
10
21
|
identity?: boolean;
|
|
22
|
+
/** An identity's seed and increment, as the database spells them. */
|
|
23
|
+
identitySeed?: string;
|
|
24
|
+
identityIncrement?: string;
|
|
11
25
|
description?: string;
|
|
26
|
+
/** Only where it differs from the database's collation. */
|
|
27
|
+
collation?: string;
|
|
28
|
+
sparse?: boolean;
|
|
29
|
+
/** The dynamic data masking function: default(), email(), partial(2,"XX",0). */
|
|
30
|
+
masked?: string;
|
|
31
|
+
/** Always Encrypted: how, and under which column encryption key. */
|
|
32
|
+
encrypted?: {
|
|
33
|
+
type: 'deterministic' | 'randomized';
|
|
34
|
+
key: string;
|
|
35
|
+
};
|
|
36
|
+
sensitivity?: Sensitivity;
|
|
37
|
+
properties?: Properties;
|
|
12
38
|
}
|
|
13
39
|
export interface Index {
|
|
14
40
|
name: string;
|
|
15
41
|
columns: string[];
|
|
16
42
|
unique: boolean;
|
|
17
43
|
included?: string[];
|
|
44
|
+
/** As the database names the kind: nonclustered, clustered columnstore, xml, spatial. */
|
|
45
|
+
type?: string;
|
|
46
|
+
/** A filtered index's WHERE. */
|
|
47
|
+
filter?: string;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
properties?: Properties;
|
|
18
50
|
}
|
|
19
51
|
export interface Constraint {
|
|
20
52
|
name: string;
|
|
21
53
|
kind: 'primary' | 'unique' | 'check' | 'foreign';
|
|
22
54
|
columns: string[];
|
|
23
55
|
definition?: string;
|
|
56
|
+
/** A foreign key or check the database no longer enforces. */
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** Enforced, but not checked against the rows already there, so the optimiser cannot rely on it. */
|
|
59
|
+
untrusted?: boolean;
|
|
60
|
+
properties?: Properties;
|
|
61
|
+
}
|
|
62
|
+
export interface Trigger {
|
|
63
|
+
name: string;
|
|
64
|
+
timing: 'after' | 'instead of';
|
|
65
|
+
/** insert, update, delete. */
|
|
66
|
+
events: string[];
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
definition?: string;
|
|
69
|
+
}
|
|
70
|
+
/** A row-level security predicate on the table: a filter hides rows, a block refuses writes. */
|
|
71
|
+
export interface SecurityPredicate {
|
|
72
|
+
policy: string;
|
|
73
|
+
kind: 'filter' | 'block';
|
|
74
|
+
/** For a block predicate: after insert, before update, and so on. */
|
|
75
|
+
operation?: string;
|
|
76
|
+
predicate: string;
|
|
77
|
+
enabled: boolean;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* How big a table is, as the database keeps it about itself: counted from its catalogue, never by reading
|
|
81
|
+
* rows, so the count is the database's own estimate. Absent when the source could not read it.
|
|
82
|
+
*/
|
|
83
|
+
export interface TableStats {
|
|
84
|
+
rows: number;
|
|
85
|
+
/** The pages the rows take, the heap or clustered index, in bytes. */
|
|
86
|
+
dataBytes: number;
|
|
87
|
+
/** The pages every other index takes, in bytes. */
|
|
88
|
+
indexBytes: number;
|
|
24
89
|
}
|
|
25
90
|
export interface Table {
|
|
26
91
|
name: string;
|
|
@@ -32,10 +97,38 @@ export interface Table {
|
|
|
32
97
|
constraints: Constraint[];
|
|
33
98
|
/** The SQL of a view. */
|
|
34
99
|
definition?: string;
|
|
100
|
+
stats?: TableStats;
|
|
101
|
+
properties?: Properties;
|
|
102
|
+
/** When the table was created and its definition last altered, as ISO timestamps. */
|
|
103
|
+
created?: string;
|
|
104
|
+
altered?: string;
|
|
105
|
+
/** A system-versioned table: its history table (a ref) and the columns that bound each row's period. */
|
|
106
|
+
temporal?: {
|
|
107
|
+
history: string;
|
|
108
|
+
start: string;
|
|
109
|
+
end: string;
|
|
110
|
+
};
|
|
111
|
+
/** The history table of a system-versioned table, as its ref. */
|
|
112
|
+
historyOf?: string;
|
|
113
|
+
memoryOptimised?: 'schema and data' | 'schema only';
|
|
114
|
+
ledger?: 'updatable' | 'append-only' | 'history';
|
|
115
|
+
partitioning?: {
|
|
116
|
+
scheme: string;
|
|
117
|
+
function: string;
|
|
118
|
+
column: string;
|
|
119
|
+
partitions: number;
|
|
120
|
+
};
|
|
121
|
+
/** The table's own compression, when it has any: row, page, columnstore, columnstore archive. */
|
|
122
|
+
compression?: string;
|
|
123
|
+
changeTracking?: boolean;
|
|
124
|
+
cdc?: boolean;
|
|
125
|
+
triggers?: Trigger[];
|
|
126
|
+
security?: SecurityPredicate[];
|
|
35
127
|
}
|
|
36
128
|
export interface Schema {
|
|
37
129
|
name: string;
|
|
38
130
|
tables: Table[];
|
|
131
|
+
properties?: Properties;
|
|
39
132
|
}
|
|
40
133
|
export interface RelationEnd {
|
|
41
134
|
table: string;
|
|
@@ -49,12 +142,22 @@ export interface Relation {
|
|
|
49
142
|
virtual?: boolean;
|
|
50
143
|
onDelete?: string;
|
|
51
144
|
onUpdate?: string;
|
|
145
|
+
/** As on its constraint: not enforced, or not checked against the rows already there. */
|
|
146
|
+
disabled?: boolean;
|
|
147
|
+
untrusted?: boolean;
|
|
52
148
|
}
|
|
53
149
|
export interface Database {
|
|
54
150
|
name: string;
|
|
55
151
|
driver: string;
|
|
56
152
|
version?: string;
|
|
57
153
|
description?: string;
|
|
154
|
+
/** When the source read its tables' stats, as an ISO timestamp: they change, so they carry their moment. */
|
|
155
|
+
statsReadAt?: string;
|
|
156
|
+
/** The default collation; a column says its own only where it differs. */
|
|
157
|
+
collation?: string;
|
|
158
|
+
properties?: Properties;
|
|
159
|
+
/** What the source could not read and why, so a reader knows an absence is not a no. */
|
|
160
|
+
unread?: string[];
|
|
58
161
|
schemas: Schema[];
|
|
59
162
|
relations: Relation[];
|
|
60
163
|
}
|
package/dist/model.js
CHANGED
|
@@ -113,10 +113,16 @@ function checkDatabase(check, d, p) {
|
|
|
113
113
|
check.string(d.driver, `${p}.driver`);
|
|
114
114
|
check.optionalString(d.version, `${p}.version`);
|
|
115
115
|
check.optionalString(d.description, `${p}.description`);
|
|
116
|
+
check.optionalString(d.statsReadAt, `${p}.statsReadAt`);
|
|
117
|
+
check.optionalString(d.collation, `${p}.collation`);
|
|
118
|
+
check.properties(d.properties, `${p}.properties`);
|
|
119
|
+
if (d.unread !== undefined)
|
|
120
|
+
check.strings(d.unread, `${p}.unread`);
|
|
116
121
|
check.list(d.schemas, `${p}.schemas`, (s, sp) => {
|
|
117
122
|
if (!check.object(s, sp))
|
|
118
123
|
return;
|
|
119
124
|
check.string(s.name, `${sp}.name`);
|
|
125
|
+
check.properties(s.properties, `${sp}.properties`);
|
|
120
126
|
check.list(s.tables, `${sp}.tables`, (t, tp) => checkTable(check, t, tp));
|
|
121
127
|
});
|
|
122
128
|
check.list(d.relations, `${p}.relations`, (r, rp) => {
|
|
@@ -131,6 +137,8 @@ function checkDatabase(check, d, p) {
|
|
|
131
137
|
check.strings(e.columns, `${rp}.${end}.columns`);
|
|
132
138
|
}
|
|
133
139
|
check.optionalBoolean(r.virtual, `${rp}.virtual`);
|
|
140
|
+
check.optionalBoolean(r.disabled, `${rp}.disabled`);
|
|
141
|
+
check.optionalBoolean(r.untrusted, `${rp}.untrusted`);
|
|
134
142
|
});
|
|
135
143
|
}
|
|
136
144
|
function checkTable(check, t, p) {
|
|
@@ -141,8 +149,13 @@ function checkTable(check, t, p) {
|
|
|
141
149
|
check.errors.push(`${p}.kind must be "table" or "view"`);
|
|
142
150
|
check.optionalString(t.description, `${p}.description`);
|
|
143
151
|
check.optionalString(t.definition, `${p}.definition`);
|
|
152
|
+
if (t.stats !== undefined && check.object(t.stats, `${p}.stats`)) {
|
|
153
|
+
for (const field of ['rows', 'dataBytes', 'indexBytes'])
|
|
154
|
+
check.count(t.stats[field], `${p}.stats.${field}`);
|
|
155
|
+
}
|
|
144
156
|
if (t.primaryKey !== undefined)
|
|
145
157
|
check.strings(t.primaryKey, `${p}.primaryKey`);
|
|
158
|
+
checkCatalogue(check, t, p);
|
|
146
159
|
check.list(t.columns, `${p}.columns`, (c, cp) => {
|
|
147
160
|
if (!check.object(c, cp))
|
|
148
161
|
return;
|
|
@@ -154,6 +167,18 @@ function checkTable(check, t, p) {
|
|
|
154
167
|
check.optionalString(c.computed, `${cp}.computed`);
|
|
155
168
|
check.optionalBoolean(c.identity, `${cp}.identity`);
|
|
156
169
|
check.optionalString(c.description, `${cp}.description`);
|
|
170
|
+
for (const field of ['identitySeed', 'identityIncrement', 'collation', 'masked'])
|
|
171
|
+
check.optionalString(c[field], `${cp}.${field}`);
|
|
172
|
+
check.optionalBoolean(c.sparse, `${cp}.sparse`);
|
|
173
|
+
check.properties(c.properties, `${cp}.properties`);
|
|
174
|
+
if (c.encrypted !== undefined && check.object(c.encrypted, `${cp}.encrypted`)) {
|
|
175
|
+
check.oneOf(c.encrypted.type, ['deterministic', 'randomized'], `${cp}.encrypted.type`);
|
|
176
|
+
check.string(c.encrypted.key, `${cp}.encrypted.key`);
|
|
177
|
+
}
|
|
178
|
+
if (c.sensitivity !== undefined && check.object(c.sensitivity, `${cp}.sensitivity`)) {
|
|
179
|
+
for (const field of ['label', 'informationType', 'rank'])
|
|
180
|
+
check.optionalString(c.sensitivity[field], `${cp}.sensitivity.${field}`);
|
|
181
|
+
}
|
|
157
182
|
});
|
|
158
183
|
check.list(t.indexes, `${p}.indexes`, (x, xp) => {
|
|
159
184
|
if (!check.object(x, xp))
|
|
@@ -162,6 +187,10 @@ function checkTable(check, t, p) {
|
|
|
162
187
|
check.strings(x.columns, `${xp}.columns`);
|
|
163
188
|
if (typeof x.unique !== 'boolean')
|
|
164
189
|
check.errors.push(`${xp}.unique must be true or false`);
|
|
190
|
+
check.optionalString(x.type, `${xp}.type`);
|
|
191
|
+
check.optionalString(x.filter, `${xp}.filter`);
|
|
192
|
+
check.optionalBoolean(x.disabled, `${xp}.disabled`);
|
|
193
|
+
check.properties(x.properties, `${xp}.properties`);
|
|
165
194
|
});
|
|
166
195
|
check.list(t.constraints, `${p}.constraints`, (c, cp) => {
|
|
167
196
|
if (!check.object(c, cp))
|
|
@@ -171,8 +200,56 @@ function checkTable(check, t, p) {
|
|
|
171
200
|
check.errors.push(`${cp}.kind is not a constraint kind`);
|
|
172
201
|
check.strings(c.columns, `${cp}.columns`);
|
|
173
202
|
check.optionalString(c.definition, `${cp}.definition`);
|
|
203
|
+
check.optionalBoolean(c.disabled, `${cp}.disabled`);
|
|
204
|
+
check.optionalBoolean(c.untrusted, `${cp}.untrusted`);
|
|
205
|
+
check.properties(c.properties, `${cp}.properties`);
|
|
174
206
|
});
|
|
175
207
|
}
|
|
208
|
+
/** What the catalogue says about a table beyond its columns, keys and indexes. */
|
|
209
|
+
function checkCatalogue(check, t, p) {
|
|
210
|
+
check.properties(t.properties, `${p}.properties`);
|
|
211
|
+
for (const field of ['created', 'altered', 'historyOf', 'compression'])
|
|
212
|
+
check.optionalString(t[field], `${p}.${field}`);
|
|
213
|
+
for (const field of ['changeTracking', 'cdc'])
|
|
214
|
+
check.optionalBoolean(t[field], `${p}.${field}`);
|
|
215
|
+
if (t.memoryOptimised !== undefined)
|
|
216
|
+
check.oneOf(t.memoryOptimised, ['schema and data', 'schema only'], `${p}.memoryOptimised`);
|
|
217
|
+
if (t.ledger !== undefined)
|
|
218
|
+
check.oneOf(t.ledger, ['updatable', 'append-only', 'history'], `${p}.ledger`);
|
|
219
|
+
if (t.temporal !== undefined && check.object(t.temporal, `${p}.temporal`)) {
|
|
220
|
+
for (const field of ['history', 'start', 'end'])
|
|
221
|
+
check.string(t.temporal[field], `${p}.temporal.${field}`);
|
|
222
|
+
}
|
|
223
|
+
if (t.partitioning !== undefined && check.object(t.partitioning, `${p}.partitioning`)) {
|
|
224
|
+
for (const field of ['scheme', 'function', 'column'])
|
|
225
|
+
check.string(t.partitioning[field], `${p}.partitioning.${field}`);
|
|
226
|
+
check.count(t.partitioning.partitions, `${p}.partitioning.partitions`);
|
|
227
|
+
}
|
|
228
|
+
if (t.triggers !== undefined) {
|
|
229
|
+
check.list(t.triggers, `${p}.triggers`, (g, gp) => {
|
|
230
|
+
if (!check.object(g, gp))
|
|
231
|
+
return;
|
|
232
|
+
check.string(g.name, `${gp}.name`);
|
|
233
|
+
check.oneOf(g.timing, ['after', 'instead of'], `${gp}.timing`);
|
|
234
|
+
check.strings(g.events, `${gp}.events`);
|
|
235
|
+
if (typeof g.enabled !== 'boolean')
|
|
236
|
+
check.errors.push(`${gp}.enabled must be true or false`);
|
|
237
|
+
check.optionalString(g.definition, `${gp}.definition`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (t.security !== undefined) {
|
|
241
|
+
check.list(t.security, `${p}.security`, (r, rp) => {
|
|
242
|
+
if (!check.object(r, rp))
|
|
243
|
+
return;
|
|
244
|
+
check.string(r.policy, `${rp}.policy`);
|
|
245
|
+
check.oneOf(r.kind, ['filter', 'block'], `${rp}.kind`);
|
|
246
|
+
check.optionalString(r.operation, `${rp}.operation`);
|
|
247
|
+
check.string(r.predicate, `${rp}.predicate`);
|
|
248
|
+
if (typeof r.enabled !== 'boolean')
|
|
249
|
+
check.errors.push(`${rp}.enabled must be true or false`);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
176
253
|
class Checker {
|
|
177
254
|
errors;
|
|
178
255
|
constructor(errors) {
|
|
@@ -196,6 +273,22 @@ class Checker {
|
|
|
196
273
|
if (value !== undefined && typeof value !== 'boolean')
|
|
197
274
|
this.errors.push(`${path} must be true or false when given`);
|
|
198
275
|
}
|
|
276
|
+
properties(value, path) {
|
|
277
|
+
if (value === undefined || !this.object(value, path))
|
|
278
|
+
return;
|
|
279
|
+
for (const [name, text] of Object.entries(value)) {
|
|
280
|
+
if (typeof text !== 'string')
|
|
281
|
+
this.errors.push(`${path}.${name} must be a string`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
oneOf(value, allowed, path) {
|
|
285
|
+
if (!allowed.includes(value))
|
|
286
|
+
this.errors.push(`${path} must be ${allowed.map((a) => `"${a}"`).join(' or ')}`);
|
|
287
|
+
}
|
|
288
|
+
count(value, path) {
|
|
289
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
290
|
+
this.errors.push(`${path} must be a number of 0 or more`);
|
|
291
|
+
}
|
|
199
292
|
strings(value, path) {
|
|
200
293
|
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
201
294
|
this.errors.push(`${path} must be a list of strings`);
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import { findTable, type Model } from '../model.ts';
|
|
1
|
+
import { findTable, type Model, type Table, type TableStats } from '../model.ts';
|
|
2
2
|
export declare const wholeDiagramLimit = 40;
|
|
3
3
|
export declare function renderMarkdown(model: Model): Record<string, string>;
|
|
4
4
|
/** The product's name for a driver: mssql is SQL Server. */
|
|
5
5
|
export declare function driverName(driver: string): string;
|
|
6
|
+
/** A thing the catalogue says about a table beyond its columns and keys: a name, and its particulars. */
|
|
7
|
+
export interface Feature {
|
|
8
|
+
name: string;
|
|
9
|
+
detail?: string;
|
|
10
|
+
/** A table the detail names, for a link. */
|
|
11
|
+
ref?: string;
|
|
12
|
+
}
|
|
13
|
+
/** What the catalogue says about a table beyond its columns and keys, in the order a reader weighs it. */
|
|
14
|
+
export declare function features(table: Table): Feature[];
|
|
15
|
+
/** 1,204,377: thousands grouped the same way wherever it runs, so the docs do not change with the machine. */
|
|
16
|
+
export declare function formatCount(n: number): string;
|
|
17
|
+
/** 480 KB, 7.1 MB, 102 MB: binary units, as the database counts its pages, with one decimal below ten. */
|
|
18
|
+
export declare function formatBytes(bytes: number): string;
|
|
19
|
+
/** The sentence under a table's title: the count is the database's estimate, so it says about, and when. */
|
|
20
|
+
export declare function statsLine(stats: TableStats, readAt?: string): string;
|
|
6
21
|
/** One table's page on its own: what the page hands to an agent. */
|
|
7
22
|
export declare function renderTableMarkdown(model: Model, ref: string): string;
|
|
8
23
|
export { findTable };
|
package/dist/render/markdown.js
CHANGED
|
@@ -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}.md`] = renderTable(model, ref, table);
|
|
14
|
+
files[`tables/${ref}.md`] = renderTable(model, ref, table, database);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -27,15 +27,24 @@ function renderIndex(model) {
|
|
|
27
27
|
if (database.description)
|
|
28
28
|
parts.push(`${database.description}\n`);
|
|
29
29
|
parts.push(`${driverLine(database)}\n`);
|
|
30
|
-
|
|
30
|
+
if (database.unread?.length)
|
|
31
|
+
parts.push(`Not read: ${database.unread.join('; ')}.\n`);
|
|
32
|
+
// Rows and size only when the source read them; a view, which keeps no rows of its own, leaves them blank.
|
|
33
|
+
const sized = database.schemas.some((s) => s.tables.some((t) => t.stats));
|
|
34
|
+
parts.push(sized ? '| Table | Kind | Columns | Rows | Size | Description |\n|---|---|---|---|---|---|' : '| Table | Kind | Columns | Description |\n|---|---|---|---|');
|
|
31
35
|
for (const schema of database.schemas) {
|
|
32
36
|
for (const table of schema.tables) {
|
|
33
37
|
const ref = tableRef(schema.name, table.name);
|
|
34
|
-
|
|
38
|
+
const size = !sized ? '' : table.stats ? ` ${formatCount(table.stats.rows)} | ${formatBytes(table.stats.dataBytes + table.stats.indexBytes)} |` : ' | |';
|
|
39
|
+
parts.push(`| [${ref}](tables/${ref}.md) | ${table.kind} | ${table.columns.length} |${size} ${cell(table.description)} |`);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
parts.push('');
|
|
38
43
|
}
|
|
44
|
+
const owned = model.databases.flatMap((d) => [[d.name, d.properties], ...d.schemas.map((s) => [s.name, s.properties])]);
|
|
45
|
+
const properties = propertiesTable(owned);
|
|
46
|
+
if (properties)
|
|
47
|
+
parts.push(`## Properties\n\n${properties}`);
|
|
39
48
|
for (const group of model.groups) {
|
|
40
49
|
parts.push(`## ${group.name}\n`);
|
|
41
50
|
if (group.description)
|
|
@@ -60,19 +69,123 @@ export function driverName(driver) {
|
|
|
60
69
|
}
|
|
61
70
|
function driverLine(database) {
|
|
62
71
|
const name = driverName(database.driver);
|
|
63
|
-
|
|
72
|
+
const collation = database.collation ? `, collation ${database.collation}` : '';
|
|
73
|
+
return `${database.version ? `${name} ${database.version}` : name}${collation}.`;
|
|
74
|
+
}
|
|
75
|
+
/** What the catalogue says about a table beyond its columns and keys, in the order a reader weighs it. */
|
|
76
|
+
export function features(table) {
|
|
77
|
+
const list = [];
|
|
78
|
+
if (table.temporal) {
|
|
79
|
+
const { history, start, end } = table.temporal;
|
|
80
|
+
list.push({ name: 'System-versioned', detail: `history in ${history}, each row valid from ${start} to ${end}`, ref: history });
|
|
81
|
+
}
|
|
82
|
+
if (table.historyOf)
|
|
83
|
+
list.push({ name: 'History', detail: `of ${table.historyOf}`, ref: table.historyOf });
|
|
84
|
+
if (table.memoryOptimised)
|
|
85
|
+
list.push({ name: 'Memory-optimised', detail: table.memoryOptimised });
|
|
86
|
+
if (table.ledger)
|
|
87
|
+
list.push({ name: 'Ledger', detail: table.ledger });
|
|
88
|
+
if (table.partitioning) {
|
|
89
|
+
const { column, scheme, function: fn, partitions } = table.partitioning;
|
|
90
|
+
list.push({ name: 'Partitioned', detail: `on ${column} by ${scheme} (function ${fn}), ${partitions} partitions` });
|
|
91
|
+
}
|
|
92
|
+
// A list is a table whose partitions are compressed differently.
|
|
93
|
+
if (table.compression)
|
|
94
|
+
list.push({ name: 'Compressed', detail: table.compression.includes(',') ? `${table.compression}, by partition` : table.compression });
|
|
95
|
+
if (table.changeTracking)
|
|
96
|
+
list.push({ name: 'Change tracking' });
|
|
97
|
+
if (table.cdc)
|
|
98
|
+
list.push({ name: 'Change data capture' });
|
|
99
|
+
if (table.triggers?.length)
|
|
100
|
+
list.push({ name: 'Triggers', detail: String(table.triggers.length) });
|
|
101
|
+
if (table.security?.length)
|
|
102
|
+
list.push({ name: 'Row-level security', detail: [...new Set(table.security.map((s) => s.policy))].join(', ') });
|
|
103
|
+
if (table.created) {
|
|
104
|
+
const altered = table.altered && table.altered.slice(0, 10) !== table.created.slice(0, 10) ? `, last altered ${table.altered.slice(0, 10)}` : '';
|
|
105
|
+
list.push({ name: 'Created', detail: `${table.created.slice(0, 10)}${altered}` });
|
|
106
|
+
}
|
|
107
|
+
return list;
|
|
108
|
+
}
|
|
109
|
+
/** A column's type, with its collation where it differs from the database's. */
|
|
110
|
+
function columnType(column) {
|
|
111
|
+
return column.collation ? `${column.type}, collation ${column.collation}` : column.type;
|
|
112
|
+
}
|
|
113
|
+
/** What else the catalogue says about a column, for the Key cell: identity, sparse, masking, encryption. */
|
|
114
|
+
function columnTraits(column) {
|
|
115
|
+
const traits = [];
|
|
116
|
+
if (column.identity)
|
|
117
|
+
traits.push(column.identitySeed !== undefined ? `identity(${column.identitySeed},${column.identityIncrement ?? '1'})` : 'identity');
|
|
118
|
+
if (column.computed !== undefined)
|
|
119
|
+
traits.push(`computed \`${column.computed}\``);
|
|
120
|
+
if (column.sparse)
|
|
121
|
+
traits.push('sparse');
|
|
122
|
+
if (column.masked)
|
|
123
|
+
traits.push(`masked \`${column.masked}\``);
|
|
124
|
+
if (column.encrypted)
|
|
125
|
+
traits.push(`encrypted (${column.encrypted.type}, ${column.encrypted.key})`);
|
|
126
|
+
return traits;
|
|
127
|
+
}
|
|
128
|
+
/** The description cell: the description, then the sensitivity classification when there is one. */
|
|
129
|
+
function columnDescription(column) {
|
|
130
|
+
const parts = column.description ? [column.description] : [];
|
|
131
|
+
const s = column.sensitivity;
|
|
132
|
+
if (s)
|
|
133
|
+
parts.push(`Sensitivity: ${[s.label, s.informationType, s.rank && `rank ${s.rank}`].filter(Boolean).join(', ')}.`);
|
|
134
|
+
return parts.join(' ');
|
|
135
|
+
}
|
|
136
|
+
/** Not enforced, or not checked against the rows already there. */
|
|
137
|
+
function enforcement(item) {
|
|
138
|
+
return [item.disabled ? 'disabled' : '', item.untrusted ? 'not trusted' : ''].filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
function indexKind(index) {
|
|
141
|
+
return [index.type ?? '', index.filter ? `filtered \`${index.filter}\`` : '', index.disabled ? 'disabled' : ''].filter(Boolean).join(', ');
|
|
142
|
+
}
|
|
143
|
+
/** Every extended property of the things listed, one row each; empty when there are none. */
|
|
144
|
+
function propertiesTable(owners) {
|
|
145
|
+
const rows = owners.flatMap(([on, properties]) => Object.entries(properties ?? {}).map(([name, value]) => `| ${cell(on)} | ${cell(name)} | ${cell(value)} |`));
|
|
146
|
+
return rows.length === 0 ? '' : `| On | Name | Value |\n|---|---|---|\n${rows.join('\n')}\n`;
|
|
147
|
+
}
|
|
148
|
+
/** 1,204,377: thousands grouped the same way wherever it runs, so the docs do not change with the machine. */
|
|
149
|
+
export function formatCount(n) {
|
|
150
|
+
return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
151
|
+
}
|
|
152
|
+
const byteUnits = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
153
|
+
/** 480 KB, 7.1 MB, 102 MB: binary units, as the database counts its pages, with one decimal below ten. */
|
|
154
|
+
export function formatBytes(bytes) {
|
|
155
|
+
let value = bytes;
|
|
156
|
+
let unit = 0;
|
|
157
|
+
while (value >= 1024 && unit < byteUnits.length - 1) {
|
|
158
|
+
value /= 1024;
|
|
159
|
+
unit++;
|
|
160
|
+
}
|
|
161
|
+
const text = unit === 0 || value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
|
|
162
|
+
return `${text} ${byteUnits[unit]}`;
|
|
163
|
+
}
|
|
164
|
+
/** The sentence under a table's title: the count is the database's estimate, so it says about, and when. */
|
|
165
|
+
export function statsLine(stats, readAt) {
|
|
166
|
+
const when = readAt ? `, as the database counted them on ${readAt.slice(0, 10)}` : '';
|
|
167
|
+
return `About ${formatCount(stats.rows)} rows · ${formatBytes(stats.dataBytes)} of data · ${formatBytes(stats.indexBytes)} of indexes${when}.`;
|
|
64
168
|
}
|
|
65
169
|
/** One table's page on its own: what the page hands to an agent. */
|
|
66
170
|
export function renderTableMarkdown(model, ref) {
|
|
67
171
|
const found = findTable(model, ref);
|
|
68
172
|
if (!found)
|
|
69
173
|
throw new Error(`${ref} is not a table in the model`);
|
|
70
|
-
return renderTable(model, ref, found.table);
|
|
174
|
+
return renderTable(model, ref, found.table, found.database);
|
|
71
175
|
}
|
|
72
|
-
function renderTable(model, ref, table) {
|
|
176
|
+
function renderTable(model, ref, table, database) {
|
|
73
177
|
const parts = [`# ${ref}\n`];
|
|
74
178
|
if (table.description)
|
|
75
179
|
parts.push(`${table.description}\n`);
|
|
180
|
+
if (table.stats)
|
|
181
|
+
parts.push(`${statsLine(table.stats, database.statsReadAt)}\n`);
|
|
182
|
+
const listed = features(table);
|
|
183
|
+
if (listed.length > 0) {
|
|
184
|
+
parts.push('## Features\n');
|
|
185
|
+
// 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}.md)`) : f.detail);
|
|
187
|
+
parts.push(listed.map((f) => `- **${f.name}**${f.detail ? `: ${linked(f)}` : ''}`).join('\n') + '\n');
|
|
188
|
+
}
|
|
76
189
|
const relations = relationsOf(model, ref);
|
|
77
190
|
parts.push('| Column | Type | Nullable | Default | Key | Description |\n|---|---|---|---|---|---|');
|
|
78
191
|
for (const column of table.columns) {
|
|
@@ -84,16 +197,14 @@ function renderTable(model, ref, table) {
|
|
|
84
197
|
if (i === -1)
|
|
85
198
|
continue;
|
|
86
199
|
const { table: toTable, columns } = relation.to;
|
|
87
|
-
|
|
200
|
+
const state = [relation.virtual ? 'notes' : '', ...enforcement(relation)].filter(Boolean);
|
|
201
|
+
keys.push(`FK → [${toTable}](${toTable}.md).${columns[i] ?? columns.join(',')}${state.length ? ` (${state.join(', ')})` : ''}`);
|
|
88
202
|
}
|
|
89
203
|
if (isUniqueColumn(table, column.name))
|
|
90
204
|
keys.push('unique');
|
|
91
|
-
|
|
92
|
-
keys.push('identity');
|
|
93
|
-
if (column.computed !== undefined)
|
|
94
|
-
keys.push(`computed \`${column.computed}\``);
|
|
205
|
+
keys.push(...columnTraits(column));
|
|
95
206
|
const dflt = column.default === undefined ? '' : `\`${column.default}\``;
|
|
96
|
-
parts.push(`| ${cell(column.name)} | ${cell(column
|
|
207
|
+
parts.push(`| ${cell(column.name)} | ${cell(columnType(column))} | ${column.nullable ? 'yes' : 'no'} | ${cell(dflt)} | ${cell(keys.join(', '))} | ${cell(columnDescription(column))} |`);
|
|
97
208
|
}
|
|
98
209
|
parts.push('');
|
|
99
210
|
if (relations.in.length > 0) {
|
|
@@ -105,10 +216,12 @@ function renderTable(model, ref, table) {
|
|
|
105
216
|
}
|
|
106
217
|
if (table.indexes.length > 0) {
|
|
107
218
|
parts.push('## Indexes\n');
|
|
108
|
-
|
|
219
|
+
// The Kind column only when the source said what kind; a model without it renders as it always did.
|
|
220
|
+
const kinds = table.indexes.some((i) => indexKind(i) !== '');
|
|
221
|
+
parts.push(kinds ? '| Name | Columns | Unique | Kind |\n|---|---|---|---|' : '| Name | Columns | Unique |\n|---|---|---|');
|
|
109
222
|
for (const index of table.indexes) {
|
|
110
223
|
const columns = index.columns.join(', ') + (index.included?.length ? ` (includes ${index.included.join(', ')})` : '');
|
|
111
|
-
parts.push(`| ${cell(index.name)} | ${cell(columns)} | ${index.unique ? 'yes' : 'no'} |`);
|
|
224
|
+
parts.push(`| ${cell(index.name)} | ${cell(columns)} | ${index.unique ? 'yes' : 'no'} |${kinds ? ` ${cell(indexKind(index))} |` : ''}`);
|
|
112
225
|
}
|
|
113
226
|
parts.push('');
|
|
114
227
|
}
|
|
@@ -117,7 +230,34 @@ function renderTable(model, ref, table) {
|
|
|
117
230
|
parts.push('| Name | Kind | Columns | Definition |\n|---|---|---|---|');
|
|
118
231
|
for (const constraint of table.constraints) {
|
|
119
232
|
const definition = constraint.definition === undefined ? '' : `\`${constraint.definition}\``;
|
|
120
|
-
|
|
233
|
+
const kind = [constraint.kind, ...enforcement(constraint)].join(', ');
|
|
234
|
+
parts.push(`| ${cell(constraint.name)} | ${kind} | ${cell(constraint.columns.join(', '))} | ${cell(definition)} |`);
|
|
235
|
+
}
|
|
236
|
+
parts.push('');
|
|
237
|
+
}
|
|
238
|
+
const properties = propertiesTable([
|
|
239
|
+
[ref, table.properties],
|
|
240
|
+
...table.columns.map((c) => [c.name, c.properties]),
|
|
241
|
+
...table.indexes.map((i) => [i.name, i.properties]),
|
|
242
|
+
...table.constraints.map((c) => [c.name, c.properties]),
|
|
243
|
+
]);
|
|
244
|
+
if (properties)
|
|
245
|
+
parts.push(`## Properties\n\n${properties}`);
|
|
246
|
+
if (table.triggers?.length) {
|
|
247
|
+
parts.push('## Triggers\n');
|
|
248
|
+
for (const trigger of table.triggers) {
|
|
249
|
+
const when = `${trigger.timing === 'after' ? 'After' : 'Instead of'} ${trigger.events.join(', ')}. ${trigger.enabled ? 'Enabled' : 'Disabled'}.`;
|
|
250
|
+
parts.push(`### ${trigger.name}\n\n${when}\n`);
|
|
251
|
+
if (trigger.definition)
|
|
252
|
+
parts.push('```sql\n' + trigger.definition.trim() + '\n```\n');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (table.security?.length) {
|
|
256
|
+
parts.push('## Row-level security\n');
|
|
257
|
+
parts.push('| Policy | Kind | Predicate | Enabled |\n|---|---|---|---|');
|
|
258
|
+
for (const s of table.security) {
|
|
259
|
+
const kind = s.operation ? `${s.kind}, ${s.operation}` : s.kind;
|
|
260
|
+
parts.push(`| ${cell(s.policy)} | ${kind} | ${cell(`\`${s.predicate}\``)} | ${s.enabled ? 'yes' : 'no'} |`);
|
|
121
261
|
}
|
|
122
262
|
parts.push('');
|
|
123
263
|
}
|
package/package.json
CHANGED