220 lines
11 KiB
Python
220 lines
11 KiB
Python
"""Oracle, nur lesend (python-oracledb im Thin-Modus: ohne Oracle-Client, Oracle ab 12.1, also auch 19c).
|
|
|
|
Sicherungen:
|
|
- jede Abfrage in einer schreibgeschützten Transaktion (SET TRANSACTION READ ONLY), die immer
|
|
zurückgerollt wird
|
|
- genau eine Anweisung: oracledb führt je Aufruf nur eine aus; PL/SQL-Blöcke (BEGIN, DECLARE, CALL)
|
|
schließt schon das erste Schlüsselwort aus, PL/SQL im WITH (WITH FUNCTION/PROCEDURE) wird
|
|
abgewiesen, weil eine Funktion mit autonomer Transaktion die schreibgeschützte Transaktion umginge
|
|
- Zeitlimit je Aufruf an die Datenbank (call_timeout) und zusätzlich Abbruch vom Client aus
|
|
- höchstens max_rows Zeilen werden geholt
|
|
|
|
Grenze: Eine Abfrage kann vorhandene Funktionen aufrufen, für die der Nutzer EXECUTE hat. Darum
|
|
ist bei Oracle ein Nutzer nur mit CREATE SESSION und SELECT-Rechten besonders wichtig;
|
|
check_connection meldet Schreibrechte und systemweite Rechte.
|
|
"""
|
|
|
|
import re
|
|
import threading
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from typing import Any
|
|
|
|
import oracledb
|
|
|
|
from dbmcp.core import Invalid
|
|
from dbmcp.core.config import Settings
|
|
from dbmcp.core.connection import Connection
|
|
from dbmcp.core.driver import Check
|
|
from dbmcp.core.result import Table
|
|
|
|
# CLOB/BLOB direkt als Text bzw. Bytes statt als LOB-Objekt (gilt für den ganzen Prozess)
|
|
oracledb.defaults.fetch_lobs = False
|
|
|
|
# Schemas der Anwendungen; von Oracle selbst gepflegte (SYS, SYSTEM, APEX …) bleiben außen vor
|
|
OWNERS = "SELECT username FROM all_users WHERE oracle_maintained = 'N'"
|
|
PLSQL_IN_WITH = re.compile(r"\bWITH\s*(?:(?:/\*.*?\*/|--[^\n]*\n)\s*)*(?:FUNCTION|PROCEDURE)\b", re.I | re.S)
|
|
# Systemweite Rechte, mit denen der Nutzer in fremde Tabellen schreiben könnte
|
|
ANY_PRIVILEGES = ("INSERT ANY TABLE", "UPDATE ANY TABLE", "DELETE ANY TABLE", "DROP ANY TABLE",
|
|
"ALTER ANY TABLE", "CREATE ANY TABLE", "EXECUTE ANY PROCEDURE")
|
|
|
|
# Bekannte Ursachen beim Verbindungsaufbau, in dieser Reihenfolge geprüft
|
|
CAUSES = (
|
|
("ORA-01017", "Benutzer oder Passwort falsch."),
|
|
("ORA-12514", "Service-Name unbekannt; bei Oracle gehört unter „Datenbank“ der Service-Name (z. B. ORCLPDB1)."),
|
|
("DPY-6001", "Service-Name unbekannt; bei Oracle gehört unter „Datenbank“ der Service-Name (z. B. ORCLPDB1)."),
|
|
("ORA-28000", "Der Nutzer ist gesperrt."),
|
|
("ORA-28001", "Das Passwort ist abgelaufen."),
|
|
("DPY-3001", "Der Server verlangt Native Network Encryption; das unterstützt der Treiber im Thin-Modus nicht. "
|
|
"Abhilfe: TLS (TCPS) am Server oder ein Image mit Oracle Instant Client (Thick-Modus)."),
|
|
("Connection refused", "Verbindung abgelehnt; läuft der Server und stimmt der Port?"),
|
|
("timed out", "Zeitüberschreitung beim Verbindungsaufbau; Host oder Port prüfen."),
|
|
("SSL", "TLS-Verbindung fehlgeschlagen; spricht der Server TCPS auf diesem Port, und ist das Zertifikat gültig?"),
|
|
)
|
|
TIMEOUTS = ("DPY-4024", "ORA-01013", "DPY-4011")
|
|
# Schreibgeschützte Transaktionen lesen keine Tabelle, deren Definition sich gerade geändert hat
|
|
# (z. B. TRUNCATE in einem Ladeprozess, auch wenige Sekunden vor Beginn der Transaktion)
|
|
CHANGED = "ORA-01466"
|
|
|
|
|
|
def first_line(error: Exception) -> str:
|
|
text = str(error).strip()
|
|
return text.splitlines()[0][:300] if text else type(error).__name__
|
|
|
|
|
|
def connect_error(error: Exception) -> str:
|
|
text = str(error)
|
|
for needle, message in CAUSES:
|
|
if needle in text:
|
|
return message
|
|
return first_line(error)
|
|
|
|
|
|
def identifier(part: str) -> str:
|
|
"""Wie Oracle Namen auflöst: ohne Anführungszeichen in Großbuchstaben, mit genau so."""
|
|
part = part.strip()
|
|
if len(part) >= 2 and part.startswith('"') and part.endswith('"'):
|
|
part = part[1:-1]
|
|
else:
|
|
part = part.upper()
|
|
if not part or '"' in part or "\x00" in part:
|
|
raise Invalid("Ungültiger Tabellenname.")
|
|
return part
|
|
|
|
|
|
class OracleSession:
|
|
def __init__(self, con: oracledb.Connection, settings: Settings):
|
|
self.con, self.settings = con, settings
|
|
|
|
def fetch(self, sql: str, params: dict[str, Any] | None, max_rows: int) -> Table:
|
|
# Zweite Sicherung neben call_timeout
|
|
timer = threading.Timer(self.settings.query_timeout + 2, self.con.cancel)
|
|
timer.start()
|
|
try:
|
|
with self.con.cursor() as cursor:
|
|
cursor.arraysize = min(max_rows + 1, 1000)
|
|
cursor.prefetchrows = cursor.arraysize + 1
|
|
cursor.execute(sql, params or {})
|
|
if cursor.description is None:
|
|
raise Invalid("Die Anweisung liefert keine Zeilen; erlaubt sind nur Abfragen.")
|
|
columns = [d.name for d in cursor.description]
|
|
rows = cursor.fetchmany(max_rows + 1)
|
|
except oracledb.Error as e:
|
|
if any(code in str(e) for code in TIMEOUTS):
|
|
raise Invalid(f"Abfrage nach {self.settings.query_timeout:g} Sekunden abgebrochen; bitte eingrenzen "
|
|
"oder aggregieren.") from e
|
|
if CHANGED in str(e):
|
|
raise Invalid("Die Tabelle wurde eben geändert (z. B. neu geladen); bitte gleich noch einmal "
|
|
"versuchen.") from e
|
|
raise Invalid(f"SQL-Fehler: {first_line(e)}") from e
|
|
finally:
|
|
timer.cancel()
|
|
return Table(columns, [list(r) for r in rows[:max_rows]], len(rows) > max_rows)
|
|
|
|
def scalar(self, sql: str, params: dict[str, Any] | None = None) -> Any:
|
|
return self.fetch(sql, params, 1).rows[0][0]
|
|
|
|
def check(self) -> Check:
|
|
try:
|
|
tables = self.scalar(f"SELECT count(*) FROM all_objects WHERE object_type IN ('TABLE', 'VIEW') "
|
|
f"AND object_name NOT LIKE 'BIN$%' AND owner IN ({OWNERS})")
|
|
# Eigene Tabellen (volle Rechte) und Schreibrechte, direkt, über Rollen oder PUBLIC
|
|
writable = self.scalar(
|
|
"SELECT count(*) FROM ("
|
|
" SELECT owner, object_name FROM all_objects WHERE owner = USER AND object_type IN ('TABLE', 'VIEW')"
|
|
f" AND object_name NOT LIKE 'BIN$%' AND owner IN ({OWNERS})"
|
|
" UNION"
|
|
" SELECT table_schema, table_name FROM all_tab_privs"
|
|
" WHERE privilege IN ('INSERT', 'UPDATE', 'DELETE')"
|
|
" AND grantee IN (SELECT USER FROM dual UNION SELECT role FROM session_roles UNION SELECT 'PUBLIC' "
|
|
"FROM dual)"
|
|
f" AND table_schema IN ({OWNERS}))")
|
|
names = ", ".join(f"'{p}'" for p in ANY_PRIVILEGES)
|
|
privileges = self.fetch(f"SELECT privilege FROM session_privs WHERE privilege IN ({names}) "
|
|
"ORDER BY privilege", None, len(ANY_PRIVILEGES)).rows
|
|
except Invalid as e:
|
|
raise Invalid(str(e).replace("SQL-Fehler", "Fehler", 1)) from e
|
|
note = ""
|
|
if privileges:
|
|
note = ("Der Nutzer hat systemweite Rechte (" + ", ".join(p for (p,) in privileges) + "); empfohlen ist "
|
|
"ein Nutzer nur mit CREATE SESSION und SELECT-Rechten.")
|
|
return Check(self.con.version, int(tables), int(writable), note)
|
|
|
|
def tables(self, limit: int) -> Table:
|
|
table = self.fetch(
|
|
"SELECT o.owner, o.object_name, CASE WHEN o.object_type = 'VIEW' THEN 'view' ELSE 'table' END, "
|
|
"t.num_rows, c.comments "
|
|
"FROM all_objects o "
|
|
"LEFT JOIN all_tables t ON t.owner = o.owner AND t.table_name = o.object_name "
|
|
"LEFT JOIN all_tab_comments c ON c.owner = o.owner AND c.table_name = o.object_name "
|
|
"WHERE o.object_type IN ('TABLE', 'VIEW') AND o.object_name NOT LIKE 'BIN$%' "
|
|
f"AND o.owner IN ({OWNERS}) ORDER BY o.owner, o.object_name", None, limit)
|
|
table.columns = ["schema", "name", "type", "rows_estimate", "comment"]
|
|
return table
|
|
|
|
def describe(self, table: str, sample_rows: int) -> tuple[Table, Table]:
|
|
schema, _, name = table.rpartition(".")
|
|
owner = identifier(schema) if schema else self.scalar("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM dual")
|
|
name = identifier(name)
|
|
columns = self.fetch(
|
|
"SELECT c.column_name, "
|
|
"CASE WHEN c.data_type IN ('VARCHAR2', 'NVARCHAR2', 'CHAR', 'NCHAR') "
|
|
" THEN c.data_type || '(' || c.char_length || ')' "
|
|
"WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL "
|
|
" THEN 'NUMBER(' || c.data_precision || CASE WHEN c.data_scale > 0 THEN ',' || c.data_scale END || ')' "
|
|
"ELSE c.data_type END, c.nullable, m.comments "
|
|
"FROM all_tab_columns c LEFT JOIN all_col_comments m "
|
|
"ON m.owner = c.owner AND m.table_name = c.table_name AND m.column_name = c.column_name "
|
|
"WHERE c.owner = :owner AND c.table_name = :name ORDER BY c.column_id",
|
|
{"owner": owner, "name": name}, 1000)
|
|
if not columns.rows:
|
|
raise Invalid(f"Tabelle {owner}.{name} gibt es nicht oder sie ist nicht sichtbar; siehe list_tables.")
|
|
columns.columns = ["name", "type", "nullable", "comment"]
|
|
columns.rows = [[col, kind, nullable == "Y", comment] for col, kind, nullable, comment in columns.rows]
|
|
sample = self.fetch(f'SELECT * FROM "{owner}"."{name}" FETCH FIRST {int(sample_rows)} ROWS ONLY', None,
|
|
sample_rows)
|
|
return columns, sample
|
|
|
|
def query(self, sql: str, max_rows: int) -> Table:
|
|
if PLSQL_IN_WITH.search(sql):
|
|
raise Invalid("PL/SQL in der Abfrage (WITH FUNCTION/PROCEDURE) ist nicht erlaubt.")
|
|
return self.fetch(sql, None, max_rows)
|
|
|
|
|
|
class OracleDriver:
|
|
name = "Oracle"
|
|
key = "oracle"
|
|
default_port = 1521
|
|
dialect = ("Oracle (ab 19c); Namen ohne Anführungszeichen in Großbuchstaben, Tabellen mit Schema "
|
|
"(SCHEMA.TABELLE), Begrenzen mit FETCH FIRST n ROWS ONLY statt LIMIT, Konstanten aus dual.")
|
|
starts = ("select", "with")
|
|
|
|
def __init__(self, settings: Settings):
|
|
self.settings = settings
|
|
|
|
def params(self, connection: Connection) -> dict[str, Any]:
|
|
"""Unter Datenbank steht bei Oracle der Service-Name. TLS heißt hier TCPS; verify-full prüft
|
|
zusätzlich, ob das Zertifikat zum Host passt."""
|
|
tls = connection.sslmode in ("require", "verify-full")
|
|
return {"user": connection.user, "password": connection.password or None, "host": connection.host,
|
|
"port": connection.port, "service_name": connection.database,
|
|
"protocol": "tcps" if tls else "tcp", "ssl_server_dn_match": connection.sslmode == "verify-full",
|
|
"tcp_connect_timeout": max(self.settings.connect_timeout, 1), "retry_count": 0,
|
|
"program": "db-mcp-oracle"}
|
|
|
|
@contextmanager
|
|
def session(self, connection: Connection) -> Iterator[OracleSession]:
|
|
try:
|
|
con = oracledb.connect(**self.params(connection))
|
|
except oracledb.Error as e:
|
|
raise Invalid(f"Datenbank nicht erreichbar: {connect_error(e)}") from e
|
|
try:
|
|
con.call_timeout = int(self.settings.query_timeout * 1000)
|
|
with con.cursor() as cursor:
|
|
cursor.execute("SET TRANSACTION READ ONLY")
|
|
yield OracleSession(con, self.settings)
|
|
finally:
|
|
try:
|
|
con.rollback()
|
|
finally:
|
|
con.close()
|