"""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()