chore: establish project baseline
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user