dsh-plugin-dbgraph-catalog 0.0.0-stage → 0.1.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/lib/index.d.ts +80 -0
- package/lib/index.js +111 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +24 -0
- package/lib/mysql-map.d.ts +153 -0
- package/lib/mysql-map.js +266 -0
- package/lib/mysql.d.ts +53 -0
- package/lib/mysql.js +495 -0
- package/lib/pool.d.ts +44 -0
- package/lib/pool.js +90 -0
- package/lib/sqlite.d.ts +63 -0
- package/lib/sqlite.js +582 -0
- package/lib/support.d.ts +164 -0
- package/lib/support.js +396 -0
- package/package.json +70 -3
- package/README.md +0 -3
package/lib/mysql.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL dialect: `mysql2/promise`, loaded lazily.
|
|
3
|
+
*
|
|
4
|
+
* The driver is an optional dependency, so it is imported through {@link loadMysqlDriver} rather
|
|
5
|
+
* than at module scope. A deployment without `mysql2` gets a `DBGRAPH_DRIVER_MISSING` error from
|
|
6
|
+
* that loader (and the plugin simply does not register this dialect) instead of a package that
|
|
7
|
+
* fails to load.
|
|
8
|
+
*
|
|
9
|
+
* The catalog comes from `information_schema` in four queries rather than from `SHOW` statements
|
|
10
|
+
* per object: one round trip per catalog facet, and the column names carry the facts (`TABLE_TYPE`,
|
|
11
|
+
* `COLUMN_COMMENT`, `REFERENCED_TABLE_NAME`) instead of needing a parser.
|
|
12
|
+
*
|
|
13
|
+
* **Level split.** L1 is `TABLES` (names, kinds) plus `COLUMNS` (declared types, nullability,
|
|
14
|
+
* defaults, primary keys). L2 re-reads `TABLES`/`COLUMNS` for comments and row estimates, then adds
|
|
15
|
+
* `KEY_COLUMN_USAGE` foreign keys, `STATISTICS` indexes, single-column uniqueness and enum samples.
|
|
16
|
+
* A view's `TABLE_COMMENT` is the marker `VIEW`, not a description, and is dropped.
|
|
17
|
+
*
|
|
18
|
+
* **Read-only is a statement gate here, not a connection mode.** MySQL has no read-only connection
|
|
19
|
+
* mode a pooled session can adopt without changing the transaction shape callers see, so `execute`
|
|
20
|
+
* applies the shared keyword gate before the statement reaches the driver; the connection-level
|
|
21
|
+
* guarantee belongs to the account's grants.
|
|
22
|
+
*
|
|
23
|
+
* **Per-object failure is tolerated.** Each object's merge and each column's enum sample is guarded:
|
|
24
|
+
* a failure appends a warning and that object keeps its L1 shape, so a permissions quirk on one
|
|
25
|
+
* table cannot cost the enrichment of every other one. A failure of a bulk `information_schema`
|
|
26
|
+
* query is different — there is no partial answer to keep — and propagates; the service already
|
|
27
|
+
* degrades that case to "index stays at L1".
|
|
28
|
+
* @module dsh-plugin-dbgraph-catalog/mysql
|
|
29
|
+
*/
|
|
30
|
+
import type { ManagedDialect } from './support.ts';
|
|
31
|
+
/** The optional driver package this dialect needs. */
|
|
32
|
+
export declare const MYSQL_DRIVER_PACKAGE = "mysql2";
|
|
33
|
+
/** The `mysql2/promise` module surface this dialect uses, for the driver passed in at construction. */
|
|
34
|
+
export type MysqlModule = typeof import('mysql2/promise');
|
|
35
|
+
/**
|
|
36
|
+
* Load `mysql2/promise`, or report that the optional driver is missing.
|
|
37
|
+
* @returns the driver module.
|
|
38
|
+
* @throws DbgraphError with `DBGRAPH_DRIVER_MISSING` when the package cannot be imported.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadMysqlDriver(): Promise<MysqlModule>;
|
|
41
|
+
/**
|
|
42
|
+
* Create the MySQL dialect.
|
|
43
|
+
* @param driver - the loaded `mysql2/promise` module.
|
|
44
|
+
* @returns a dialect that caches one pool per connection id.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createMysqlDialect(driver: MysqlModule): ManagedDialect;
|
|
47
|
+
/**
|
|
48
|
+
* Quote a MySQL identifier for a statement the server cannot parameterize.
|
|
49
|
+
* @param name - the identifier, as read from the catalog.
|
|
50
|
+
* @returns the backtick-quoted identifier.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mysqlIdentifier(name: string): string;
|
|
53
|
+
//# sourceMappingURL=mysql.d.ts.map
|
package/lib/mysql.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL dialect: `mysql2/promise`, loaded lazily.
|
|
3
|
+
*
|
|
4
|
+
* The driver is an optional dependency, so it is imported through {@link loadMysqlDriver} rather
|
|
5
|
+
* than at module scope. A deployment without `mysql2` gets a `DBGRAPH_DRIVER_MISSING` error from
|
|
6
|
+
* that loader (and the plugin simply does not register this dialect) instead of a package that
|
|
7
|
+
* fails to load.
|
|
8
|
+
*
|
|
9
|
+
* The catalog comes from `information_schema` in four queries rather than from `SHOW` statements
|
|
10
|
+
* per object: one round trip per catalog facet, and the column names carry the facts (`TABLE_TYPE`,
|
|
11
|
+
* `COLUMN_COMMENT`, `REFERENCED_TABLE_NAME`) instead of needing a parser.
|
|
12
|
+
*
|
|
13
|
+
* **Level split.** L1 is `TABLES` (names, kinds) plus `COLUMNS` (declared types, nullability,
|
|
14
|
+
* defaults, primary keys). L2 re-reads `TABLES`/`COLUMNS` for comments and row estimates, then adds
|
|
15
|
+
* `KEY_COLUMN_USAGE` foreign keys, `STATISTICS` indexes, single-column uniqueness and enum samples.
|
|
16
|
+
* A view's `TABLE_COMMENT` is the marker `VIEW`, not a description, and is dropped.
|
|
17
|
+
*
|
|
18
|
+
* **Read-only is a statement gate here, not a connection mode.** MySQL has no read-only connection
|
|
19
|
+
* mode a pooled session can adopt without changing the transaction shape callers see, so `execute`
|
|
20
|
+
* applies the shared keyword gate before the statement reaches the driver; the connection-level
|
|
21
|
+
* guarantee belongs to the account's grants.
|
|
22
|
+
*
|
|
23
|
+
* **Per-object failure is tolerated.** Each object's merge and each column's enum sample is guarded:
|
|
24
|
+
* a failure appends a warning and that object keeps its L1 shape, so a permissions quirk on one
|
|
25
|
+
* table cannot cost the enrichment of every other one. A failure of a bulk `information_schema`
|
|
26
|
+
* query is different — there is no partial answer to keep — and propagates; the service already
|
|
27
|
+
* degrades that case to "index stays at L1".
|
|
28
|
+
* @module dsh-plugin-dbgraph-catalog/mysql
|
|
29
|
+
*/
|
|
30
|
+
import { DbgraphError } from 'dsh-plugin-dbgraph-service';
|
|
31
|
+
import { groupMysqlColumns, groupMysqlForeignKeys, groupMysqlIndexes, mergeMysqlObject, mysqlColumnComments, mysqlTableFacts, shouldSampleMysqlColumn, } from "./mysql-map.js";
|
|
32
|
+
import { HandleCache } from "./pool.js";
|
|
33
|
+
import { asString, assertReadOnlyStatement, clampRowLimit, DBGRAPH_QUERY_FAILED_CODE, messageOf, normalizeRows, throwIfAborted, } from "./support.js";
|
|
34
|
+
/** The optional driver package this dialect needs. */
|
|
35
|
+
export const MYSQL_DRIVER_PACKAGE = 'mysql2';
|
|
36
|
+
/** Where a connection with no `host` points. */
|
|
37
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
38
|
+
/** Where a connection with no `port` points. */
|
|
39
|
+
const DEFAULT_PORT = 3306;
|
|
40
|
+
/** One pool per connection id; catalog reads serialize, execution occasionally overlaps them. */
|
|
41
|
+
const POOL_CONNECTION_LIMIT = 4;
|
|
42
|
+
/** How long an idle pooled socket is kept before the pool closes it. */
|
|
43
|
+
const POOL_IDLE_TIMEOUT_MS = 60_000;
|
|
44
|
+
/** Statement timeout used when the caller supplies a value that is not a usable number. */
|
|
45
|
+
const FALLBACK_TIMEOUT_MS = 10_000;
|
|
46
|
+
/** L1 object listing: names and kinds only; comments belong to L2. */
|
|
47
|
+
const LIST_TABLES_QUERY = `SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE
|
|
48
|
+
FROM information_schema.TABLES
|
|
49
|
+
WHERE TABLE_SCHEMA = ?
|
|
50
|
+
ORDER BY TABLE_NAME`;
|
|
51
|
+
/** L1 column listing: declared shape only; comments belong to L2. */
|
|
52
|
+
const LIST_COLUMNS_QUERY = `SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_TYPE,
|
|
53
|
+
IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY
|
|
54
|
+
FROM information_schema.COLUMNS
|
|
55
|
+
WHERE TABLE_SCHEMA = ?
|
|
56
|
+
ORDER BY TABLE_NAME, ORDINAL_POSITION`;
|
|
57
|
+
/** L2 table enrichment: comments and catalog row estimates. */
|
|
58
|
+
const ENRICH_TABLES_QUERY = `SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT, TABLE_ROWS
|
|
59
|
+
FROM information_schema.TABLES
|
|
60
|
+
WHERE TABLE_SCHEMA = ?
|
|
61
|
+
ORDER BY TABLE_NAME`;
|
|
62
|
+
/** L2 column enrichment: comments only; shape came from L1. */
|
|
63
|
+
const ENRICH_COLUMNS_QUERY = `SELECT TABLE_NAME, COLUMN_NAME, COLUMN_COMMENT
|
|
64
|
+
FROM information_schema.COLUMNS
|
|
65
|
+
WHERE TABLE_SCHEMA = ?
|
|
66
|
+
ORDER BY TABLE_NAME, ORDINAL_POSITION`;
|
|
67
|
+
/** L2 foreign keys. `REFERENCED_TABLE_NAME IS NULL` marks non-FK key usage rows. */
|
|
68
|
+
const FOREIGN_KEYS_QUERY = `SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION,
|
|
69
|
+
REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
70
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
71
|
+
WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
72
|
+
ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION`;
|
|
73
|
+
/** L2 indexes. */
|
|
74
|
+
const INDEXES_QUERY = `SELECT TABLE_NAME, INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
|
|
75
|
+
FROM information_schema.STATISTICS
|
|
76
|
+
WHERE TABLE_SCHEMA = ?
|
|
77
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`;
|
|
78
|
+
/** The server version, for diagnostics only. */
|
|
79
|
+
const SERVER_VERSION_QUERY = 'SELECT VERSION() AS version';
|
|
80
|
+
/**
|
|
81
|
+
* The in-flight or settled driver import. Cached so every connection shares one load, and cleared on
|
|
82
|
+
* failure so a driver that appears later can still be picked up without restarting the process.
|
|
83
|
+
*/
|
|
84
|
+
let driverPromise;
|
|
85
|
+
/**
|
|
86
|
+
* Load `mysql2/promise`, or report that the optional driver is missing.
|
|
87
|
+
* @returns the driver module.
|
|
88
|
+
* @throws DbgraphError with `DBGRAPH_DRIVER_MISSING` when the package cannot be imported.
|
|
89
|
+
*/
|
|
90
|
+
export function loadMysqlDriver() {
|
|
91
|
+
if (driverPromise === undefined) {
|
|
92
|
+
driverPromise = import('mysql2/promise').catch((error) => {
|
|
93
|
+
driverPromise = undefined;
|
|
94
|
+
throw new DbgraphError(`${MYSQL_DRIVER_PACKAGE} is required by the mysql dialect but could not be imported: ${messageOf(error)}`, 'DBGRAPH_DRIVER_MISSING');
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return driverPromise;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Create the MySQL dialect.
|
|
101
|
+
* @param driver - the loaded `mysql2/promise` module.
|
|
102
|
+
* @returns a dialect that caches one pool per connection id.
|
|
103
|
+
*/
|
|
104
|
+
export function createMysqlDialect(driver) {
|
|
105
|
+
return new MysqlDialect(driver);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Quote a MySQL identifier for a statement the server cannot parameterize.
|
|
109
|
+
* @param name - the identifier, as read from the catalog.
|
|
110
|
+
* @returns the backtick-quoted identifier.
|
|
111
|
+
*/
|
|
112
|
+
export function mysqlIdentifier(name) {
|
|
113
|
+
return `\`${name.replace(/`/g, '``')}\``;
|
|
114
|
+
}
|
|
115
|
+
/** A dialect instance backed by a per-connection pool cache. */
|
|
116
|
+
class MysqlDialect {
|
|
117
|
+
engine = 'mysql';
|
|
118
|
+
driver;
|
|
119
|
+
pools = new HandleCache();
|
|
120
|
+
/**
|
|
121
|
+
* @param driver - the loaded driver module.
|
|
122
|
+
*/
|
|
123
|
+
constructor(driver) {
|
|
124
|
+
this.driver = driver;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Round-trip check: connect and read the server version.
|
|
128
|
+
* @param input - the connection to probe.
|
|
129
|
+
* @returns the probe answer; a failure is reported, not thrown, except an abort.
|
|
130
|
+
*/
|
|
131
|
+
async test(input) {
|
|
132
|
+
throwIfAborted(input.signal);
|
|
133
|
+
const started = Date.now();
|
|
134
|
+
try {
|
|
135
|
+
const pool = await this.pool(input);
|
|
136
|
+
const connection = await pool.getConnection();
|
|
137
|
+
try {
|
|
138
|
+
const [rows] = await connection.query({
|
|
139
|
+
sql: SERVER_VERSION_QUERY,
|
|
140
|
+
timeout: statementTimeout(input.queryTimeoutMs),
|
|
141
|
+
});
|
|
142
|
+
const version = rows.length === 0 ? undefined : asString(rows[0]?.['version']);
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
...(version === undefined ? {} : { serverVersion: version }),
|
|
146
|
+
elapsedMs: Date.now() - started,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
connection.release();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { ok: false, error: messageOf(error), elapsedMs: Date.now() - started };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Structural snapshot: every listed table and view with its declared columns.
|
|
159
|
+
* @param input - the connection to read.
|
|
160
|
+
* @returns an `l1` catalog.
|
|
161
|
+
*/
|
|
162
|
+
async fetchL1(input) {
|
|
163
|
+
throwIfAborted(input.signal);
|
|
164
|
+
const database = requireDatabase(input);
|
|
165
|
+
return this.withConnection(input, async (connection) => {
|
|
166
|
+
throwIfAborted(input.signal);
|
|
167
|
+
const version = await this.readVersion(connection, input);
|
|
168
|
+
const tables = await queryRows(connection, input, LIST_TABLES_QUERY, [database]);
|
|
169
|
+
const columns = await queryRows(connection, input, LIST_COLUMNS_QUERY, [database]);
|
|
170
|
+
const columnsByTable = groupMysqlColumns(columns);
|
|
171
|
+
const warnings = [];
|
|
172
|
+
const objects = [];
|
|
173
|
+
for (const row of tables) {
|
|
174
|
+
throwIfAborted(input.signal);
|
|
175
|
+
const name = asString(row.TABLE_NAME);
|
|
176
|
+
if (name === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
const facts = mysqlTableFacts(row);
|
|
179
|
+
const tableColumns = columnsByTable.get(name) ?? [];
|
|
180
|
+
if (tableColumns.length === 0) {
|
|
181
|
+
// Visible in TABLES but not in COLUMNS: the account can see the object, not its shape.
|
|
182
|
+
warnings.push(`mysql: "${name}" exposed no columns`);
|
|
183
|
+
}
|
|
184
|
+
objects.push({
|
|
185
|
+
kind: facts.kind,
|
|
186
|
+
schema: database,
|
|
187
|
+
name,
|
|
188
|
+
columns: tableColumns,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
engine: 'mysql',
|
|
193
|
+
...(version === undefined ? {} : { serverVersion: version }),
|
|
194
|
+
database,
|
|
195
|
+
objects,
|
|
196
|
+
fetchedAt: Date.now(),
|
|
197
|
+
level: 'l1',
|
|
198
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Enrichment snapshot: comments, row estimates, foreign keys, indexes, single-column uniqueness and
|
|
204
|
+
* enum samples merged onto the L1 objects passed in.
|
|
205
|
+
* @param input - the connection to read.
|
|
206
|
+
* @param l1 - the structural snapshot this run starts from; never mutated.
|
|
207
|
+
* @returns an `l2` catalog.
|
|
208
|
+
*/
|
|
209
|
+
async fetchL2(input, l1) {
|
|
210
|
+
throwIfAborted(input.signal);
|
|
211
|
+
const database = requireDatabase(input);
|
|
212
|
+
return this.withConnection(input, async (connection) => {
|
|
213
|
+
throwIfAborted(input.signal);
|
|
214
|
+
const tables = await queryRows(connection, input, ENRICH_TABLES_QUERY, [database]);
|
|
215
|
+
const columns = await queryRows(connection, input, ENRICH_COLUMNS_QUERY, [database]);
|
|
216
|
+
const foreignKeys = await queryRows(connection, input, FOREIGN_KEYS_QUERY, [database]);
|
|
217
|
+
const indexes = await queryRows(connection, input, INDEXES_QUERY, [database]);
|
|
218
|
+
const facts = new Map();
|
|
219
|
+
for (const row of tables) {
|
|
220
|
+
const name = asString(row.TABLE_NAME);
|
|
221
|
+
if (name !== undefined)
|
|
222
|
+
facts.set(name, mysqlTableFacts(row));
|
|
223
|
+
}
|
|
224
|
+
const comments = mysqlColumnComments(columns);
|
|
225
|
+
const foreignKeyEdges = groupMysqlForeignKeys(foreignKeys);
|
|
226
|
+
const indexFacts = groupMysqlIndexes(indexes);
|
|
227
|
+
const warnings = [];
|
|
228
|
+
const objects = [];
|
|
229
|
+
for (const object of l1.objects) {
|
|
230
|
+
throwIfAborted(input.signal);
|
|
231
|
+
objects.push(await this.enrichObject(connection, input, object, facts.get(object.name), comments, indexFacts.singleUnique.get(object.name), indexFacts.edges.get(object.name), foreignKeyEdges.get(object.name), warnings));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
engine: 'mysql',
|
|
235
|
+
...(l1.serverVersion === undefined ? {} : { serverVersion: l1.serverVersion }),
|
|
236
|
+
database: l1.database,
|
|
237
|
+
objects,
|
|
238
|
+
fetchedAt: Date.now(),
|
|
239
|
+
level: 'l2',
|
|
240
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Execute one statement.
|
|
246
|
+
*
|
|
247
|
+
* The row cap is applied after the driver returns the result set: the statement is caller-authored,
|
|
248
|
+
* and adding a `LIMIT` to it would change what the caller asked for. The seam's `maxRows` is a
|
|
249
|
+
* display/cost bound, not a promise that the server stopped early.
|
|
250
|
+
* @param input - the connection to run against.
|
|
251
|
+
* @param query - the statement.
|
|
252
|
+
* @param limits - row cap, statement timeout and the read-only flag.
|
|
253
|
+
* @param parameters - values bound to the statement's `?` placeholders, in order. The driver
|
|
254
|
+
* escapes them client-side, and omitting them leaves the statement exactly as written.
|
|
255
|
+
* @returns the normalized result set.
|
|
256
|
+
*/
|
|
257
|
+
async execute(input, query, limits, parameters) {
|
|
258
|
+
throwIfAborted(input.signal);
|
|
259
|
+
assertReadOnlyStatement(query, limits);
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
// Prepared once: mysql2 treats `undefined` values as "no parameters", which keeps the prior
|
|
262
|
+
// call shape for callers that never bind anything.
|
|
263
|
+
const values = parameters === undefined ? undefined : [...parameters];
|
|
264
|
+
return this.withConnection(input, async (connection) => {
|
|
265
|
+
throwIfAborted(input.signal);
|
|
266
|
+
const [result, fields] = await connection.query({ sql: query, timeout: statementTimeout(limits.timeoutMs) }, values);
|
|
267
|
+
if (!Array.isArray(result)) {
|
|
268
|
+
return {
|
|
269
|
+
columns: [],
|
|
270
|
+
rows: [],
|
|
271
|
+
rowCount: 0,
|
|
272
|
+
truncated: false,
|
|
273
|
+
redacted: [],
|
|
274
|
+
affectedRows: result.affectedRows,
|
|
275
|
+
elapsedMs: Date.now() - started,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const columns = resultColumns(fields);
|
|
279
|
+
const cap = clampRowLimit(limits.maxRows);
|
|
280
|
+
const normalized = normalizeRows(columns, result.slice(0, cap), input.connection);
|
|
281
|
+
return {
|
|
282
|
+
columns,
|
|
283
|
+
rows: normalized.rows,
|
|
284
|
+
rowCount: normalized.rows.length,
|
|
285
|
+
truncated: result.length > cap,
|
|
286
|
+
redacted: normalized.redacted,
|
|
287
|
+
elapsedMs: Date.now() - started,
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Close every cached pool. */
|
|
292
|
+
async close() {
|
|
293
|
+
await this.pools.closeAll();
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The warm pool for a connection.
|
|
297
|
+
* @param input - the connection to serve.
|
|
298
|
+
* @returns the cached pool.
|
|
299
|
+
*/
|
|
300
|
+
async pool(input) {
|
|
301
|
+
const database = requireDatabase(input);
|
|
302
|
+
const host = input.connection.host ?? DEFAULT_HOST;
|
|
303
|
+
const port = input.connection.port ?? DEFAULT_PORT;
|
|
304
|
+
// The password is deliberately not part of the fingerprint: this module must not retain it, and
|
|
305
|
+
// a pool necessarily holds authenticated sockets. A rotated password takes effect when the pool
|
|
306
|
+
// is recycled (target change or plugin reload).
|
|
307
|
+
const fingerprint = `${host}:${port}/${database}/${input.connection.user ?? ''}`;
|
|
308
|
+
return this.pools.acquire(input.connection.id, fingerprint, () => {
|
|
309
|
+
const options = {
|
|
310
|
+
host,
|
|
311
|
+
port,
|
|
312
|
+
database,
|
|
313
|
+
waitForConnections: true,
|
|
314
|
+
connectionLimit: POOL_CONNECTION_LIMIT,
|
|
315
|
+
maxIdle: POOL_CONNECTION_LIMIT,
|
|
316
|
+
idleTimeout: POOL_IDLE_TIMEOUT_MS,
|
|
317
|
+
connectTimeout: statementTimeout(input.queryTimeoutMs),
|
|
318
|
+
// UTF-8 must survive end to end: comments in this catalog are routinely Chinese.
|
|
319
|
+
charset: 'utf8mb4',
|
|
320
|
+
...(input.connection.user === undefined ? {} : { user: input.connection.user }),
|
|
321
|
+
...(input.password === undefined ? {} : { password: input.password }),
|
|
322
|
+
};
|
|
323
|
+
return this.driver.createPool(options);
|
|
324
|
+
}, pool => pool.end());
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Run work against one pooled connection, releasing it afterwards.
|
|
328
|
+
* @param input - the connection being served.
|
|
329
|
+
* @param work - the queries to run on the borrowed connection.
|
|
330
|
+
* @returns the work's result.
|
|
331
|
+
*/
|
|
332
|
+
async withConnection(input, work) {
|
|
333
|
+
const pool = await this.pool(input);
|
|
334
|
+
let connection;
|
|
335
|
+
try {
|
|
336
|
+
connection = await pool.getConnection();
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
throw new DbgraphError(`cannot connect to MySQL for connection "${input.connection.id}": ${messageOf(error)}`, 'DBGRAPH_UNAVAILABLE');
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
return await work(connection);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (error instanceof DbgraphError)
|
|
346
|
+
throw error;
|
|
347
|
+
throw new DbgraphError(`MySQL statement failed on connection "${input.connection.id}": ${messageOf(error)}`, DBGRAPH_QUERY_FAILED_CODE);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
connection.release();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Read the server version, tolerating a failure: version text is diagnostics, not catalog data.
|
|
355
|
+
* @param connection - the borrowed connection.
|
|
356
|
+
* @param input - the connection being served, for the timeout.
|
|
357
|
+
* @returns the version, or `undefined`.
|
|
358
|
+
*/
|
|
359
|
+
async readVersion(connection, input) {
|
|
360
|
+
try {
|
|
361
|
+
const [rows] = await connection.query({
|
|
362
|
+
sql: SERVER_VERSION_QUERY,
|
|
363
|
+
timeout: statementTimeout(input.queryTimeoutMs),
|
|
364
|
+
});
|
|
365
|
+
return rows.length === 0 ? undefined : asString(rows[0]?.['version']);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Merge L2 facts and samples onto one L1 object.
|
|
373
|
+
* @param connection - the borrowed connection, for enum sampling.
|
|
374
|
+
* @param input - the connection being served.
|
|
375
|
+
* @param object - the L1 object.
|
|
376
|
+
* @param facts - its `TABLES` facts, when the table still exists.
|
|
377
|
+
* @param comments - column comments keyed by table+column.
|
|
378
|
+
* @param uniqueColumns - its singly-unique columns.
|
|
379
|
+
* @param indexEdges - its index edges.
|
|
380
|
+
* @param foreignKeyEdges - its foreign-key edges.
|
|
381
|
+
* @param warnings - collector for tolerated per-object failures.
|
|
382
|
+
* @returns the enriched object, or an L1 copy when enrichment failed.
|
|
383
|
+
*/
|
|
384
|
+
async enrichObject(connection, input, object, facts, comments, uniqueColumns, indexEdges, foreignKeyEdges, warnings) {
|
|
385
|
+
try {
|
|
386
|
+
const samples = new Map();
|
|
387
|
+
for (const column of object.columns) {
|
|
388
|
+
throwIfAborted(input.signal);
|
|
389
|
+
if (!shouldSampleMysqlColumn(object.kind, input.connection, column))
|
|
390
|
+
continue;
|
|
391
|
+
const sampled = await this.sampleEnumValues(connection, input, object.name, column.name, warnings);
|
|
392
|
+
if (sampled !== undefined)
|
|
393
|
+
samples.set(column.name, sampled);
|
|
394
|
+
}
|
|
395
|
+
const edges = [...(foreignKeyEdges ?? []), ...(indexEdges ?? [])];
|
|
396
|
+
return mergeMysqlObject(object, facts, comments, uniqueColumns, edges, samples);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error instanceof DbgraphError && error.code === 'DBGRAPH_ABORTED')
|
|
400
|
+
throw error;
|
|
401
|
+
warnings.push(`mysql: enrichment failed for "${object.name}": ${messageOf(error)}`);
|
|
402
|
+
return { ...object, columns: object.columns.map(column => ({ ...column })) };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Sample a bounded set of distinct literals for one string-like column.
|
|
407
|
+
* @param connection - the borrowed connection.
|
|
408
|
+
* @param input - the connection being served.
|
|
409
|
+
* @param table - the table name.
|
|
410
|
+
* @param column - the column name.
|
|
411
|
+
* @param warnings - collector for a sample the server refused.
|
|
412
|
+
* @returns the literals, or `undefined` when sampling was skipped or produced nothing.
|
|
413
|
+
*/
|
|
414
|
+
async sampleEnumValues(connection, input, table, column, warnings) {
|
|
415
|
+
if (!Number.isFinite(input.enumSampleLimit) || input.enumSampleLimit < 1)
|
|
416
|
+
return undefined;
|
|
417
|
+
const limit = Math.floor(input.enumSampleLimit);
|
|
418
|
+
const database = requireDatabase(input);
|
|
419
|
+
const identifier = mysqlIdentifier(column);
|
|
420
|
+
const sql = `SELECT DISTINCT ${identifier} AS value`
|
|
421
|
+
+ ` FROM ${mysqlIdentifier(database)}.${mysqlIdentifier(table)}`
|
|
422
|
+
+ ` WHERE ${identifier} IS NOT NULL LIMIT ?`;
|
|
423
|
+
try {
|
|
424
|
+
const [rows] = await connection.query({ sql, timeout: statementTimeout(input.queryTimeoutMs) }, [limit]);
|
|
425
|
+
const values = [];
|
|
426
|
+
for (const row of rows) {
|
|
427
|
+
const value = enumText(row['value']);
|
|
428
|
+
if (value !== undefined && !values.includes(value))
|
|
429
|
+
values.push(value);
|
|
430
|
+
}
|
|
431
|
+
return values.length === 0 ? undefined : values;
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
warnings.push(`mysql: enum sampling failed for "${table}.${column}": ${messageOf(error)}`);
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Run one `information_schema` query and return its rows.
|
|
441
|
+
* @param connection - the borrowed connection.
|
|
442
|
+
* @param input - the connection being served, for the timeout.
|
|
443
|
+
* @param sql - the statement.
|
|
444
|
+
* @param values - bound values, in placeholder order.
|
|
445
|
+
* @returns the rows, typed by the caller's chosen row shape.
|
|
446
|
+
*/
|
|
447
|
+
async function queryRows(connection, input, sql, values) {
|
|
448
|
+
const [rows] = await connection.query({ sql, timeout: statementTimeout(input.queryTimeoutMs) }, values);
|
|
449
|
+
return rows;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The result column names, in result order.
|
|
453
|
+
* @param fields - the field packets `mysql2` returns beside a result set.
|
|
454
|
+
* @returns column names; an empty list when the driver supplied no fields.
|
|
455
|
+
*/
|
|
456
|
+
function resultColumns(fields) {
|
|
457
|
+
return (fields ?? []).map(field => field.name);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The configured database name for a connection.
|
|
461
|
+
* @param input - the connection.
|
|
462
|
+
* @returns the database name.
|
|
463
|
+
* @throws DbgraphError with `DBGRAPH_UNAVAILABLE` when the connection names no database.
|
|
464
|
+
*/
|
|
465
|
+
function requireDatabase(input) {
|
|
466
|
+
const database = input.connection.database;
|
|
467
|
+
if (database === undefined || database.trim() === '') {
|
|
468
|
+
throw new DbgraphError(`connection "${input.connection.id}" has no MySQL database configured`, 'DBGRAPH_UNAVAILABLE');
|
|
469
|
+
}
|
|
470
|
+
return database;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* A usable statement timeout.
|
|
474
|
+
* @param timeoutMs - the caller's value.
|
|
475
|
+
* @returns a positive whole number of milliseconds.
|
|
476
|
+
*/
|
|
477
|
+
function statementTimeout(timeoutMs) {
|
|
478
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1)
|
|
479
|
+
return FALLBACK_TIMEOUT_MS;
|
|
480
|
+
return Math.floor(timeoutMs);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Render one sampled value as text.
|
|
484
|
+
* @param value - a driver value.
|
|
485
|
+
* @returns the text form, or `undefined` for values that are not meaningful enum literals.
|
|
486
|
+
*/
|
|
487
|
+
function enumText(value) {
|
|
488
|
+
if (typeof value === 'string')
|
|
489
|
+
return value;
|
|
490
|
+
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
|
|
491
|
+
return String(value);
|
|
492
|
+
}
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
//# sourceMappingURL=mysql.js.map
|
package/lib/pool.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One warm handle per connection id.
|
|
3
|
+
*
|
|
4
|
+
* A dialect is asked for the same connection repeatedly inside one indexing run — L1, then L2, then
|
|
5
|
+
* possibly a `find` execution — and reconnecting for each phase would pay a handshake per query on
|
|
6
|
+
* MySQL and reopen a file per PRAGMA batch on SQLite. This cache keeps the handle alive for the
|
|
7
|
+
* plugin's lifetime instead, which is also why every dialect exposes `close()` and the plugin calls
|
|
8
|
+
* it on disposal.
|
|
9
|
+
*
|
|
10
|
+
* State is keyed by **connection id**, exactly as configured. The fingerprint beside it guards a
|
|
11
|
+
* configuration change: if the host/port/database behind the same id changes, the old handle is
|
|
12
|
+
* closed before a new one is opened, so a renamed target never silently serves the previous
|
|
13
|
+
* database.
|
|
14
|
+
*
|
|
15
|
+
* The cached password situation is deliberate: a pool necessarily holds authenticated connections,
|
|
16
|
+
* but this module never stores the plaintext password itself and never passes it to `open` a second
|
|
17
|
+
* time for an existing entry — the caller's per-operation password is only consulted when a handle
|
|
18
|
+
* is actually created.
|
|
19
|
+
* @module dsh-plugin-dbgraph-catalog/pool
|
|
20
|
+
*/
|
|
21
|
+
/** A handle cache keyed by connection id. */
|
|
22
|
+
export declare class HandleCache<T> {
|
|
23
|
+
private readonly entries;
|
|
24
|
+
/** Number of live handles; exposed so a test can prove reuse rather than infer it from timing. */
|
|
25
|
+
get size(): number;
|
|
26
|
+
/**
|
|
27
|
+
* Return the cached handle for one connection, opening it when absent or when the target changed.
|
|
28
|
+
* Concurrent callers for the same key share one open, so two statements cannot race into two
|
|
29
|
+
* pools.
|
|
30
|
+
* @param key - the connection id.
|
|
31
|
+
* @param fingerprint - target identity; a change closes the previous handle first.
|
|
32
|
+
* @param open - creates the handle; may be synchronous.
|
|
33
|
+
* @param close - closes the handle; called on replacement and on {@link closeAll}.
|
|
34
|
+
* @returns the warm handle.
|
|
35
|
+
*/
|
|
36
|
+
acquire(key: string, fingerprint: string, open: () => T | Promise<T>, close: (handle: T) => Promise<void> | void): Promise<T>;
|
|
37
|
+
/**
|
|
38
|
+
* Close every cached handle. Entries are removed before closing, so a close that fails cannot
|
|
39
|
+
* leave a handle this cache still believes it owns.
|
|
40
|
+
* @returns a promise that settles after every close was attempted.
|
|
41
|
+
*/
|
|
42
|
+
closeAll(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=pool.d.ts.map
|
package/lib/pool.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One warm handle per connection id.
|
|
3
|
+
*
|
|
4
|
+
* A dialect is asked for the same connection repeatedly inside one indexing run — L1, then L2, then
|
|
5
|
+
* possibly a `find` execution — and reconnecting for each phase would pay a handshake per query on
|
|
6
|
+
* MySQL and reopen a file per PRAGMA batch on SQLite. This cache keeps the handle alive for the
|
|
7
|
+
* plugin's lifetime instead, which is also why every dialect exposes `close()` and the plugin calls
|
|
8
|
+
* it on disposal.
|
|
9
|
+
*
|
|
10
|
+
* State is keyed by **connection id**, exactly as configured. The fingerprint beside it guards a
|
|
11
|
+
* configuration change: if the host/port/database behind the same id changes, the old handle is
|
|
12
|
+
* closed before a new one is opened, so a renamed target never silently serves the previous
|
|
13
|
+
* database.
|
|
14
|
+
*
|
|
15
|
+
* The cached password situation is deliberate: a pool necessarily holds authenticated connections,
|
|
16
|
+
* but this module never stores the plaintext password itself and never passes it to `open` a second
|
|
17
|
+
* time for an existing entry — the caller's per-operation password is only consulted when a handle
|
|
18
|
+
* is actually created.
|
|
19
|
+
* @module dsh-plugin-dbgraph-catalog/pool
|
|
20
|
+
*/
|
|
21
|
+
/** A handle cache keyed by connection id. */
|
|
22
|
+
export class HandleCache {
|
|
23
|
+
entries = new Map();
|
|
24
|
+
/** Number of live handles; exposed so a test can prove reuse rather than infer it from timing. */
|
|
25
|
+
get size() {
|
|
26
|
+
return this.entries.size;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Return the cached handle for one connection, opening it when absent or when the target changed.
|
|
30
|
+
* Concurrent callers for the same key share one open, so two statements cannot race into two
|
|
31
|
+
* pools.
|
|
32
|
+
* @param key - the connection id.
|
|
33
|
+
* @param fingerprint - target identity; a change closes the previous handle first.
|
|
34
|
+
* @param open - creates the handle; may be synchronous.
|
|
35
|
+
* @param close - closes the handle; called on replacement and on {@link closeAll}.
|
|
36
|
+
* @returns the warm handle.
|
|
37
|
+
*/
|
|
38
|
+
async acquire(key, fingerprint, open, close) {
|
|
39
|
+
const existing = this.entries.get(key);
|
|
40
|
+
if (existing !== undefined) {
|
|
41
|
+
if (existing.fingerprint === fingerprint)
|
|
42
|
+
return existing.ready;
|
|
43
|
+
this.entries.delete(key);
|
|
44
|
+
await settleClose(existing);
|
|
45
|
+
}
|
|
46
|
+
const entry = {
|
|
47
|
+
fingerprint,
|
|
48
|
+
close,
|
|
49
|
+
ready: Promise.resolve().then(open),
|
|
50
|
+
};
|
|
51
|
+
this.entries.set(key, entry);
|
|
52
|
+
try {
|
|
53
|
+
return await entry.ready;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// A failed open must not leave a permanently rejected entry behind: the next call should be
|
|
57
|
+
// able to retry (the server may simply have been starting up).
|
|
58
|
+
if (this.entries.get(key) === entry)
|
|
59
|
+
this.entries.delete(key);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Close every cached handle. Entries are removed before closing, so a close that fails cannot
|
|
65
|
+
* leave a handle this cache still believes it owns.
|
|
66
|
+
* @returns a promise that settles after every close was attempted.
|
|
67
|
+
*/
|
|
68
|
+
async closeAll() {
|
|
69
|
+
const entries = [...this.entries.values()];
|
|
70
|
+
this.entries.clear();
|
|
71
|
+
for (const entry of entries)
|
|
72
|
+
await settleClose(entry);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Close one entry, tolerating both a handle that never finished opening and a failing close — a
|
|
77
|
+
* shutdown path must not throw because the thing it is shutting down already failed.
|
|
78
|
+
* @param entry - the cached entry to close.
|
|
79
|
+
*/
|
|
80
|
+
async function settleClose(entry) {
|
|
81
|
+
try {
|
|
82
|
+
await entry.close(await entry.ready);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// Disposal continues either way, but a handle that never closed is a leaked socket or file
|
|
86
|
+
// descriptor: say so instead of dropping it.
|
|
87
|
+
console.error('[dbgraph-catalog] closing a cached handle failed:', error instanceof Error ? error.message : String(error));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=pool.js.map
|