@bytebase/dbhub
Version:
Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite
755 lines (753 loc) • 28.8 kB
JavaScript
import {
computeHitRatioPct,
toNullableNumber
} from "./chunk-FU2ZJE4E.js";
import {
SQLRowLimiter,
closeQuietly
} from "./chunk-YIGJHEU6.js";
import {
isReadOnlySQL
} from "./chunk-ABTVMHD6.js";
import {
ConnectorRegistry,
LEADING_SQL_NOISE,
SafeURL,
blankCommentsAndStrings,
obfuscateDSNPassword,
stripCommentsAndStrings
} from "./chunk-IXC4NQGC.js";
// src/connectors/oracle/index.ts
import oracledb from "oracledb";
var OracleDSNParser = class {
async parse(dsn, config) {
if (!this.isValidDSN(dsn)) {
throw new Error(
`Invalid Oracle DSN format.
Provided: ${obfuscateDSNPassword(dsn)}
Expected: ${this.getSampleDSN()}`
);
}
try {
const url = new SafeURL(dsn);
let sslmode;
let sid;
url.forEachSearchParam((value, key) => {
if (key === "sslmode") {
sslmode = value;
} else if (key === "sid") {
sid = value;
}
});
if (sslmode !== void 0 && !["disable", "require", "verify-full"].includes(sslmode)) {
throw new Error(
`Unsupported sslmode '${sslmode}' for Oracle. Supported: disable, require, verify-full`
);
}
const host = url.hostname;
const port = url.port ? parseInt(url.port, 10) : 1521;
const service = url.pathname ? url.pathname.substring(1) : "";
if (!host) {
throw new Error("Oracle DSN must include a host");
}
if (!service && !sid) {
throw new Error("Oracle DSN must include a service name in the path (or ?sid=)");
}
const useTls = sslmode === "require" || sslmode === "verify-full";
let connectString;
if (sid) {
const protocol = useTls ? "TCPS" : "TCP";
connectString = `(DESCRIPTION=(ADDRESS=(PROTOCOL=${protocol})(HOST=${host})(PORT=${port}))(CONNECT_DATA=(SID=${sid})))`;
} else {
connectString = `${useTls ? "tcps://" : ""}${host}:${port}/${service}`;
}
const pool = {
user: url.username,
password: url.password,
connectString,
poolMin: 0,
poolMax: config?.poolMaxConnections ?? 4,
poolIncrement: 1
};
if (config?.connectionTimeoutSeconds !== void 0) {
pool.connectTimeout = config.connectionTimeoutSeconds;
}
if (useTls) {
pool.sslServerDNMatch = sslmode === "verify-full";
}
return {
pool,
...config?.queryTimeoutSeconds !== void 0 && {
callTimeoutMs: config.queryTimeoutSeconds * 1e3
}
};
} catch (error) {
throw new Error(
`Failed to parse Oracle DSN: ${error instanceof Error ? error.message : String(error)}`
);
}
}
getSampleDSN() {
return "oracle://username:password@localhost:1521/FREEPDB1?sslmode=disable";
}
isValidDSN(dsn) {
return dsn.startsWith("oracle://");
}
};
var _OracleConnector = class _OracleConnector {
constructor() {
this.id = "oracle";
this.name = "Oracle";
this.dsnParser = new OracleDSNParser();
/** CURRENT_SCHEMA of the connected session, resolved once at connect time */
this.defaultSchema = "";
// Source ID is set by ConnectorManager after cloning
this.sourceId = "default";
}
getId() {
return this.sourceId;
}
clone() {
return new _OracleConnector();
}
async connect(dsn, initScript, config) {
try {
const parsed = await this.dsnParser.parse(dsn, config);
this.callTimeoutMs = parsed.callTimeoutMs;
this.pool = await oracledb.createPool(parsed.pool);
const rows = await this.query(
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS schema_name FROM dual"
);
this.defaultSchema = rows[0]?.SCHEMA_NAME ?? _OracleConnector.foldIdentifier(parsed.pool.user ?? "");
if (initScript) {
await this.executeSQL(initScript, {});
}
} catch (error) {
await closeQuietly(() => this.disconnect());
throw error;
}
}
async disconnect() {
if (this.pool) {
const pool = this.pool;
this.pool = void 0;
await pool.close(0);
}
}
/** Check out a pooled connection with the configured statement timeout applied. */
async acquire() {
if (!this.pool) {
throw new Error("Not connected to Oracle database");
}
const connection = await this.pool.getConnection();
if (this.callTimeoutMs !== void 0) {
connection.callTimeout = this.callTimeoutMs;
}
return connection;
}
/** Run one or more catalog queries on a single short-lived pooled connection. */
async withConnection(fn) {
const connection = await this.acquire();
try {
return await fn(connection);
} finally {
await connection.close();
}
}
static async fetchRows(connection, sql, binds = {}) {
const result = await connection.execute(sql, binds, {
outFormat: oracledb.OUT_FORMAT_OBJECT,
fetchTypeHandler: _OracleConnector.fetchTypeHandler
});
return result.rows ?? [];
}
/** Run one catalog query on a short-lived pooled connection. */
query(sql, binds = {}) {
return this.withConnection((connection) => _OracleConnector.fetchRows(connection, sql, binds));
}
/**
* Per-column fetch rules:
* - CLOB/NCLOB come back as strings and BLOB as a Buffer instead of Lob
* streams, so result rows are plain JSON. (LONG columns such as
* ALL_TAB_COLUMNS.DATA_DEFAULT are already fetched as strings by default.)
* - NUMBER is fetched as its exact decimal string and converted here:
* integers within Number's safe range become numbers, larger integers
* become BigInt (the response serializer renders those as strings), and
* anything with a fraction or exponent becomes a number. The driver's
* default would round a NUMBER(20) identifier above 2^53 silently.
*/
static fetchTypeHandler(metaData) {
if (metaData.dbType === oracledb.DB_TYPE_CLOB || metaData.dbType === oracledb.DB_TYPE_NCLOB) {
return { type: oracledb.STRING };
}
if (metaData.dbType === oracledb.DB_TYPE_BLOB) {
return { type: oracledb.BUFFER };
}
if (metaData.dbType === oracledb.DB_TYPE_NUMBER) {
return { type: oracledb.STRING, converter: _OracleConnector.convertNumber };
}
return void 0;
}
/**
* See fetchTypeHandler. Receives the NUMBER's decimal string (the generic
* signature is what oracledb's converter type requires). Public for unit
* testing.
*/
static convertNumber(value) {
if (value === null || value === void 0) {
return null;
}
const text = String(value);
if (/^-?\d+$/.test(text)) {
const asNumber = Number(text);
return Number.isSafeInteger(asNumber) ? asNumber : BigInt(text);
}
return Number(text);
}
/**
* The catalog spelling of an identifier a caller wrote unquoted: Oracle
* folds those to upper case. A name that already contains an upper-case
* letter is taken as spelled, so a case-sensitive quoted identifier like
* "MyTable" is still reachable by its exact name. (An all-lower-case quoted
* identifier is not; that trade keeps every catalog predicate a plain
* equality on an indexed column.)
*/
static foldIdentifier(name) {
return /[A-Z]/.test(name) ? name : name.toUpperCase();
}
schemaOrDefault(schema) {
return schema ? _OracleConnector.foldIdentifier(schema) : this.defaultSchema;
}
async getSchemas() {
try {
const rows = await this.query(
`SELECT username
FROM all_users
WHERE oracle_maintained = 'N' OR username = :current_schema
ORDER BY username`,
{ current_schema: this.schemaOrDefault() }
);
return rows.map((row) => row.USERNAME);
} catch (error) {
throw new Error(`Failed to get schemas: ${error.message}`);
}
}
async getDefaultSchema() {
return this.defaultSchema || null;
}
async getTables(schema) {
try {
const rows = await this.query(
`SELECT table_name
FROM all_tables
WHERE owner = :schema
AND nested = 'NO'
AND secondary = 'N'
AND (iot_type IS NULL OR iot_type = 'IOT')
AND table_name NOT LIKE 'BIN$%'
ORDER BY table_name`,
{ schema: this.schemaOrDefault(schema) }
);
return rows.map((row) => row.TABLE_NAME);
} catch (error) {
throw new Error(`Failed to get tables: ${error.message}`);
}
}
async getViews(schema) {
try {
const rows = await this.query(
`SELECT view_name FROM all_views WHERE owner = :schema ORDER BY view_name`,
{ schema: this.schemaOrDefault(schema) }
);
return rows.map((row) => row.VIEW_NAME);
} catch (error) {
throw new Error(`Failed to get views: ${error.message}`);
}
}
async tableExists(tableName, schema) {
try {
const rows = await this.query(
`SELECT COUNT(*) AS cnt FROM all_tables WHERE owner = :schema AND table_name = :table_name`,
{ schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
);
return Number(rows[0]?.CNT ?? 0) > 0;
} catch (error) {
throw new Error(`Failed to check if table exists: ${error.message}`);
}
}
async getTableSchema(tableName, schema) {
try {
const rows = await this.query(
`SELECT c.column_name,
c.data_type,
c.data_length,
c.char_length,
c.data_precision,
c.data_scale,
c.nullable,
c.data_default,
cc.comments AS description
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner
AND cc.table_name = c.table_name
AND cc.column_name = c.column_name
WHERE c.owner = :schema
AND c.table_name = :table_name
ORDER BY c.column_id`,
{ schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
);
return rows.map((row) => ({
column_name: row.COLUMN_NAME,
data_type: _OracleConnector.formatDataType(row),
is_nullable: row.NULLABLE === "Y" ? "YES" : "NO",
// DATA_DEFAULT is a LONG that keeps the DDL's trailing whitespace.
column_default: row.DATA_DEFAULT?.trim() || null,
description: row.DESCRIPTION || null
}));
} catch (error) {
throw new Error(`Failed to get schema for table ${tableName}: ${error.message}`);
}
}
/**
* Render a column's type the way it appears in DDL: `VARCHAR2(100)`,
* `NUMBER(10,2)`, `NUMBER`, `TIMESTAMP(6)`. Oracle's catalog splits these
* across several columns.
*/
static formatDataType(row) {
const type = row.DATA_TYPE;
if (/^(?:N?VARCHAR2|N?CHAR|RAW)$/.test(type)) {
const length = type === "RAW" ? row.DATA_LENGTH : row.CHAR_LENGTH;
return length ? `${type}(${length})` : type;
}
if (type === "NUMBER") {
if (row.DATA_PRECISION === null) {
return row.DATA_SCALE === null ? type : `NUMBER(*,${row.DATA_SCALE})`;
}
return row.DATA_SCALE ? `NUMBER(${row.DATA_PRECISION},${row.DATA_SCALE})` : `NUMBER(${row.DATA_PRECISION})`;
}
if (type === "FLOAT" && row.DATA_PRECISION !== null) {
return `FLOAT(${row.DATA_PRECISION})`;
}
return type;
}
async getTableIndexes(tableName, schema) {
try {
const rows = await this.query(
`SELECT i.index_name,
i.uniqueness,
CASE WHEN pk.constraint_name IS NOT NULL THEN 1 ELSE 0 END AS is_primary,
ic.column_name
FROM all_indexes i
JOIN all_ind_columns ic
ON ic.index_owner = i.owner
AND ic.index_name = i.index_name
LEFT JOIN all_constraints pk
ON pk.owner = i.table_owner
AND pk.table_name = i.table_name
AND pk.constraint_type = 'P'
AND pk.index_owner = i.owner
AND pk.index_name = i.index_name
WHERE i.table_owner = :schema
AND i.table_name = :table_name
ORDER BY i.index_name, ic.column_position`,
{ schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
);
const indexMap = /* @__PURE__ */ new Map();
for (const row of rows) {
let index = indexMap.get(row.INDEX_NAME);
if (!index) {
index = {
index_name: row.INDEX_NAME,
column_names: [],
is_unique: row.UNIQUENESS === "UNIQUE",
is_primary: Number(row.IS_PRIMARY) === 1
};
indexMap.set(row.INDEX_NAME, index);
}
index.column_names.push(row.COLUMN_NAME);
}
return Array.from(indexMap.values());
} catch (error) {
throw new Error(`Failed to get indexes for table ${tableName}: ${error.message}`);
}
}
async getTableComment(tableName, schema) {
try {
const rows = await this.query(
`SELECT comments FROM all_tab_comments WHERE owner = :schema AND table_name = :table_name`,
{ schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
);
return rows[0]?.COMMENTS || null;
} catch {
return null;
}
}
async getTableRowCount(tableName, schema) {
try {
const rows = await this.query(
`SELECT num_rows FROM all_tables WHERE owner = :schema AND table_name = :table_name`,
{ schema: this.schemaOrDefault(schema), table_name: _OracleConnector.foldIdentifier(tableName) }
);
const numRows = rows[0]?.NUM_ROWS;
return numRows === null || numRows === void 0 ? null : Number(numRows);
} catch {
return null;
}
}
async getHealthCheck() {
if (!this.pool) {
throw new Error("Not connected to Oracle database");
}
const notes = [];
const result = {};
try {
const [sessions, params] = await this.withConnection(
(connection) => Promise.all([
_OracleConnector.fetchRows(
connection,
// STATUS is ACTIVE (running a call), INACTIVE (idle), or one of
// the transitional states KILLED / SNIPED / CACHED, which count
// toward the total but are neither active nor idle. TADDR is
// non-null while the session has an open transaction;
// LAST_CALL_ET is seconds since the current call began (ACTIVE)
// or since the last call ended (otherwise).
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN status = 'INACTIVE' THEN 1 ELSE 0 END) AS idle,
SUM(CASE WHEN status = 'INACTIVE' AND taddr IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction,
MAX(CASE WHEN status = 'INACTIVE' AND taddr IS NOT NULL THEN last_call_et END) AS longest_idle_in_transaction_seconds,
MAX(CASE WHEN status = 'ACTIVE' THEN last_call_et END) AS longest_active_query_seconds
FROM v$session
WHERE type = 'USER'
AND sid <> SYS_CONTEXT('USERENV', 'SID')`
),
_OracleConnector.fetchRows(
connection,
`SELECT value FROM v$parameter WHERE name = 'sessions'`
)
])
);
const conn = sessions[0];
const maxConnections = params.length > 0 ? Number(params[0].VALUE) : null;
result.connections = {
total: Number(conn.TOTAL ?? 0),
active: Number(conn.ACTIVE ?? 0),
idle: Number(conn.IDLE ?? 0),
idleInTransaction: Number(conn.IDLE_IN_TRANSACTION ?? 0),
// Oracle has no equivalent of Postgres's "idle in transaction
// (aborted)" state: a failed statement is rolled back on its own
// and leaves the transaction usable.
maxConnections: maxConnections !== null && maxConnections > 0 ? maxConnections : null,
longestIdleInTransactionSeconds: toNullableNumber(conn.LONGEST_IDLE_IN_TRANSACTION_SECONDS),
longestActiveQuerySeconds: toNullableNumber(conn.LONGEST_ACTIVE_QUERY_SECONDS)
};
} catch {
notes.push(
"Connection pool metrics unavailable: connecting user lacks SELECT on V$SESSION / V$PARAMETER (grant SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY)."
);
}
try {
const stats = await this.query(
`SELECT name, value FROM v$sysstat
WHERE name IN ('db block gets', 'consistent gets', 'physical reads')`
);
const byName = Object.fromEntries(stats.map((row) => [row.NAME, Number(row.VALUE)]));
const logicalReads = (byName["db block gets"] ?? 0) + (byName["consistent gets"] ?? 0);
const physicalReads = byName["physical reads"] ?? 0;
result.bufferCache = {
hitRatioPct: computeHitRatioPct(logicalReads, physicalReads),
blocksHit: logicalReads - physicalReads,
blocksRead: physicalReads
};
} catch {
notes.push(
"Buffer cache metrics unavailable: connecting user lacks SELECT on V$SYSSTAT (grant SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY)."
);
}
if (notes.length > 0) {
result.notes = notes;
}
return result;
}
async getStoredProcedures(schema, routineType) {
try {
const typeFilter = routineType === "function" ? "object_type = 'FUNCTION'" : routineType === "procedure" ? "object_type = 'PROCEDURE'" : "object_type IN ('PROCEDURE', 'FUNCTION')";
const rows = await this.query(
`SELECT object_name FROM all_objects WHERE owner = :schema AND ${typeFilter} ORDER BY object_name`,
{ schema: this.schemaOrDefault(schema) }
);
return rows.map((row) => row.OBJECT_NAME);
} catch (error) {
throw new Error(`Failed to get stored procedures: ${error.message}`);
}
}
async getStoredProcedureDetail(procedureName, schema) {
try {
const schemaToUse = this.schemaOrDefault(schema);
const name = _OracleConnector.foldIdentifier(procedureName);
return await this.withConnection(async (connection) => {
const objects = await _OracleConnector.fetchRows(
connection,
`SELECT object_type
FROM all_objects
WHERE owner = :schema AND object_name = :name
AND object_type IN ('PROCEDURE', 'FUNCTION')`,
{ schema: schemaToUse, name }
);
if (objects.length === 0) {
throw new Error(`Stored procedure '${procedureName}' not found in schema '${schemaToUse}'`);
}
const objectType = objects[0].OBJECT_TYPE;
const isFunction = objectType === "FUNCTION";
const [args, source] = await Promise.all([
// Standalone routines only (package_name IS NULL). Position 0 with
// no argument name is a function's return value.
_OracleConnector.fetchRows(
connection,
`SELECT argument_name, position, in_out, data_type
FROM all_arguments
WHERE owner = :schema AND object_name = :name
AND package_name IS NULL AND data_level = 0
ORDER BY position`,
{ schema: schemaToUse, name }
),
_OracleConnector.fetchRows(
connection,
`SELECT text FROM all_source
WHERE owner = :schema AND name = :name AND type = :object_type
ORDER BY line`,
{ schema: schemaToUse, name, object_type: objectType }
)
]);
const returnType = args.find((arg) => arg.POSITION === 0 && arg.ARGUMENT_NAME === null)?.DATA_TYPE;
const parameterList = args.filter((arg) => arg.ARGUMENT_NAME !== null).map((arg) => `${arg.ARGUMENT_NAME} ${arg.IN_OUT} ${arg.DATA_TYPE ?? ""}`.trim()).join(", ");
return {
procedure_name: name,
procedure_type: isFunction ? "function" : "procedure",
language: "plsql",
parameter_list: parameterList,
return_type: isFunction ? returnType ?? void 0 : void 0,
definition: source.length > 0 ? source.map((row) => row.TEXT).join("") : void 0
};
});
} catch (error) {
throw new Error(`Failed to get stored procedure details: ${error.message}`);
}
}
/**
* Bind values for one statement, keyed by placeholder name. DBHub's
* placeholders are `:1`, `:2`, ... and each names parameters[N-1]; binding
* by name (rather than handing the driver a positional array) lets a
* placeholder repeat (`:1 ... :1`) or appear out of order, and lets a batch
* hand each statement only the binds it uses, since the driver rejects a
* bind object naming a placeholder the statement lacks (NJS-097).
*/
static bindsFor(statement, parameters) {
const binds = {};
const blanked = blankCommentsAndStrings(statement, "oracle");
for (const match of blanked.matchAll(/(?<!:):(\d+)\b/g)) {
const index = parseInt(match[1], 10);
if (index >= 1 && index <= parameters.length) {
binds[match[1]] = parameters[index - 1];
}
}
return binds;
}
/**
* Wrap a driver error with context while keeping the properties the
* connection-error classifier reads (`code`, `errorNum`), which a plain
* `new Error(message)` would drop.
*/
static wrapError(prefix, error) {
const wrapped = new Error(`${prefix}: ${error.message}`, { cause: error });
for (const key of ["code", "errorNum", "offset"]) {
const value = error?.[key];
if (value !== void 0) {
wrapped[key] = value;
}
}
return wrapped;
}
async executeSQL(sqlQuery, options, parameters) {
const afterNoise = sqlQuery.replace(LEADING_SQL_NOISE, "");
if (/^explain\b/i.test(afterNoise)) {
return this.explainQuery(afterNoise.slice("explain".length), options.readonly, parameters);
}
const statements = _OracleConnector.splitStatements(afterNoise);
const connection = await this.acquire();
try {
if (options.readonly) {
await connection.execute("SET TRANSACTION READ ONLY");
}
const resultSets = [];
for (const statement of statements) {
const { sql: processedSQL, probeApplied } = SQLRowLimiter.applyMaxRowsForOracleWithTruncationProbe(statement, options.maxRows);
const result = await connection.execute(
processedSQL,
_OracleConnector.bindsFor(statement, parameters ?? []),
{
outFormat: oracledb.OUT_FORMAT_OBJECT,
fetchTypeHandler: _OracleConnector.fetchTypeHandler
}
);
const rows = result.rows ?? [];
const resultSet = {
sql: statement,
rows,
rowCount: result.rows ? rows.length : result.rowsAffected ?? 0
};
SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied);
resultSets.push(resultSet);
}
if (options.readonly) {
await connection.rollback();
} else {
await connection.commit();
}
return { resultSets };
} catch (error) {
await closeQuietly(() => connection.rollback());
throw _OracleConnector.wrapError("Failed to execute query", error);
} finally {
await connection.close();
}
}
/**
* Split a batch into the statements Oracle executes one per round trip.
*
* Plain SQL ends at a top-level semicolon, which is stripped: Oracle
* rejects a trailing `;` on a SQL statement (ORA-00933). A PL/SQL block,
* or the DDL that creates one, *requires* its semicolons and is sent
* whole: it ends at the `;` that closes its outermost BEGIN ... END
* (depth-tracked over `begin`/`case` ... `end`, with `end if` / `end loop`
* neutral), or at a SQL*Plus `/` line, which is dropped either way. The
* scan runs on the comment/string-blanked text so nothing inside a
* literal or comment counts.
*/
static splitStatements(sql) {
const blanked = blankCommentsAndStrings(sql, "oracle");
const statements = [];
const boundary = /(?:\s|\/(?=[ \t]*(?:\r?\n|$)))*/y;
const plainEnd = /;|^[ \t]*\/[ \t]*$/gm;
const token = /\b(begin|case|end|compound\s+trigger)\b(?:\s+(if|loop|case))?|;|^[ \t]*\/[ \t]*$/gim;
const push = (start, end) => {
const text = sql.slice(start, end).trim();
if (text) statements.push(text);
};
let i = 0;
while (i < blanked.length) {
boundary.lastIndex = i;
i += boundary.exec(blanked)[0].length;
if (i >= blanked.length) break;
const start = i;
const rest = blanked.slice(i);
const isUnit = _OracleConnector.PLSQL_UNIT.test(rest);
if (!isUnit && !_OracleConnector.PLSQL_BODY.test(rest)) {
plainEnd.lastIndex = i;
const m2 = plainEnd.exec(blanked);
const end2 = m2?.index ?? blanked.length;
push(start, end2);
i = end2 + (m2?.[0] === ";" ? 1 : 0);
continue;
}
let depth = isUnit ? 1 : 0;
let opened = isUnit;
let end = blanked.length;
token.lastIndex = i;
let m;
while ((m = token.exec(blanked)) !== null) {
if (m[0] === ";") {
if (opened && depth === 0) {
end = m.index + 1;
break;
}
} else if (m[1] === void 0) {
end = m.index;
break;
} else {
const keyword = m[1].toLowerCase();
const closes = m[2]?.toLowerCase();
if (keyword !== "end") {
depth++;
opened = true;
} else if (closes === void 0 || closes === "case") {
depth--;
}
}
}
push(start, end);
i = end;
}
return statements;
}
/**
* Run `EXPLAIN PLAN FOR <statement>` and return the formatted plan.
*
* EXPLAIN PLAN parses and optimizes the statement without executing it. It
* stores the plan in PLAN_TABLE (a session-private global temporary table),
* which is why this path runs outside the READ ONLY transaction used by
* executeSQL and cleans up its rows afterwards.
*
* Accepts the Postgres-style `EXPLAIN <stmt>` the explain_sql tool emits as
* well as Oracle's own `EXPLAIN PLAN [SET STATEMENT_ID = '...'] FOR <stmt>`.
*/
async explainQuery(afterExplain, readonly, parameters) {
const innerQuery = afterExplain.replace(/^\s*plan\b(?:\s+set\s+statement_id\s*=\s*'[^']*')?\s+for\b/i, "").replace(/;\s*$/, "").trim();
if (!stripCommentsAndStrings(innerQuery, "oracle").trim()) {
throw new Error("EXPLAIN requires a statement to analyze");
}
if (readonly && !isReadOnlySQL(innerQuery, "oracle")) {
throw new Error("Read-only mode: EXPLAIN is only allowed for read statements");
}
const statementId = `dbhub_${Math.random().toString(36).slice(2, 14)}`;
return this.withConnection(async (connection) => {
try {
await connection.execute(
`EXPLAIN PLAN SET STATEMENT_ID = '${statementId}' FOR ${innerQuery}`,
_OracleConnector.bindsFor(innerQuery, parameters ?? [])
);
const lines = await _OracleConnector.fetchRows(
connection,
"SELECT plan_table_output FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', :id, 'TYPICAL'))",
{ id: statementId }
);
const plan = lines.map((row) => row.PLAN_TABLE_OUTPUT).join("\n");
return {
resultSets: [
{
rows: lines.length > 0 ? [{ plan }] : [],
rowCount: lines.length > 0 ? 1 : 0
}
]
};
} catch (error) {
throw _OracleConnector.wrapError("Failed to explain query", error);
} finally {
await closeQuietly(async () => {
await connection.execute("DELETE FROM plan_table WHERE statement_id = :id", { id: statementId });
await connection.commit();
});
}
});
}
};
/**
* Leading keywords of a statement whose body is PL/SQL. Such a statement
* ends at the semicolon that closes its outermost BEGIN ... END, not at
* the first semicolon (see splitStatements).
*/
_OracleConnector.PLSQL_BODY = /^(?:begin|declare|create\s+(?:or\s+replace\s+)?(?:(?:editionable|noneditionable)\s+)?(?:procedure|function|trigger))\b/i;
/**
* Package specs/bodies and type bodies: `IS ... END name;` with no BEGIN
* of their own at the top level, so the IS/AS opens the block.
*/
_OracleConnector.PLSQL_UNIT = /^create\s+(?:or\s+replace\s+)?(?:(?:editionable|noneditionable)\s+)?(?:package(?:\s+body)?|type\s+body)\b/i;
var OracleConnector = _OracleConnector;
var oracleConnector = new OracleConnector();
ConnectorRegistry.register(oracleConnector);
export {
OracleConnector,
OracleDSNParser
};