Files
GTI_GERENCIADOR_FISCAL/src/gti_discovery/catalog.py
T

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