@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.
- package/README.md +7 -7
- package/dist/{chunk-A4TSDCPY.js → chunk-6SZKW6GD.js} +1 -1
- package/dist/{chunk-VDA5BVNE.js → chunk-77BJNE6E.js} +87 -57
- package/dist/{chunk-IQRBLAB4.js → chunk-ABTVMHD6.js} +40 -10
- package/dist/{chunk-MF6LSVWK.js → chunk-IXC4NQGC.js} +29 -3
- package/dist/{chunk-JFWX35TB.js → chunk-X2EC47W4.js} +1 -0
- package/dist/chunk-YIGJHEU6.js +343 -0
- package/dist/index.js +16 -15
- package/dist/{mariadb-EVJ5HLYH.js → mariadb-L7Z55ROY.js} +6 -5
- package/dist/{mysql-GXDUNLJ6.js → mysql-XXIHWQGR.js} +6 -5
- package/dist/oracle-OUKBU52X.js +754 -0
- package/dist/{postgres-M3W3Y6F5.js → postgres-6XJ7V4J4.js} +13 -6
- package/dist/public/assets/{index-BdBaFSHd.js → index-BRlIJaqn.js} +27 -27
- package/dist/public/index.html +1 -1
- package/dist/{registry-UF3N2H4G.js → registry-P4IGJ56H.js} +2 -2
- package/dist/{sqlite-ASYMU5BI.js → sqlite-AO2BKIC6.js} +22 -10
- package/dist/{sqlserver-WNVSBFJ3.js → sqlserver-AJUK6L55.js} +10 -18
- package/package.json +5 -2
- package/dist/chunk-6QETXS44.js +0 -254
|
@@ -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
|
+
};
|