@bytebase/dbhub 1.2.5 → 1.3.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.
@@ -0,0 +1,754 @@
1
+ import {
2
+ computeHitRatioPct,
3
+ toNullableNumber
4
+ } from "./chunk-FU2ZJE4E.js";
5
+ import {
6
+ SQLRowLimiter,
7
+ closeQuietly
8
+ } from "./chunk-YIGJHEU6.js";
9
+ import {
10
+ isReadOnlySQL
11
+ } from "./chunk-ABTVMHD6.js";
12
+ import {
13
+ ConnectorRegistry,
14
+ LEADING_SQL_NOISE,
15
+ SafeURL,
16
+ blankCommentsAndStrings,
17
+ obfuscateDSNPassword,
18
+ stripCommentsAndStrings
19
+ } from "./chunk-IXC4NQGC.js";
20
+
21
+ // src/connectors/oracle/index.ts
22
+ import oracledb from "oracledb";
23
+ var OracleDSNParser = class {
24
+ async parse(dsn, config) {
25
+ if (!this.isValidDSN(dsn)) {
26
+ throw new Error(
27
+ `Invalid Oracle DSN format.
28
+ Provided: ${obfuscateDSNPassword(dsn)}
29
+ Expected: ${this.getSampleDSN()}`
30
+ );
31
+ }
32
+ try {
33
+ const url = new SafeURL(dsn);
34
+ let sslmode;
35
+ let sid;
36
+ url.forEachSearchParam((value, key) => {
37
+ if (key === "sslmode") {
38
+ sslmode = value;
39
+ } else if (key === "sid") {
40
+ sid = value;
41
+ }
42
+ });
43
+ if (sslmode !== void 0 && !["disable", "require", "verify-full"].includes(sslmode)) {
44
+ throw new Error(
45
+ `Unsupported sslmode '${sslmode}' for Oracle. Supported: disable, require, verify-full`
46
+ );
47
+ }
48
+ const host = url.hostname;
49
+ const port = url.port ? parseInt(url.port, 10) : 1521;
50
+ const service = url.pathname ? url.pathname.substring(1) : "";
51
+ if (!host) {
52
+ throw new Error("Oracle DSN must include a host");
53
+ }
54
+ if (!service && !sid) {
55
+ throw new Error("Oracle DSN must include a service name in the path (or ?sid=)");
56
+ }
57
+ const useTls = sslmode === "require" || sslmode === "verify-full";
58
+ let connectString;
59
+ if (sid) {
60
+ const protocol = useTls ? "TCPS" : "TCP";
61
+ connectString = `(DESCRIPTION=(ADDRESS=(PROTOCOL=${protocol})(HOST=${host})(PORT=${port}))(CONNECT_DATA=(SID=${sid})))`;
62
+ } else {
63
+ connectString = `${useTls ? "tcps://" : ""}${host}:${port}/${service}`;
64
+ }
65
+ const pool = {
66
+ user: url.username,
67
+ password: url.password,
68
+ connectString,
69
+ poolMin: 0,
70
+ poolMax: config?.poolMaxConnections ?? 4,
71
+ poolIncrement: 1
72
+ };
73
+ if (config?.connectionTimeoutSeconds !== void 0) {
74
+ pool.connectTimeout = config.connectionTimeoutSeconds;
75
+ }
76
+ if (useTls) {
77
+ pool.sslServerDNMatch = sslmode === "verify-full";
78
+ }
79
+ return {
80
+ pool,
81
+ ...config?.queryTimeoutSeconds !== void 0 && {
82
+ callTimeoutMs: config.queryTimeoutSeconds * 1e3
83
+ }
84
+ };
85
+ } catch (error) {
86
+ throw new Error(
87
+ `Failed to parse Oracle DSN: ${error instanceof Error ? error.message : String(error)}`
88
+ );
89
+ }
90
+ }
91
+ getSampleDSN() {
92
+ return "oracle://username:password@localhost:1521/FREEPDB1?sslmode=disable";
93
+ }
94
+ isValidDSN(dsn) {
95
+ return dsn.startsWith("oracle://");
96
+ }
97
+ };
98
+ var _OracleConnector = class _OracleConnector {
99
+ constructor() {
100
+ this.id = "oracle";
101
+ this.name = "Oracle";
102
+ this.dsnParser = new OracleDSNParser();
103
+ /** CURRENT_SCHEMA of the connected session, resolved once at connect time */
104
+ this.defaultSchema = "";
105
+ // Source ID is set by ConnectorManager after cloning
106
+ this.sourceId = "default";
107
+ }
108
+ getId() {
109
+ return this.sourceId;
110
+ }
111
+ clone() {
112
+ return new _OracleConnector();
113
+ }
114
+ async connect(dsn, initScript, config) {
115
+ try {
116
+ const parsed = await this.dsnParser.parse(dsn, config);
117
+ this.callTimeoutMs = parsed.callTimeoutMs;
118
+ this.pool = await oracledb.createPool(parsed.pool);
119
+ const rows = await this.query(
120
+ "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS schema_name FROM dual"
121
+ );
122
+ this.defaultSchema = rows[0]?.SCHEMA_NAME ?? _OracleConnector.foldIdentifier(parsed.pool.user ?? "");
123
+ if (initScript) {
124
+ await this.executeSQL(initScript, {});
125
+ }
126
+ } catch (error) {
127
+ await closeQuietly(() => this.disconnect());
128
+ throw error;
129
+ }
130
+ }
131
+ async disconnect() {
132
+ if (this.pool) {
133
+ const pool = this.pool;
134
+ this.pool = void 0;
135
+ await pool.close(0);
136
+ }
137
+ }
138
+ /** Check out a pooled connection with the configured statement timeout applied. */
139
+ async acquire() {
140
+ if (!this.pool) {
141
+ throw new Error("Not connected to Oracle database");
142
+ }
143
+ const connection = await this.pool.getConnection();
144
+ if (this.callTimeoutMs !== void 0) {
145
+ connection.callTimeout = this.callTimeoutMs;
146
+ }
147
+ return connection;
148
+ }
149
+ /** Run one or more catalog queries on a single short-lived pooled connection. */
150
+ async withConnection(fn) {
151
+ const connection = await this.acquire();
152
+ try {
153
+ return await fn(connection);
154
+ } finally {
155
+ await connection.close();
156
+ }
157
+ }
158
+ static async fetchRows(connection, sql, binds = {}) {
159
+ const result = await connection.execute(sql, binds, {
160
+ outFormat: oracledb.OUT_FORMAT_OBJECT,
161
+ fetchTypeHandler: _OracleConnector.fetchTypeHandler
162
+ });
163
+ return result.rows ?? [];
164
+ }
165
+ /** Run one catalog query on a short-lived pooled connection. */
166
+ query(sql, binds = {}) {
167
+ return this.withConnection((connection) => _OracleConnector.fetchRows(connection, sql, binds));
168
+ }
169
+ /**
170
+ * Per-column fetch rules:
171
+ * - CLOB/NCLOB come back as strings and BLOB as a Buffer instead of Lob
172
+ * streams, so result rows are plain JSON. (LONG columns such as
173
+ * ALL_TAB_COLUMNS.DATA_DEFAULT are already fetched as strings by default.)
174
+ * - NUMBER is fetched as its exact decimal string and converted here:
175
+ * integers within Number's safe range become numbers, larger integers
176
+ * become BigInt (the response serializer renders those as strings), and
177
+ * anything with a fraction or exponent becomes a number. The driver's
178
+ * default would round a NUMBER(20) identifier above 2^53 silently.
179
+ */
180
+ static fetchTypeHandler(metaData) {
181
+ if (metaData.dbType === oracledb.DB_TYPE_CLOB || metaData.dbType === oracledb.DB_TYPE_NCLOB) {
182
+ return { type: oracledb.STRING };
183
+ }
184
+ if (metaData.dbType === oracledb.DB_TYPE_BLOB) {
185
+ return { type: oracledb.BUFFER };
186
+ }
187
+ if (metaData.dbType === oracledb.DB_TYPE_NUMBER) {
188
+ return { type: oracledb.STRING, converter: _OracleConnector.convertNumber };
189
+ }
190
+ return void 0;
191
+ }
192
+ /**
193
+ * See fetchTypeHandler. Receives the NUMBER's decimal string (the generic
194
+ * signature is what oracledb's converter type requires). Public for unit
195
+ * testing.
196
+ */
197
+ static convertNumber(value) {
198
+ if (value === null || value === void 0) {
199
+ return null;
200
+ }
201
+ const text = String(value);
202
+ if (/^-?\d+$/.test(text)) {
203
+ const asNumber = Number(text);
204
+ return Number.isSafeInteger(asNumber) ? asNumber : BigInt(text);
205
+ }
206
+ return Number(text);
207
+ }
208
+ /**
209
+ * The catalog spelling of an identifier a caller wrote unquoted: Oracle
210
+ * folds those to upper case. A name that already contains an upper-case
211
+ * letter is taken as spelled, so a case-sensitive quoted identifier like
212
+ * "MyTable" is still reachable by its exact name. (An all-lower-case quoted
213
+ * identifier is not; that trade keeps every catalog predicate a plain
214
+ * equality on an indexed column.)
215
+ */
216
+ static foldIdentifier(name) {
217
+ return /[A-Z]/.test(name) ? name : name.toUpperCase();
218
+ }
219
+ schemaOrDefault(schema) {
220
+ return schema ? _OracleConnector.foldIdentifier(schema) : this.defaultSchema;
221
+ }
222
+ async getSchemas() {
223
+ try {
224
+ const rows = await this.query(
225
+ `SELECT username
226
+ FROM all_users
227
+ WHERE oracle_maintained = 'N' OR username = :current_schema
228
+ ORDER BY username`,
229
+ { current_schema: this.schemaOrDefault() }
230
+ );
231
+ return rows.map((row) => row.USERNAME);
232
+ } catch (error) {
233
+ throw new Error(`Failed to get schemas: ${error.message}`);
234
+ }
235
+ }
236
+ async getDefaultSchema() {
237
+ return this.defaultSchema || null;
238
+ }
239
+ async getTables(schema) {
240
+ try {
241
+ const rows = await this.query(
242
+ `SELECT table_name
243
+ FROM all_tables
244
+ WHERE owner = :schema
245
+ AND nested = 'NO'
246
+ AND secondary = 'N'
247
+ AND (iot_type IS NULL OR iot_type = 'IOT')
248
+ AND table_name NOT LIKE 'BIN$%'
249
+ ORDER BY table_name`,
250
+ { schema: this.schemaOrDefault(schema) }
251
+ );
252
+ return rows.map((row) => row.TABLE_NAME);
253
+ } catch (error) {
254
+ throw new Error(`Failed to get tables: ${error.message}`);
255
+ }
256
+ }
257
+ async getViews(schema) {
258
+ try {
259
+ const rows = await this.query(
260
+ `SELECT view_name FROM all_views WHERE owner = :schema ORDER BY view_name`,
261
+ { schema: this.schemaOrDefault(schema) }
262
+ );
263
+ return rows.map((row) => row.VIEW_NAME);
264
+ } catch (error) {
265
+ throw new Error(`Failed to get views: ${error.message}`);
266
+ }
267
+ }
268
+ async tableExists(tableName, schema) {
269
+ try {
270
+ const rows = await this.query(
271
+ `SELECT COUNT(*) AS cnt FROM all_tables WHERE owner = :schema AND table_name = :table_name`,
272
+ { schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
273
+ );
274
+ return Number(rows[0]?.CNT ?? 0) > 0;
275
+ } catch (error) {
276
+ throw new Error(`Failed to check if table exists: ${error.message}`);
277
+ }
278
+ }
279
+ async getTableSchema(tableName, schema) {
280
+ try {
281
+ const rows = await this.query(
282
+ `SELECT c.column_name,
283
+ c.data_type,
284
+ c.data_length,
285
+ c.char_length,
286
+ c.data_precision,
287
+ c.data_scale,
288
+ c.nullable,
289
+ c.data_default,
290
+ cc.comments AS description
291
+ FROM all_tab_columns c
292
+ LEFT JOIN all_col_comments cc
293
+ ON cc.owner = c.owner
294
+ AND cc.table_name = c.table_name
295
+ AND cc.column_name = c.column_name
296
+ WHERE c.owner = :schema
297
+ AND c.table_name = :table_name
298
+ ORDER BY c.column_id`,
299
+ { schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
300
+ );
301
+ return rows.map((row) => ({
302
+ column_name: row.COLUMN_NAME,
303
+ data_type: _OracleConnector.formatDataType(row),
304
+ is_nullable: row.NULLABLE === "Y" ? "YES" : "NO",
305
+ // DATA_DEFAULT is a LONG that keeps the DDL's trailing whitespace.
306
+ column_default: row.DATA_DEFAULT?.trim() || null,
307
+ description: row.DESCRIPTION || null
308
+ }));
309
+ } catch (error) {
310
+ throw new Error(`Failed to get schema for table ${tableName}: ${error.message}`);
311
+ }
312
+ }
313
+ /**
314
+ * Render a column's type the way it appears in DDL: `VARCHAR2(100)`,
315
+ * `NUMBER(10,2)`, `NUMBER`, `TIMESTAMP(6)`. Oracle's catalog splits these
316
+ * across several columns.
317
+ */
318
+ static formatDataType(row) {
319
+ const type = row.DATA_TYPE;
320
+ if (/^(?:N?VARCHAR2|N?CHAR|RAW)$/.test(type)) {
321
+ const length = type === "RAW" ? row.DATA_LENGTH : row.CHAR_LENGTH;
322
+ return length ? `${type}(${length})` : type;
323
+ }
324
+ if (type === "NUMBER") {
325
+ if (row.DATA_PRECISION === null) {
326
+ return row.DATA_SCALE === null ? type : `NUMBER(*,${row.DATA_SCALE})`;
327
+ }
328
+ return row.DATA_SCALE ? `NUMBER(${row.DATA_PRECISION},${row.DATA_SCALE})` : `NUMBER(${row.DATA_PRECISION})`;
329
+ }
330
+ if (type === "FLOAT" && row.DATA_PRECISION !== null) {
331
+ return `FLOAT(${row.DATA_PRECISION})`;
332
+ }
333
+ return type;
334
+ }
335
+ async getTableIndexes(tableName, schema) {
336
+ try {
337
+ const rows = await this.query(
338
+ `SELECT i.index_name,
339
+ i.uniqueness,
340
+ CASE WHEN pk.constraint_name IS NOT NULL THEN 1 ELSE 0 END AS is_primary,
341
+ ic.column_name
342
+ FROM all_indexes i
343
+ JOIN all_ind_columns ic
344
+ ON ic.index_owner = i.owner
345
+ AND ic.index_name = i.index_name
346
+ LEFT JOIN all_constraints pk
347
+ ON pk.owner = i.table_owner
348
+ AND pk.table_name = i.table_name
349
+ AND pk.constraint_type = 'P'
350
+ AND pk.index_owner = i.owner
351
+ AND pk.index_name = i.index_name
352
+ WHERE i.table_owner = :schema
353
+ AND i.table_name = :table_name
354
+ ORDER BY i.index_name, ic.column_position`,
355
+ { schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
356
+ );
357
+ const indexMap = /* @__PURE__ */ new Map();
358
+ for (const row of rows) {
359
+ let index = indexMap.get(row.INDEX_NAME);
360
+ if (!index) {
361
+ index = {
362
+ index_name: row.INDEX_NAME,
363
+ column_names: [],
364
+ is_unique: row.UNIQUENESS === "UNIQUE",
365
+ is_primary: Number(row.IS_PRIMARY) === 1
366
+ };
367
+ indexMap.set(row.INDEX_NAME, index);
368
+ }
369
+ index.column_names.push(row.COLUMN_NAME);
370
+ }
371
+ return Array.from(indexMap.values());
372
+ } catch (error) {
373
+ throw new Error(`Failed to get indexes for table ${tableName}: ${error.message}`);
374
+ }
375
+ }
376
+ async getTableComment(tableName, schema) {
377
+ try {
378
+ const rows = await this.query(
379
+ `SELECT comments FROM all_tab_comments WHERE owner = :schema AND table_name = :table_name`,
380
+ { schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
381
+ );
382
+ return rows[0]?.COMMENTS || null;
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+ async getTableRowCount(tableName, schema) {
388
+ try {
389
+ const rows = await this.query(
390
+ `SELECT num_rows FROM all_tables WHERE owner = :schema AND table_name = :table_name`,
391
+ { schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
392
+ );
393
+ const numRows = rows[0]?.NUM_ROWS;
394
+ return numRows === null || numRows === void 0 ? null : Number(numRows);
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+ async getHealthCheck() {
400
+ if (!this.pool) {
401
+ throw new Error("Not connected to Oracle database");
402
+ }
403
+ const notes = [];
404
+ const result = {};
405
+ try {
406
+ const [sessions, params] = await this.withConnection(
407
+ (connection) => Promise.all([
408
+ _OracleConnector.fetchRows(
409
+ connection,
410
+ // STATUS is ACTIVE (running a call), INACTIVE (idle), or one of
411
+ // the transitional states KILLED / SNIPED / CACHED, which count
412
+ // toward the total but are neither active nor idle. TADDR is
413
+ // non-null while the session has an open transaction;
414
+ // LAST_CALL_ET is seconds since the current call began (ACTIVE)
415
+ // or since the last call ended (otherwise).
416
+ `SELECT
417
+ COUNT(*) AS total,
418
+ SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) AS active,
419
+ SUM(CASE WHEN status = 'INACTIVE' THEN 1 ELSE 0 END) AS idle,
420
+ SUM(CASE WHEN status = 'INACTIVE' AND taddr IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction,
421
+ MAX(CASE WHEN status = 'INACTIVE' AND taddr IS NOT NULL THEN last_call_et END) AS longest_idle_in_transaction_seconds,
422
+ MAX(CASE WHEN status = 'ACTIVE' THEN last_call_et END) AS longest_active_query_seconds
423
+ FROM v$session
424
+ WHERE type = 'USER'
425
+ AND sid <> SYS_CONTEXT('USERENV', 'SID')`
426
+ ),
427
+ _OracleConnector.fetchRows(
428
+ connection,
429
+ `SELECT value FROM v$parameter WHERE name = 'sessions'`
430
+ )
431
+ ])
432
+ );
433
+ const conn = sessions[0];
434
+ const maxConnections = params.length > 0 ? Number(params[0].VALUE) : null;
435
+ result.connections = {
436
+ total: Number(conn.TOTAL ?? 0),
437
+ active: Number(conn.ACTIVE ?? 0),
438
+ idle: Number(conn.IDLE ?? 0),
439
+ idleInTransaction: Number(conn.IDLE_IN_TRANSACTION ?? 0),
440
+ // Oracle has no equivalent of Postgres's "idle in transaction
441
+ // (aborted)" state: a failed statement is rolled back on its own
442
+ // and leaves the transaction usable.
443
+ maxConnections: maxConnections !== null && maxConnections > 0 ? maxConnections : null,
444
+ longestIdleInTransactionSeconds: toNullableNumber(conn.LONGEST_IDLE_IN_TRANSACTION_SECONDS),
445
+ longestActiveQuerySeconds: toNullableNumber(conn.LONGEST_ACTIVE_QUERY_SECONDS)
446
+ };
447
+ } catch {
448
+ notes.push(
449
+ "Connection pool metrics unavailable: connecting user lacks SELECT on V$SESSION / V$PARAMETER (grant SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY)."
450
+ );
451
+ }
452
+ try {
453
+ const stats = await this.query(
454
+ `SELECT name, value FROM v$sysstat
455
+ WHERE name IN ('db block gets', 'consistent gets', 'physical reads')`
456
+ );
457
+ const byName = Object.fromEntries(stats.map((row) => [row.NAME, Number(row.VALUE)]));
458
+ const logicalReads = (byName["db block gets"] ?? 0) + (byName["consistent gets"] ?? 0);
459
+ const physicalReads = byName["physical reads"] ?? 0;
460
+ result.bufferCache = {
461
+ hitRatioPct: computeHitRatioPct(logicalReads, physicalReads),
462
+ blocksHit: logicalReads - physicalReads,
463
+ blocksRead: physicalReads
464
+ };
465
+ } catch {
466
+ notes.push(
467
+ "Buffer cache metrics unavailable: connecting user lacks SELECT on V$SYSSTAT (grant SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY)."
468
+ );
469
+ }
470
+ if (notes.length > 0) {
471
+ result.notes = notes;
472
+ }
473
+ return result;
474
+ }
475
+ async getStoredProcedures(schema, routineType) {
476
+ try {
477
+ const typeFilter = routineType === "function" ? "object_type = 'FUNCTION'" : routineType === "procedure" ? "object_type = 'PROCEDURE'" : "object_type IN ('PROCEDURE', 'FUNCTION')";
478
+ const rows = await this.query(
479
+ `SELECT object_name FROM all_objects WHERE owner = :schema AND ${typeFilter} ORDER BY object_name`,
480
+ { schema: this.schemaOrDefault(schema) }
481
+ );
482
+ return rows.map((row) => row.OBJECT_NAME);
483
+ } catch (error) {
484
+ throw new Error(`Failed to get stored procedures: ${error.message}`);
485
+ }
486
+ }
487
+ async getStoredProcedureDetail(procedureName, schema) {
488
+ try {
489
+ const schemaToUse = this.schemaOrDefault(schema);
490
+ const name = _OracleConnector.foldIdentifier(procedureName);
491
+ return await this.withConnection(async (connection) => {
492
+ const objects = await _OracleConnector.fetchRows(
493
+ connection,
494
+ `SELECT object_type
495
+ FROM all_objects
496
+ WHERE owner = :schema AND object_name = :name
497
+ AND object_type IN ('PROCEDURE', 'FUNCTION')`,
498
+ { schema: schemaToUse, name }
499
+ );
500
+ if (objects.length === 0) {
501
+ throw new Error(`Stored procedure '${procedureName}' not found in schema '${schemaToUse}'`);
502
+ }
503
+ const objectType = objects[0].OBJECT_TYPE;
504
+ const isFunction = objectType === "FUNCTION";
505
+ const [args, source] = await Promise.all([
506
+ // Standalone routines only (package_name IS NULL). Position 0 with
507
+ // no argument name is a function's return value.
508
+ _OracleConnector.fetchRows(
509
+ connection,
510
+ `SELECT argument_name, position, in_out, data_type
511
+ FROM all_arguments
512
+ WHERE owner = :schema AND object_name = :name
513
+ AND package_name IS NULL AND data_level = 0
514
+ ORDER BY position`,
515
+ { schema: schemaToUse, name }
516
+ ),
517
+ _OracleConnector.fetchRows(
518
+ connection,
519
+ `SELECT text FROM all_source
520
+ WHERE owner = :schema AND name = :name AND type = :object_type
521
+ ORDER BY line`,
522
+ { schema: schemaToUse, name, object_type: objectType }
523
+ )
524
+ ]);
525
+ const returnType = args.find((arg) => arg.POSITION === 0 && arg.ARGUMENT_NAME === null)?.DATA_TYPE;
526
+ const parameterList = args.filter((arg) => arg.ARGUMENT_NAME !== null).map((arg) => `${arg.ARGUMENT_NAME} ${arg.IN_OUT} ${arg.DATA_TYPE ?? ""}`.trim()).join(", ");
527
+ return {
528
+ procedure_name: name,
529
+ procedure_type: isFunction ? "function" : "procedure",
530
+ language: "plsql",
531
+ parameter_list: parameterList,
532
+ return_type: isFunction ? returnType ?? void 0 : void 0,
533
+ definition: source.length > 0 ? source.map((row) => row.TEXT).join("") : void 0
534
+ };
535
+ });
536
+ } catch (error) {
537
+ throw new Error(`Failed to get stored procedure details: ${error.message}`);
538
+ }
539
+ }
540
+ /**
541
+ * Bind values for one statement, keyed by placeholder name. DBHub's
542
+ * placeholders are `:1`, `:2`, ... and each names parameters[N-1]; binding
543
+ * by name (rather than handing the driver a positional array) lets a
544
+ * placeholder repeat (`:1 ... :1`) or appear out of order, and lets a batch
545
+ * hand each statement only the binds it uses, since the driver rejects a
546
+ * bind object naming a placeholder the statement lacks (NJS-097).
547
+ */
548
+ static bindsFor(statement, parameters) {
549
+ const binds = {};
550
+ const blanked = blankCommentsAndStrings(statement, "oracle");
551
+ for (const match of blanked.matchAll(/(?<!:):(\d+)\b/g)) {
552
+ const index = parseInt(match[1], 10);
553
+ if (index >= 1 && index <= parameters.length) {
554
+ binds[match[1]] = parameters[index - 1];
555
+ }
556
+ }
557
+ return binds;
558
+ }
559
+ /**
560
+ * Wrap a driver error with context while keeping the properties the
561
+ * connection-error classifier reads (`code`, `errorNum`), which a plain
562
+ * `new Error(message)` would drop.
563
+ */
564
+ static wrapError(prefix, error) {
565
+ const wrapped = new Error(`${prefix}: ${error.message}`, { cause: error });
566
+ for (const key of ["code", "errorNum", "offset"]) {
567
+ const value = error?.[key];
568
+ if (value !== void 0) {
569
+ wrapped[key] = value;
570
+ }
571
+ }
572
+ return wrapped;
573
+ }
574
+ async executeSQL(sqlQuery, options, parameters) {
575
+ const afterNoise = sqlQuery.replace(LEADING_SQL_NOISE, "");
576
+ if (/^explain\b/i.test(afterNoise)) {
577
+ return this.explainQuery(afterNoise.slice("explain".length), options.readonly, parameters);
578
+ }
579
+ const statements = _OracleConnector.splitStatements(afterNoise);
580
+ const connection = await this.acquire();
581
+ try {
582
+ if (options.readonly) {
583
+ await connection.execute("SET TRANSACTION READ ONLY");
584
+ }
585
+ const resultSets = [];
586
+ for (const statement of statements) {
587
+ const { sql: processedSQL, probeApplied } = SQLRowLimiter.applyMaxRowsForOracleWithTruncationProbe(statement, options.maxRows);
588
+ const result = await connection.execute(
589
+ processedSQL,
590
+ _OracleConnector.bindsFor(statement, parameters ?? []),
591
+ {
592
+ outFormat: oracledb.OUT_FORMAT_OBJECT,
593
+ fetchTypeHandler: _OracleConnector.fetchTypeHandler
594
+ }
595
+ );
596
+ const rows = result.rows ?? [];
597
+ const resultSet = {
598
+ sql: statement,
599
+ rows,
600
+ rowCount: result.rows ? rows.length : result.rowsAffected ?? 0
601
+ };
602
+ SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied);
603
+ resultSets.push(resultSet);
604
+ }
605
+ if (options.readonly) {
606
+ await connection.rollback();
607
+ } else {
608
+ await connection.commit();
609
+ }
610
+ return { resultSets };
611
+ } catch (error) {
612
+ await closeQuietly(() => connection.rollback());
613
+ throw _OracleConnector.wrapError("Failed to execute query", error);
614
+ } finally {
615
+ await connection.close();
616
+ }
617
+ }
618
+ /**
619
+ * Split a batch into the statements Oracle executes one per round trip.
620
+ *
621
+ * Plain SQL ends at a top-level semicolon, which is stripped: Oracle
622
+ * rejects a trailing `;` on a SQL statement (ORA-00933). A PL/SQL block,
623
+ * or the DDL that creates one, *requires* its semicolons and is sent
624
+ * whole: it ends at the `;` that closes its outermost BEGIN ... END
625
+ * (depth-tracked over `begin`/`case` ... `end`, with `end if` / `end loop`
626
+ * neutral), or at a SQL*Plus `/` line, which is dropped either way. The
627
+ * scan runs on the comment/string-blanked text so nothing inside a
628
+ * literal or comment counts.
629
+ */
630
+ static splitStatements(sql) {
631
+ const blanked = blankCommentsAndStrings(sql, "oracle");
632
+ const statements = [];
633
+ const boundary = /(?:\s|\/(?=[ \t]*(?:\r?\n|$)))*/y;
634
+ const plainEnd = /;|^[ \t]*\/[ \t]*$/gm;
635
+ const token = /\b(begin|case|end|compound\s+trigger)\b(?:\s+(if|loop|case))?|;|^[ \t]*\/[ \t]*$/gim;
636
+ const push = (start, end) => {
637
+ const text = sql.slice(start, end).trim();
638
+ if (text) statements.push(text);
639
+ };
640
+ let i = 0;
641
+ while (i < blanked.length) {
642
+ boundary.lastIndex = i;
643
+ i += boundary.exec(blanked)[0].length;
644
+ if (i >= blanked.length) break;
645
+ const start = i;
646
+ const rest = blanked.slice(i);
647
+ const isUnit = _OracleConnector.PLSQL_UNIT.test(rest);
648
+ if (!isUnit && !_OracleConnector.PLSQL_BODY.test(rest)) {
649
+ plainEnd.lastIndex = i;
650
+ const m2 = plainEnd.exec(blanked);
651
+ const end2 = m2?.index ?? blanked.length;
652
+ push(start, end2);
653
+ i = end2 + (m2?.[0] === ";" ? 1 : 0);
654
+ continue;
655
+ }
656
+ let depth = isUnit ? 1 : 0;
657
+ let opened = isUnit;
658
+ let end = blanked.length;
659
+ token.lastIndex = i;
660
+ let m;
661
+ while ((m = token.exec(blanked)) !== null) {
662
+ if (m[0] === ";") {
663
+ if (opened && depth === 0) {
664
+ end = m.index + 1;
665
+ break;
666
+ }
667
+ } else if (m[1] === void 0) {
668
+ end = m.index;
669
+ break;
670
+ } else {
671
+ const keyword = m[1].toLowerCase();
672
+ const closes = m[2]?.toLowerCase();
673
+ if (keyword !== "end") {
674
+ depth++;
675
+ opened = true;
676
+ } else if (closes === void 0 || closes === "case") {
677
+ depth--;
678
+ }
679
+ }
680
+ }
681
+ push(start, end);
682
+ i = end;
683
+ }
684
+ return statements;
685
+ }
686
+ /**
687
+ * Run `EXPLAIN PLAN FOR <statement>` and return the formatted plan.
688
+ *
689
+ * EXPLAIN PLAN parses and optimizes the statement without executing it. It
690
+ * stores the plan in PLAN_TABLE (a session-private global temporary table),
691
+ * which is why this path runs outside the READ ONLY transaction used by
692
+ * executeSQL and cleans up its rows afterwards.
693
+ *
694
+ * Accepts the Postgres-style `EXPLAIN <stmt>` the explain_sql tool emits as
695
+ * well as Oracle's own `EXPLAIN PLAN [SET STATEMENT_ID = '...'] FOR <stmt>`.
696
+ */
697
+ async explainQuery(afterExplain, readonly, parameters) {
698
+ const innerQuery = afterExplain.replace(/^\s*plan\b(?:\s+set\s+statement_id\s*=\s*'[^']*')?\s+for\b/i, "").replace(/;\s*$/, "").trim();
699
+ if (!stripCommentsAndStrings(innerQuery, "oracle").trim()) {
700
+ throw new Error("EXPLAIN requires a statement to analyze");
701
+ }
702
+ if (readonly && !isReadOnlySQL(innerQuery, "oracle")) {
703
+ throw new Error("Read-only mode: EXPLAIN is only allowed for read statements");
704
+ }
705
+ const statementId = `dbhub_${Math.random().toString(36).slice(2, 14)}`;
706
+ return this.withConnection(async (connection) => {
707
+ try {
708
+ await connection.execute(
709
+ `EXPLAIN PLAN SET STATEMENT_ID = '${statementId}' FOR ${innerQuery}`,
710
+ _OracleConnector.bindsFor(innerQuery, parameters ?? [])
711
+ );
712
+ const lines = await _OracleConnector.fetchRows(
713
+ connection,
714
+ "SELECT plan_table_output FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', :id, 'TYPICAL'))",
715
+ { id: statementId }
716
+ );
717
+ const plan = lines.map((row) => row.PLAN_TABLE_OUTPUT).join("\n");
718
+ return {
719
+ resultSets: [
720
+ {
721
+ rows: lines.length > 0 ? [{ plan }] : [],
722
+ rowCount: lines.length > 0 ? 1 : 0
723
+ }
724
+ ]
725
+ };
726
+ } catch (error) {
727
+ throw _OracleConnector.wrapError("Failed to explain query", error);
728
+ } finally {
729
+ await closeQuietly(async () => {
730
+ await connection.execute("DELETE FROM plan_table WHERE statement_id = :id", { id: statementId });
731
+ await connection.commit();
732
+ });
733
+ }
734
+ });
735
+ }
736
+ };
737
+ /**
738
+ * Leading keywords of a statement whose body is PL/SQL. Such a statement
739
+ * ends at the semicolon that closes its outermost BEGIN ... END, not at
740
+ * the first semicolon (see splitStatements).
741
+ */
742
+ _OracleConnector.PLSQL_BODY = /^(?:begin|declare|create\s+(?:or\s+replace\s+)?(?:(?:editionable|noneditionable)\s+)?(?:procedure|function|trigger))\b/i;
743
+ /**
744
+ * Package specs/bodies and type bodies: `IS ... END name;` with no BEGIN
745
+ * of their own at the top level, so the IS/AS opens the block.
746
+ */
747
+ _OracleConnector.PLSQL_UNIT = /^create\s+(?:or\s+replace\s+)?(?:(?:editionable|noneditionable)\s+)?(?:package(?:\s+body)?|type\s+body)\b/i;
748
+ var OracleConnector = _OracleConnector;
749
+ var oracleConnector = new OracleConnector();
750
+ ConnectorRegistry.register(oracleConnector);
751
+ export {
752
+ OracleConnector,
753
+ OracleDSNParser
754
+ };