@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/extensions/commands.ts +732 -0
- package/extensions/context.ts +37 -0
- package/extensions/deps.ts +32 -0
- package/extensions/heydb.ts +20 -0
- package/extensions/secret-input.ts +120 -0
- package/extensions/tools.ts +402 -0
- package/lib/about.ts +7 -0
- package/lib/config/operations.ts +75 -0
- package/lib/config/store.ts +73 -0
- package/lib/config/types.ts +37 -0
- package/lib/config/validate.ts +136 -0
- package/lib/engines/index.ts +66 -0
- package/lib/engines/mysql/catalog.ts +371 -0
- package/lib/engines/mysql/introspect.ts +186 -0
- package/lib/engines/postgres/catalog.ts +390 -0
- package/lib/engines/postgres/introspect.ts +240 -0
- package/lib/knowledge/operations.ts +223 -0
- package/lib/knowledge/query.ts +129 -0
- package/lib/knowledge/types.ts +64 -0
- package/lib/knowledge/validate.ts +151 -0
- package/lib/model/diff.ts +218 -0
- package/lib/model/hash.ts +88 -0
- package/lib/model/names.ts +141 -0
- package/lib/model/order.ts +47 -0
- package/lib/model/references.ts +251 -0
- package/lib/model/snapshot.ts +128 -0
- package/lib/model/stats.ts +97 -0
- package/lib/model/validate.ts +240 -0
- package/lib/render/markdown.ts +508 -0
- package/lib/search.ts +530 -0
- package/lib/secrets.ts +149 -0
- package/lib/store/envfile.ts +124 -0
- package/lib/store/files.ts +57 -0
- package/lib/store/guards.ts +68 -0
- package/lib/store/knowledge.ts +139 -0
- package/lib/store/project.ts +28 -0
- package/lib/store/snapshots.ts +142 -0
- package/lib/usecases/author.ts +51 -0
- package/lib/usecases/code-scan.ts +435 -0
- package/lib/usecases/init.ts +111 -0
- package/lib/usecases/knowledge.ts +214 -0
- package/lib/usecases/project.ts +377 -0
- package/lib/usecases/resolve.ts +186 -0
- package/lib/usecases/sharing.ts +83 -0
- package/lib/usecases/status.ts +37 -0
- package/lib/usecases/sync.ts +33 -0
- package/package.json +69 -5
- package/skills/heydb/SKILL.md +67 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { Knowledge, Relationship } from "../knowledge/types.ts";
|
|
2
|
+
import { formatName, type ObjectRef, parseName, parseSegments } from "./names.ts";
|
|
3
|
+
import type { Snapshot, Table } from "./snapshot.ts";
|
|
4
|
+
|
|
5
|
+
// Who points to a table. In a legacy schema this is the strongest evidence of
|
|
6
|
+
// what a table means: "gar" has no comment and looks like "garantías", but 500
|
|
7
|
+
// columns elsewhere called ref_gar or id_gar say "Artículo" in their comments.
|
|
8
|
+
//
|
|
9
|
+
// Three kinds of evidence, strongest first:
|
|
10
|
+
// - declared foreign keys, enforced by the engine;
|
|
11
|
+
// - implicit relationships the team documented (knowledge);
|
|
12
|
+
// - name-pattern guesses: a column called ref_gar, id_gar, cod_gar, gar_id…
|
|
13
|
+
// in another table of the same database. Nothing enforces them, so they are
|
|
14
|
+
// always shown as guesses.
|
|
15
|
+
//
|
|
16
|
+
// A table never references itself here: its own foreign keys are shown as
|
|
17
|
+
// outgoing relationships, and a self-reference says nothing about meaning.
|
|
18
|
+
//
|
|
19
|
+
// Only relationships between two tables of this snapshot count. One that
|
|
20
|
+
// crosses into another connection names a table of a different database
|
|
21
|
+
// server, even when the name is the same; heydb_table lists those apart.
|
|
22
|
+
|
|
23
|
+
/** Guesses kept per table; the rest are only counted. */
|
|
24
|
+
export const MAX_NAME_PATTERN_GUESSES = 200;
|
|
25
|
+
|
|
26
|
+
const SUMMARY_SIZE = 3;
|
|
27
|
+
|
|
28
|
+
export interface DeclaredReference {
|
|
29
|
+
/** Canonical name of the referencing table. */
|
|
30
|
+
readonly table: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly columns: readonly string[];
|
|
33
|
+
readonly toColumns: readonly string[];
|
|
34
|
+
/** Comment of the first referencing column that has one, or "". */
|
|
35
|
+
readonly comment: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ImplicitReference {
|
|
39
|
+
readonly relationship: Relationship;
|
|
40
|
+
/** Comment of the first referencing column that has one, or "". */
|
|
41
|
+
readonly comment: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NamePatternReference {
|
|
45
|
+
/** Canonical name of the table holding the column. */
|
|
46
|
+
readonly table: string;
|
|
47
|
+
readonly column: string;
|
|
48
|
+
readonly comment: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CommentCount {
|
|
52
|
+
/** The comment as first written, on one line. */
|
|
53
|
+
readonly comment: string;
|
|
54
|
+
readonly count: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface IncomingReferences {
|
|
58
|
+
readonly declared: readonly DeclaredReference[];
|
|
59
|
+
readonly implicit: readonly ImplicitReference[];
|
|
60
|
+
/** At most MAX_NAME_PATTERN_GUESSES, commented columns first. */
|
|
61
|
+
readonly guesses: readonly NamePatternReference[];
|
|
62
|
+
/** Guesses found beyond the kept ones. */
|
|
63
|
+
readonly moreGuesses: number;
|
|
64
|
+
/** The most frequent comments of all referencing columns, guesses included. */
|
|
65
|
+
readonly summary: readonly CommentCount[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function incomingReferences(snapshot: Snapshot, knowledge: Knowledge, tableName: string): IncomingReferences {
|
|
69
|
+
if (parseSegments(tableName).length !== 3) throw new Error(`expected a table name (database.schema.table): got "${tableName}"`);
|
|
70
|
+
const target = parseName(tableName) as Required<Omit<ObjectRef, "column">>;
|
|
71
|
+
const canonical = formatName(target);
|
|
72
|
+
const tables = tableIndex(snapshot);
|
|
73
|
+
|
|
74
|
+
const declared: DeclaredReference[] = [];
|
|
75
|
+
for (const [name, { table }] of tables) {
|
|
76
|
+
if (name === canonical) continue;
|
|
77
|
+
for (const key of table.foreignKeys) {
|
|
78
|
+
if (formatName(key.references) !== canonical) continue;
|
|
79
|
+
declared.push({ table: name, name: key.name, columns: key.columns, toColumns: key.references.columns, comment: commentOf(table, key.columns) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const implicit = local(knowledge)
|
|
84
|
+
.filter((relationship) => relationship.to.table === canonical && relationship.from.table !== canonical)
|
|
85
|
+
.map((relationship) => {
|
|
86
|
+
const from = tables.get(relationship.from.table)?.table;
|
|
87
|
+
return { relationship, comment: from === undefined ? "" : commentOf(from, relationship.from.columns) };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const covered = coveredColumns(snapshot, knowledge);
|
|
91
|
+
const wanted = target.table.toLowerCase();
|
|
92
|
+
const guesses: NamePatternReference[] = [];
|
|
93
|
+
for (const [name, { database, table }] of tables) {
|
|
94
|
+
if (database !== target.database || name === canonical || table.kind !== "table") continue;
|
|
95
|
+
for (const column of table.columns) {
|
|
96
|
+
if (covered.has(columnKey(name, column.name))) continue;
|
|
97
|
+
if (namePatternTargets(column.name).includes(wanted)) guesses.push({ table: name, column: column.name, comment: column.comment });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
guesses.sort(
|
|
101
|
+
(a, b) => Number(a.comment.trim() === "") - Number(b.comment.trim() === "") || compareText(a.table, b.table) || compareText(a.column, b.column),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const comments = [...declared.map((item) => item.comment), ...implicit.map((item) => item.comment), ...guesses.map((item) => item.comment)];
|
|
105
|
+
return {
|
|
106
|
+
declared: declared.sort((a, b) => compareText(a.table, b.table) || compareText(a.name, b.name)),
|
|
107
|
+
implicit,
|
|
108
|
+
guesses: guesses.slice(0, MAX_NAME_PATTERN_GUESSES),
|
|
109
|
+
moreGuesses: Math.max(0, guesses.length - MAX_NAME_PATTERN_GUESSES),
|
|
110
|
+
summary: summarize(comments),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How many incoming references every table has (canonical name → count),
|
|
116
|
+
* counted exactly like incomingReferences, for the whole snapshot in one pass:
|
|
117
|
+
* the overview ranks 2,000 tables and cannot scan every column once per table.
|
|
118
|
+
*/
|
|
119
|
+
export function incomingCounts(snapshot: Snapshot, knowledge: Knowledge): Map<string, number> {
|
|
120
|
+
const counts = new Map<string, number>();
|
|
121
|
+
const add = (name: string) => counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
122
|
+
const tables = tableIndex(snapshot);
|
|
123
|
+
|
|
124
|
+
for (const [name, { table }] of tables) {
|
|
125
|
+
for (const key of table.foreignKeys) {
|
|
126
|
+
const to = formatName(key.references);
|
|
127
|
+
if (to !== name) add(to);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const relationship of local(knowledge)) {
|
|
131
|
+
if (relationship.to.table !== relationship.from.table) add(relationship.to.table);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Per database: lowercased table name → canonical names (two when only case differs).
|
|
135
|
+
const byName = new Map<string, Map<string, string[]>>();
|
|
136
|
+
for (const [name, { database }] of tables) {
|
|
137
|
+
const names = byName.get(database) ?? new Map<string, string[]>();
|
|
138
|
+
byName.set(database, names);
|
|
139
|
+
const lower = (parseName(name).table as string).toLowerCase();
|
|
140
|
+
names.set(lower, [...(names.get(lower) ?? []), name]);
|
|
141
|
+
}
|
|
142
|
+
const covered = coveredColumns(snapshot, knowledge);
|
|
143
|
+
for (const [name, { database, table }] of tables) {
|
|
144
|
+
if (table.kind !== "table") continue;
|
|
145
|
+
const names = byName.get(database);
|
|
146
|
+
for (const column of table.columns) {
|
|
147
|
+
if (covered.has(columnKey(name, column.name))) continue;
|
|
148
|
+
for (const candidate of namePatternTargets(column.name)) {
|
|
149
|
+
for (const to of names?.get(candidate) ?? []) if (to !== name) add(to);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return counts;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The table names a column name may point to, lowercased: what comes before
|
|
158
|
+
* or after any "_". ref_gar → ref, gar; cod_gar_ant → cod, cod_gar, gar_ant,
|
|
159
|
+
* ant. So "gar" matches ref_gar, id_gar, cod_gar, gar_id and gar_precio as a
|
|
160
|
+
* whole token, but never ref_garantia.
|
|
161
|
+
*/
|
|
162
|
+
export function namePatternTargets(columnName: string): string[] {
|
|
163
|
+
const lower = columnName.toLowerCase();
|
|
164
|
+
const targets = new Set<string>();
|
|
165
|
+
for (let index = lower.indexOf("_"); index !== -1; index = lower.indexOf("_", index + 1)) {
|
|
166
|
+
const before = lower.slice(0, index);
|
|
167
|
+
const after = lower.slice(index + 1);
|
|
168
|
+
if (before !== "") targets.add(before);
|
|
169
|
+
if (after !== "") targets.add(after);
|
|
170
|
+
}
|
|
171
|
+
return [...targets];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface IndexedTable {
|
|
175
|
+
readonly database: string;
|
|
176
|
+
readonly table: Table;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tableIndex(snapshot: Snapshot): Map<string, IndexedTable> {
|
|
180
|
+
const tables = new Map<string, IndexedTable>();
|
|
181
|
+
for (const database of snapshot.databases) {
|
|
182
|
+
for (const schema of database.schemas) {
|
|
183
|
+
for (const table of schema.tables) {
|
|
184
|
+
tables.set(formatName({ database: database.name, schema: schema.name, table: table.name }), { database: database.name, table });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return tables;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A column that is part of a foreign key or a documented relationship already
|
|
192
|
+
// says where it points; guessing from its name would only add noise.
|
|
193
|
+
function coveredColumns(snapshot: Snapshot, knowledge: Knowledge): Set<string> {
|
|
194
|
+
const covered = new Set<string>();
|
|
195
|
+
for (const [name, { table }] of tableIndex(snapshot)) {
|
|
196
|
+
for (const key of table.foreignKeys) for (const column of key.columns) covered.add(columnKey(name, column));
|
|
197
|
+
}
|
|
198
|
+
for (const { from } of knowledge.relationships) {
|
|
199
|
+
if (from.connection === undefined) for (const column of from.columns) covered.add(columnKey(from.table, column));
|
|
200
|
+
}
|
|
201
|
+
return covered;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The relationships whose two sides are tables of this connection. */
|
|
205
|
+
function local(knowledge: Knowledge): Relationship[] {
|
|
206
|
+
return knowledge.relationships.filter(({ from, to }) => from.connection === undefined && to.connection === undefined);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function columnKey(table: string, column: string): string {
|
|
210
|
+
return `${table}\u0000${column}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function commentOf(table: Table, columns: readonly string[]): string {
|
|
214
|
+
for (const name of columns) {
|
|
215
|
+
const comment = table.columns.find((column) => column.name === name)?.comment ?? "";
|
|
216
|
+
if (comment.trim() !== "") return comment;
|
|
217
|
+
}
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// "Artículo", "articulo." and " ARTÍCULO " are the same answer. Grouped
|
|
222
|
+
// without case, accents, extra spaces or end punctuation; shown as first written.
|
|
223
|
+
function summarize(comments: readonly string[]): CommentCount[] {
|
|
224
|
+
const groups = new Map<string, { comment: string; count: number }>();
|
|
225
|
+
for (const comment of comments) {
|
|
226
|
+
const shown = comment.replace(/\s+/g, " ").trim();
|
|
227
|
+
const key = fold(shown);
|
|
228
|
+
if (key === "") continue;
|
|
229
|
+
const group = groups.get(key);
|
|
230
|
+
if (group === undefined) groups.set(key, { comment: shown.replace(/[.:;,]+$/, ""), count: 1 });
|
|
231
|
+
else group.count += 1;
|
|
232
|
+
}
|
|
233
|
+
return [...groups.values()]
|
|
234
|
+
.sort((a, b) => b.count - a.count || compareText(a.comment, b.comment))
|
|
235
|
+
.slice(0, SUMMARY_SIZE)
|
|
236
|
+
.map(({ comment, count }) => ({ comment, count }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The same folding as normalizeText in lib/search.ts, which model code must not import.
|
|
240
|
+
function fold(text: string): string {
|
|
241
|
+
return text
|
|
242
|
+
.normalize("NFD")
|
|
243
|
+
.replace(/\p{M}/gu, "")
|
|
244
|
+
.toLowerCase()
|
|
245
|
+
.replace(/[.:;,]+$/, "");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Plain code-unit comparison: the same order on every machine and locale.
|
|
249
|
+
function compareText(a: string, b: string): number {
|
|
250
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
251
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A Snapshot is an immutable picture of a database server's structure at one
|
|
2
|
+
// moment: which databases, schemas, tables and columns exist and how they are
|
|
3
|
+
// keyed and related. It never contains row data.
|
|
4
|
+
//
|
|
5
|
+
// Conventions used by every type in this file:
|
|
6
|
+
// - `readonly` everywhere: a snapshot is a fact about the past, never edited.
|
|
7
|
+
// - `null` (not an optional field) when a value was checked and is absent, so
|
|
8
|
+
// "no default" and "we forgot to read the default" never look the same.
|
|
9
|
+
// - Names are kept exactly as the engine reports them (case included).
|
|
10
|
+
|
|
11
|
+
export type EngineKind = "mysql" | "mariadb" | "postgresql";
|
|
12
|
+
|
|
13
|
+
export interface Snapshot {
|
|
14
|
+
/** Bumped only when the shape of this file changes incompatibly. */
|
|
15
|
+
readonly formatVersion: 1;
|
|
16
|
+
readonly engine: EngineInfo;
|
|
17
|
+
/** ISO 8601 timestamp of the capture. */
|
|
18
|
+
readonly capturedAt: string;
|
|
19
|
+
readonly databases: readonly Database[];
|
|
20
|
+
/** What could not be read (missing privileges, unsupported features). */
|
|
21
|
+
readonly warnings: readonly Warning[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EngineInfo {
|
|
25
|
+
readonly kind: EngineKind;
|
|
26
|
+
/** Version string as reported by the server, e.g. "8.0.36" or "10.11.6-MariaDB". */
|
|
27
|
+
readonly version: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Database {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
/** MySQL and MariaDB have exactly one schema per database, named like the database. */
|
|
33
|
+
readonly schemas: readonly Schema[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Schema {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly tables: readonly Table[];
|
|
39
|
+
readonly routines: readonly Routine[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TableKind = "table" | "view";
|
|
43
|
+
|
|
44
|
+
export interface Table {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly kind: TableKind;
|
|
47
|
+
readonly comment: string;
|
|
48
|
+
/** Storage engine (InnoDB, MyISAM…); null where the concept does not exist (PostgreSQL). */
|
|
49
|
+
readonly storageEngine: string | null;
|
|
50
|
+
/** Approximate number of rows reported by the engine statistics; null when unknown. */
|
|
51
|
+
readonly rowEstimate: number | null;
|
|
52
|
+
readonly columns: readonly Column[];
|
|
53
|
+
/** Primary key column names in key order (not table order). Empty when there is none. */
|
|
54
|
+
readonly primaryKey: readonly string[];
|
|
55
|
+
readonly indexes: readonly Index[];
|
|
56
|
+
readonly foreignKeys: readonly ForeignKey[];
|
|
57
|
+
readonly triggers: readonly Trigger[];
|
|
58
|
+
/**
|
|
59
|
+
* The SELECT of a view as the engine prints it. null for tables, and null for a
|
|
60
|
+
* view whose definition the user may not see (a warning says so).
|
|
61
|
+
*/
|
|
62
|
+
readonly viewDefinition: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Column {
|
|
66
|
+
readonly name: string;
|
|
67
|
+
/** 1-based position in the table definition. */
|
|
68
|
+
readonly position: number;
|
|
69
|
+
/** Full type as the engine prints it, e.g. "bigint unsigned" or "varchar(255)". */
|
|
70
|
+
readonly type: string;
|
|
71
|
+
readonly nullable: boolean;
|
|
72
|
+
/** Default expression as text; null when the column has no default. */
|
|
73
|
+
readonly default: string | null;
|
|
74
|
+
readonly comment: string;
|
|
75
|
+
readonly autoIncrement: boolean;
|
|
76
|
+
readonly generated: GeneratedColumn | null;
|
|
77
|
+
readonly collation: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GeneratedColumn {
|
|
81
|
+
readonly expression: string;
|
|
82
|
+
/** true for STORED, false for VIRTUAL. */
|
|
83
|
+
readonly stored: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Index {
|
|
87
|
+
readonly name: string;
|
|
88
|
+
/** Column names in index order. */
|
|
89
|
+
readonly columns: readonly string[];
|
|
90
|
+
readonly unique: boolean;
|
|
91
|
+
/** BTREE, HASH, FULLTEXT, GIN… as reported by the engine. */
|
|
92
|
+
readonly method: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type ReferentialAction = "CASCADE" | "SET NULL" | "SET DEFAULT" | "RESTRICT" | "NO ACTION";
|
|
96
|
+
|
|
97
|
+
export interface ForeignKey {
|
|
98
|
+
readonly name: string;
|
|
99
|
+
/** Local columns, in the same order as `references.columns`. */
|
|
100
|
+
readonly columns: readonly string[];
|
|
101
|
+
readonly references: {
|
|
102
|
+
readonly database: string;
|
|
103
|
+
readonly schema: string;
|
|
104
|
+
readonly table: string;
|
|
105
|
+
readonly columns: readonly string[];
|
|
106
|
+
};
|
|
107
|
+
readonly onDelete: ReferentialAction;
|
|
108
|
+
readonly onUpdate: ReferentialAction;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Routine {
|
|
112
|
+
readonly name: string;
|
|
113
|
+
readonly kind: "procedure" | "function";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface Trigger {
|
|
117
|
+
readonly name: string;
|
|
118
|
+
readonly timing: "BEFORE" | "AFTER" | "INSTEAD OF";
|
|
119
|
+
readonly events: readonly ("INSERT" | "UPDATE" | "DELETE" | "TRUNCATE")[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface Warning {
|
|
123
|
+
/** Stable machine-readable code, e.g. "routines_hidden". */
|
|
124
|
+
readonly code: string;
|
|
125
|
+
readonly message: string;
|
|
126
|
+
/** Canonical name of the affected object, when there is one. */
|
|
127
|
+
readonly target?: string;
|
|
128
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Knowledge } from "../knowledge/types.ts";
|
|
2
|
+
import { formatName } from "./names.ts";
|
|
3
|
+
import { incomingCounts } from "./references.ts";
|
|
4
|
+
import type { Snapshot, Table } from "./snapshot.ts";
|
|
5
|
+
|
|
6
|
+
// What a schema looks like at a glance, before reading any table: how much of
|
|
7
|
+
// it is commented (how far comments can be trusted as documentation), how its
|
|
8
|
+
// tables are named, and which tables the rest points to (its core).
|
|
9
|
+
|
|
10
|
+
const TOP_PREFIXES = 5;
|
|
11
|
+
const TOP_TABLES = 10;
|
|
12
|
+
|
|
13
|
+
export interface PrefixCount {
|
|
14
|
+
readonly prefix: string;
|
|
15
|
+
readonly count: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MainTable {
|
|
19
|
+
/** Canonical name. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly table: Table;
|
|
22
|
+
/** Declared, implicit and name-pattern references from other tables. */
|
|
23
|
+
readonly incoming: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SchemaStats {
|
|
27
|
+
readonly database: string;
|
|
28
|
+
readonly schema: string;
|
|
29
|
+
/** Tables only; views are counted apart and left out of every other number. */
|
|
30
|
+
readonly tables: number;
|
|
31
|
+
readonly views: number;
|
|
32
|
+
readonly tablesWithComment: number;
|
|
33
|
+
readonly columns: number;
|
|
34
|
+
readonly columnsWithComment: number;
|
|
35
|
+
/** Prefixes shared by two tables or more, most used first. */
|
|
36
|
+
readonly prefixes: readonly PrefixCount[];
|
|
37
|
+
/** The most referenced tables, most first; only tables with references. */
|
|
38
|
+
readonly mainTables: readonly MainTable[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One entry per schema, in snapshot order (one per database in MySQL and MariaDB). */
|
|
42
|
+
export function schemaStats(snapshot: Snapshot, knowledge: Knowledge, counts = incomingCounts(snapshot, knowledge)): SchemaStats[] {
|
|
43
|
+
return snapshot.databases.flatMap((database) =>
|
|
44
|
+
database.schemas.map((schema): SchemaStats => {
|
|
45
|
+
const tables = schema.tables.filter((table) => table.kind === "table");
|
|
46
|
+
const columns = tables.flatMap((table) => table.columns);
|
|
47
|
+
|
|
48
|
+
const prefixes = new Map<string, number>();
|
|
49
|
+
for (const table of tables) {
|
|
50
|
+
const prefix = tablePrefix(table.name);
|
|
51
|
+
if (prefix !== null) prefixes.set(prefix, (prefixes.get(prefix) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mainTables = tables
|
|
55
|
+
.map((table) => {
|
|
56
|
+
const name = formatName({ database: database.name, schema: schema.name, table: table.name });
|
|
57
|
+
return { name, table, incoming: counts.get(name) ?? 0 };
|
|
58
|
+
})
|
|
59
|
+
.filter((item) => item.incoming > 0)
|
|
60
|
+
.sort((a, b) => b.incoming - a.incoming || compareText(a.name, b.name))
|
|
61
|
+
.slice(0, TOP_TABLES);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
database: database.name,
|
|
65
|
+
schema: schema.name,
|
|
66
|
+
tables: tables.length,
|
|
67
|
+
views: schema.tables.length - tables.length,
|
|
68
|
+
tablesWithComment: tables.filter((table) => table.comment.trim() !== "").length,
|
|
69
|
+
columns: columns.length,
|
|
70
|
+
columnsWithComment: columns.filter((column) => column.comment.trim() !== "").length,
|
|
71
|
+
prefixes: [...prefixes]
|
|
72
|
+
.filter(([, count]) => count >= 2)
|
|
73
|
+
.map(([prefix, count]) => ({ prefix, count }))
|
|
74
|
+
.sort((a, b) => b.count - a.count || compareText(a.prefix, b.prefix))
|
|
75
|
+
.slice(0, TOP_PREFIXES),
|
|
76
|
+
mainTables,
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The family a table name belongs to: the start of the name up to the first
|
|
84
|
+
* "_" ("sii_fac" → "sii_") or digit ("log2020" → "log0-9"), lowercased. null
|
|
85
|
+
* when there is no such start ("gcl", "_tmp", "2020_log").
|
|
86
|
+
*/
|
|
87
|
+
export function tablePrefix(name: string): string | null {
|
|
88
|
+
const match = /^([^_0-9]+)([_0-9])/.exec(name);
|
|
89
|
+
if (match === null) return null;
|
|
90
|
+
const start = (match[1] as string).toLowerCase();
|
|
91
|
+
return match[2] === "_" ? `${start}_` : `${start}0-9`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Plain code-unit comparison: the same order on every machine and locale.
|
|
95
|
+
function compareText(a: string, b: string): number {
|
|
96
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
97
|
+
}
|