"""The closed, read-only Firebird catalog API (Mission 00D redesign). This module is the ONLY place that ever builds SQL text and sends it to the Firebird driver for real. It exposes no ``execute(sql)``, ``query(sql)`` or equivalent to callers -- ``FirebirdCatalog`` only offers narrow, pre-defined operations (``get_database_info``, ``list_relations``, ``get_relation_columns``, ...), so a caller can never supply SQL, a FROM clause, a WHERE clause, a procedure name to execute, or any other arbitrary expression. Two kinds of SQL exist in this module: 1. Fixed, module-owned catalog queries (``_QUERIES`` below) against ``RDB$...``/``MON$...`` system tables. These never contain caller input -- they are plain string literals written by this project -- so a lexical "is this SQL safe" check is unnecessary and is deliberately NOT applied to them (see ``gti_discovery.read_only``'s module docstring for why that check is not trustworthy as a boundary anyway). They may freely use CAST, COUNT, SUM, JOIN, or any other legitimate Firebird SQL. 2. Exactly one dynamic query, built by ``sample_relation_rows``. Its only variable parts are a relation name and column names, and both are required to have been produced by ``list_relations()`` / ``get_relation_columns()`` of THIS SAME catalog instance in THIS SAME session first (see ``_KnownCatalog`` below) -- never taken from raw caller text. Firebird identifier quoting is applied, and the row limit is a validated, hard-capped integer embedded as a literal (ints cannot carry SQL syntax). Nothing about this query depends on ``gti_discovery.read_only``. The exact adapter type must be present in the private approved-adapter registry before construction and must declare database-level read-only enforcement. The handle is then held privately; ``__getattribute__`` refuses to hand it back through normal attribute access, so a caller holding a ``FirebirdCatalog`` has no path to raw ``execute``. """ from __future__ import annotations from collections.abc import Mapping from copy import deepcopy from types import MappingProxyType from typing import Any from .collector import ReadOnlyCatalogReader, _fetch_rows, _safe_sample_rows from . import firebird_queries as _firebird_queries from .firebird_queries import _SamplingCapability, _execute_catalog from .identifiers import quote_firebird_identifier MAX_SAMPLE_ROWS = 50 _quote_identifier = quote_firebird_identifier def _freeze_snapshot(value: Any) -> Any: if isinstance(value, Mapping): return MappingProxyType({deepcopy(key): _freeze_snapshot(item) for key, item in value.items()}) if isinstance(value, (list, tuple)): return tuple(_freeze_snapshot(item) for item in value) return deepcopy(value) def _freeze_catalog_rows(rows: list[Any]) -> tuple[Any, ...]: return tuple(_freeze_snapshot(row) for row in rows) class StructuredFirebirdReader(ReadOnlyCatalogReader): """The only read-only surface handed to collectors/consumers. Constructed only by ``gti_discovery.firebird_connection`` from a driver adapter that has already *proven* database-level read-only enforcement (``adapter.read_only is True``) and is present in the private exact-type approval registry. No public constructor path accepts an unapproved or unproven adapter. """ __slots__ = ("_handle", "_relations_cache", "_fields_cache") def __init__(self, handle: Any): if type(handle) not in _firebird_queries._APPROVED_ADAPTER_TYPES: raise TypeError("adapter is not an approved implementation") if callable(handle) or not callable(getattr(handle, "execute", None)): raise TypeError("adapter must expose an execute method") if getattr(handle, "read_only", None) is not True: raise ValueError("handle must declare database-level read-only enforcement") object.__setattr__(self, "_handle", handle) object.__setattr__(self, "_relations_cache", None) object.__setattr__(self, "_fields_cache", None) def __getattribute__(self, name: str) -> Any: if name in ("_handle",): raise AttributeError(name) return object.__getattribute__(self, name) def __setattr__(self, name: str, value: Any) -> None: raise AttributeError("FirebirdCatalog is immutable outside __init__") @property def read_only(self) -> bool: return True def close(self) -> None: handle = object.__getattribute__(self, "_handle") close = getattr(handle, "close", None) if callable(close): close() def _run_fixed_catalog(self, operation_key: str) -> list[Any]: if operation_key not in ("database", "relations", "fields", "keys", "indexes", "foreign_keys", "triggers", "procedures", "procedure_parameters", "packages", "generators", "dependencies", "monitoring"): raise ValueError("operation is not a fixed catalog operation") handle = object.__getattribute__(self, "_handle") return _fetch_rows(_execute_catalog(handle, operation_key)) # -- bulk catalog reads (fixed, project-owned queries) -------------- def get_database_info(self): return self._run_fixed_catalog("database") def list_relations(self): rows = _freeze_catalog_rows(self._run_fixed_catalog("relations")) object.__setattr__(self, "_relations_cache", rows) return rows def list_relation_fields(self): rows = _freeze_catalog_rows(self._run_fixed_catalog("fields")) object.__setattr__(self, "_fields_cache", rows) return rows def list_keys(self): return self._run_fixed_catalog("keys") def list_indexes(self): return self._run_fixed_catalog("indexes") def list_foreign_keys(self): return self._run_fixed_catalog("foreign_keys") def list_triggers(self): return self._run_fixed_catalog("triggers") def list_procedures(self): return self._run_fixed_catalog("procedures") def list_procedure_parameters(self): return self._run_fixed_catalog("procedure_parameters") def list_packages(self): return self._run_fixed_catalog("packages") def list_generators(self): return self._run_fixed_catalog("generators") def list_dependencies(self): return self._run_fixed_catalog("dependencies") def list_monitoring(self): return self._run_fixed_catalog("monitoring") # -- relation-scoped convenience reads (pure Python filters over ---- # -- the same bulk, project-owned queries above -- no new SQL) ------ def _known_relation_names(self) -> set[str]: cached = object.__getattribute__(self, "_relations_cache") rows = cached if cached is not None else self.list_relations() return {str(row.get("RELATION_NAME", "")).strip().upper() for row in rows} def _require_known_relation(self, relation: str) -> str: """Phase 4: a relation name is only usable if it was itself returned by RDB$RELATIONS first -- never trusted as free text. This structurally excludes procedures/UDFs/expressions, since RDB$RELATIONS only lists real tables and views.""" if not isinstance(relation, str) or not relation.strip(): raise ValueError("relation must be a non-empty string") candidate = relation.strip().upper() if candidate not in self._known_relation_names(): raise ValueError(f"unknown relation: {relation!r} was not returned by list_relations()") rows = object.__getattribute__(self, "_relations_cache") for row in rows or (): issued = str(row.get("RELATION_NAME", "")).strip() if issued.upper() == candidate: return issued raise ValueError(f"unknown relation: {relation!r} was not returned by list_relations()") def get_relation_columns(self, relation: str) -> tuple[dict, ...]: known = self._require_known_relation(relation) cached = object.__getattribute__(self, "_fields_cache") rows = cached if cached is not None else self.list_relation_fields() return tuple(row for row in rows if str(row.get("RELATION_NAME", "")).strip().upper() == known.upper()) def get_primary_keys(self, relation: str) -> list[dict]: known = self._require_known_relation(relation) return [ row for row in self.list_keys() if str(row.get("RELATION_NAME", "")).strip().upper() == known and "PRIMARY" in str(row.get("CONSTRAINT_TYPE", "")).upper() ] def get_foreign_keys(self, relation: str) -> list[dict]: known = self._require_known_relation(relation) return [ row for row in self.list_foreign_keys() if str(row.get("CHILD_RELATION_NAME", "")).strip().upper() == known or str(row.get("REFERENCED_RELATION_NAME", "")).strip().upper() == known ] def get_indices(self, relation: str) -> list[dict]: known = self._require_known_relation(relation) return [row for row in self.list_indexes() if str(row.get("RELATION_NAME", "")).strip().upper() == known] def get_triggers(self, relation: str | None = None) -> list[dict]: rows = self.list_triggers() if relation is None: return rows known = self._require_known_relation(relation) return [row for row in rows if str(row.get("RELATION_NAME", "")).strip().upper() == known] def get_procedures(self) -> list[dict]: """Metadata only -- procedures are inventoried, never executed (Mission 00D Phase 5). There is no operation anywhere in this class that runs a procedure or a selectable stored procedure.""" return self.list_procedures() def get_procedure_parameters(self) -> list[dict]: return self.list_procedure_parameters() def get_packages(self) -> list[dict]: return self.list_packages() def get_generators(self) -> list[dict]: return self.list_generators() def get_dependencies(self) -> list[dict]: return self.list_dependencies() def get_monitoring(self) -> list[dict]: return self.list_monitoring() # -- the one dynamic operation: bounded, catalog-validated sampling - def sample_relation_rows(self, relation: str, columns: list[str], limit: int) -> list[dict]: """Sample rows from a real table/view, never a procedure/UDF/expression. Every safety property below is structural, not textual: - ``relation`` must already appear in ``list_relations()`` (so it is a real table/view -- RDB$RELATIONS never lists procedures/UDFs). - each of ``columns`` must already appear in ``get_relation_columns(relation)`` for that exact relation. - ``limit`` must be a plain, non-bool ``int`` within ``1..MAX_SAMPLE_ROWS`` and is embedded only as a literal integer (an int value cannot carry SQL syntax). - identifiers are Firebird double-quoted with embedded quotes doubled before being placed in the SQL text. """ known_relation = self._require_known_relation(relation) if not isinstance(columns, (list, tuple)) or not columns: raise ValueError("columns must be a non-empty list of column names") available = { str(row.get("FIELD_NAME", "")).strip().upper(): str(row.get("FIELD_NAME", "")).strip() for row in self.get_relation_columns(known_relation) } validated_columns: list[str] = [] for column in columns: if not isinstance(column, str) or not column.strip(): raise ValueError("column names must be non-empty strings") candidate = column.strip().upper() if candidate not in available: raise ValueError(f"unknown column {column!r} for relation {relation!r}") validated_columns.append(available[candidate]) if type(limit) is not int: raise ValueError("limit must be an int") if not (1 <= limit <= MAX_SAMPLE_ROWS): raise ValueError(f"limit must be between 1 and {MAX_SAMPLE_ROWS}") return _safe_sample_rows( _fetch_rows(_execute_catalog( object.__getattribute__(self, "_handle"), "sample_relation", (known_relation, tuple(validated_columns), limit), capability=_SamplingCapability( object.__getattribute__(self, "_relations_cache"), object.__getattribute__(self, "_fields_cache"), ), )) ) FirebirdCatalog = StructuredFirebirdReader