288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""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
|