Source code for pandera_catalog.backend.sql_backend

"""SQLAlchemy Core backend for persisting catalog state and generated views."""

from __future__ import annotations

import json
from collections import defaultdict

import pandera.pandas as pa
from sqlalchemy import Boolean, Column, Integer, MetaData, String, Table, Text, create_engine, delete, insert, select, text
from sqlalchemy.engine import Connection, Engine
import yaml

from pandera_catalog.types import SchemaEntry, SchemaProjectionEntry, SchemaProjectionStep

from .views import get_view_ddl

_COLUMN_BASE_KEYS = {
    "title",
    "description",
    "dtype",
    "nullable",
    "checks",
    "unique",
    "coerce",
    "required",
    "regex",
}

_SCHEMA_BASE_KEYS = {
    "schema_type",
    "version",
    "columns",
    "checks",
    "index",
    "dtype",
    "coerce",
    "strict",
    "name",
    "ordered",
    "unique",
    "report_duplicates",
    "unique_column_names",
    "add_missing_columns",
    "title",
    "description",
    "metadata",
}


[docs] class SqlCatalogBackend: """Persist catalog schemas/projections and maintain tabular views.""" def __init__( self, engine: Engine | str | None = None, url: str | None = None, ) -> None: if isinstance(engine, str): if url is not None: raise ValueError("Pass either a positional URL or url=, not both.") url = engine engine = None if engine is not None and url is not None: raise ValueError("Pass either engine or url, not both.") if url is None: url = "sqlite:///:memory:" self.engine: Engine = ( engine if engine is not None else create_engine(url, future=True) ) self.metadata = MetaData() self.schemas = Table( "catalog_schemas", self.metadata, Column("schema_name", String(255), primary_key=True), Column("description", Text, nullable=True), Column("tags_json", Text, nullable=False), Column("schema_yaml", Text, nullable=False), ) self.schema_columns = Table( "catalog_schema_columns", self.metadata, Column("schema_name", String(255), nullable=False), Column("column_name", String(255), nullable=False), Column("ordinal", Integer, nullable=False), Column("dtype", String(255), nullable=True), Column("nullable", Boolean, nullable=True), Column("required", Boolean, nullable=True), Column("unique_value", Boolean, nullable=True), Column("coerce", Boolean, nullable=True), Column("regex", Boolean, nullable=True), Column("checks_json", Text, nullable=True), Column("source_schema_name", String(255), nullable=False), Column("source_column_name", String(255), nullable=False), ) self.schema_checks = Table( "catalog_schema_checks", self.metadata, Column("schema_name", String(255), nullable=False), Column("column_name", String(255), nullable=True), Column("scope", String(32), nullable=False), Column("check_order", Integer, nullable=False), Column("name", String(255), nullable=False), Column("statistics_json", Text, nullable=True), Column("payload_json", Text, nullable=False), Column("source_schema_name", String(255), nullable=False), Column("source_column_name", String(255), nullable=True), ) self.metadata_lookup = Table( "catalog_metadata_lookup", self.metadata, Column("schema_name", String(255), nullable=False), Column("meta_key", String(255), nullable=False), Column("lookup_key", String(255), nullable=True), Column("lookup_value_json", Text, nullable=False), Column("value_type", String(64), nullable=False), ) self.projections = Table( "catalog_projections", self.metadata, Column("projection_name", String(255), primary_key=True), Column("description", Text, nullable=True), ) self.projection_steps = Table( "catalog_projection_steps", self.metadata, Column("projection_name", String(255), nullable=False), Column("step_index", Integer, nullable=False), Column("kind", String(64), nullable=False), Column("source_schema_name", String(255), nullable=False), Column("name_ordinal", Integer, nullable=False), Column("selected_name", String(255), nullable=False), ) self.projection_columns = Table( "catalog_projection_columns", self.metadata, Column("projection_name", String(255), nullable=False), Column("column_name", String(255), nullable=False), Column("ordinal", Integer, nullable=False), Column("dtype", String(255), nullable=True), Column("nullable", Boolean, nullable=True), Column("required", Boolean, nullable=True), Column("unique_value", Boolean, nullable=True), Column("coerce", Boolean, nullable=True), Column("regex", Boolean, nullable=True), Column("checks_json", Text, nullable=True), Column("source_schema_name", String(255), nullable=False), Column("source_column_name", String(255), nullable=False), ) self.projection_checks = Table( "catalog_projection_checks", self.metadata, Column("projection_name", String(255), nullable=False), Column("column_name", String(255), nullable=True), Column("scope", String(32), nullable=False), Column("check_order", Integer, nullable=False), Column("name", String(255), nullable=False), Column("statistics_json", Text, nullable=True), Column("payload_json", Text, nullable=False), Column("source_schema_name", String(255), nullable=False), Column("source_column_name", String(255), nullable=True), )
[docs] def initialize(self) -> None: """Create backend tables and tabular views.""" self.metadata.create_all(self.engine) self._create_views()
def _create_views(self) -> None: dialect_name = self.engine.dialect.name with self.engine.begin() as conn: for drop_sql, create_sql in get_view_ddl(dialect_name): conn.execute(text(drop_sql)) conn.execute(text(create_sql))
[docs] def load_schemas(self) -> list[SchemaEntry]: """Return persisted schema entries.""" entries: list[SchemaEntry] = [] with self.engine.connect() as conn: rows = conn.execute( select( self.schemas.c.schema_name, self.schemas.c.description, self.schemas.c.tags_json, self.schemas.c.schema_yaml, ) ).all() for row in rows: entries.append( SchemaEntry( name=row.schema_name, schema=pa.DataFrameSchema.from_yaml(row.schema_yaml), description=row.description, tags=list(json.loads(row.tags_json)), ) ) return entries
[docs] def load_projections(self) -> list[SchemaProjectionEntry]: """Return persisted projection entries.""" with self.engine.connect() as conn: projection_rows = conn.execute( select(self.projections.c.projection_name, self.projections.c.description) ).all() step_rows = conn.execute( select( self.projection_steps.c.projection_name, self.projection_steps.c.step_index, self.projection_steps.c.kind, self.projection_steps.c.source_schema_name, self.projection_steps.c.name_ordinal, self.projection_steps.c.selected_name, ).order_by( self.projection_steps.c.projection_name, self.projection_steps.c.step_index, self.projection_steps.c.name_ordinal, ) ).all() descriptions = {row.projection_name: row.description for row in projection_rows} grouped: dict[str, dict[int, dict[str, object]]] = defaultdict(dict) for row in step_rows: projection_steps = grouped[row.projection_name] step_payload = projection_steps.setdefault( row.step_index, { "schema": row.source_schema_name, "kind": row.kind, "names": [], }, ) names = step_payload["names"] assert isinstance(names, list) names.append(row.selected_name) result: list[SchemaProjectionEntry] = [] for projection_name, step_map in grouped.items(): ordered_steps: list[SchemaProjectionStep] = [] for step_index in sorted(step_map): payload = step_map[step_index] ordered_steps.append( SchemaProjectionStep( schema=str(payload["schema"]), kind=str(payload["kind"]), names=list(payload["names"]), ) ) result.append( SchemaProjectionEntry( name=projection_name, steps=ordered_steps, description=descriptions.get(projection_name), ) ) return result
[docs] def upsert_schema(self, entry: SchemaEntry) -> None: """Insert or replace a schema entry and its flattened rows.""" schema_yaml = entry.schema.to_yaml() definition = yaml.safe_load(schema_yaml) or {} with self.engine.begin() as conn: self._delete_schema_rows(conn, entry.name) conn.execute( insert(self.schemas).values( schema_name=entry.name, description=entry.description, tags_json=json.dumps(entry.tags), schema_yaml=schema_yaml, ) ) self._insert_schema_flat_rows(conn, entry.name, definition)
[docs] def remove_schema(self, schema_name: str) -> None: """Delete a persisted schema and its flattened rows.""" with self.engine.begin() as conn: self._delete_schema_rows(conn, schema_name)
[docs] def upsert_projection( self, entry: SchemaProjectionEntry, resolved_columns: list[tuple[str, str]], ) -> None: """Insert or replace a projection and materialized projection rows.""" with self.engine.begin() as conn: self._delete_projection_rows(conn, entry.name) conn.execute( insert(self.projections).values( projection_name=entry.name, description=entry.description, ) ) for step_index, step in enumerate(entry.steps, start=1): for name_ordinal, selected_name in enumerate(step.names, start=1): conn.execute( insert(self.projection_steps).values( projection_name=entry.name, step_index=step_index, kind=step.kind, source_schema_name=step.schema, name_ordinal=name_ordinal, selected_name=selected_name, ) ) for ordinal, (source_schema_name, source_column_name) in enumerate( resolved_columns, start=1 ): source_column = conn.execute( select( self.schema_columns.c.dtype, self.schema_columns.c.nullable, self.schema_columns.c.required, self.schema_columns.c.unique_value, self.schema_columns.c.coerce, self.schema_columns.c.regex, self.schema_columns.c.checks_json, ).where( self.schema_columns.c.schema_name == source_schema_name, self.schema_columns.c.column_name == source_column_name, ) ).first() if source_column is None: continue conn.execute( insert(self.projection_columns).values( projection_name=entry.name, column_name=source_column_name, ordinal=ordinal, dtype=source_column.dtype, nullable=source_column.nullable, required=source_column.required, unique_value=source_column.unique_value, coerce=source_column.coerce, regex=source_column.regex, checks_json=source_column.checks_json, source_schema_name=source_schema_name, source_column_name=source_column_name, ) ) source_checks = conn.execute( select( self.schema_checks.c.column_name, self.schema_checks.c.scope, self.schema_checks.c.check_order, self.schema_checks.c.name, self.schema_checks.c.statistics_json, self.schema_checks.c.payload_json, ).where( self.schema_checks.c.schema_name == source_schema_name, self.schema_checks.c.column_name == source_column_name, self.schema_checks.c.scope == "column", ) ).all() for check_row in source_checks: conn.execute( insert(self.projection_checks).values( projection_name=entry.name, column_name=check_row.column_name, scope=check_row.scope, check_order=check_row.check_order, name=check_row.name, statistics_json=check_row.statistics_json, payload_json=check_row.payload_json, source_schema_name=source_schema_name, source_column_name=source_column_name, ) )
[docs] def remove_projection(self, projection_name: str) -> None: """Delete a persisted projection and materialized rows.""" with self.engine.begin() as conn: self._delete_projection_rows(conn, projection_name)
def _delete_schema_rows(self, conn: Connection, schema_name: str) -> None: conn.execute(delete(self.schema_checks).where(self.schema_checks.c.schema_name == schema_name)) conn.execute(delete(self.schema_columns).where(self.schema_columns.c.schema_name == schema_name)) conn.execute(delete(self.metadata_lookup).where(self.metadata_lookup.c.schema_name == schema_name)) conn.execute(delete(self.schemas).where(self.schemas.c.schema_name == schema_name)) def _delete_projection_rows(self, conn: Connection, projection_name: str) -> None: conn.execute( delete(self.projection_checks).where( self.projection_checks.c.projection_name == projection_name ) ) conn.execute( delete(self.projection_columns).where( self.projection_columns.c.projection_name == projection_name ) ) conn.execute( delete(self.projection_steps).where( self.projection_steps.c.projection_name == projection_name ) ) conn.execute( delete(self.projections).where(self.projections.c.projection_name == projection_name) ) def _insert_schema_flat_rows( self, conn: Connection, schema_name: str, definition: dict[str, object] ) -> None: columns = definition.get("columns") or {} if isinstance(columns, dict): for ordinal, (column_name, payload) in enumerate(columns.items(), start=1): column_payload = payload if isinstance(payload, dict) else {} checks = self._normalise_checks(column_payload.get("checks")) if not checks: checks = [ {key: value} for key, value in column_payload.items() if key not in _COLUMN_BASE_KEYS ] conn.execute( insert(self.schema_columns).values( schema_name=schema_name, column_name=column_name, ordinal=ordinal, dtype=self._optional_text(column_payload.get("dtype")), nullable=self._optional_bool(column_payload.get("nullable")), required=self._optional_bool(column_payload.get("required")), unique_value=self._optional_bool(column_payload.get("unique")), coerce=self._optional_bool(column_payload.get("coerce")), regex=self._optional_bool(column_payload.get("regex")), checks_json=json.dumps(checks), source_schema_name=schema_name, source_column_name=column_name, ) ) for check_order, check_payload in enumerate(checks, start=1): name, statistics_json, payload_json = self._parse_check_payload( check_payload, check_order ) conn.execute( insert(self.schema_checks).values( schema_name=schema_name, column_name=column_name, scope="column", check_order=check_order, name=name, statistics_json=statistics_json, payload_json=payload_json, source_schema_name=schema_name, source_column_name=column_name, ) ) dataframe_checks = self._normalise_checks(definition.get("checks")) if not dataframe_checks: dataframe_checks = [ {key: value} for key, value in definition.items() if key not in _SCHEMA_BASE_KEYS ] for check_order, check_payload in enumerate(dataframe_checks, start=1): name, statistics_json, payload_json = self._parse_check_payload( check_payload, check_order ) conn.execute( insert(self.schema_checks).values( schema_name=schema_name, column_name=None, scope="dataframe", check_order=check_order, name=name, statistics_json=statistics_json, payload_json=payload_json, source_schema_name=schema_name, source_column_name=None, ) ) metadata_payload = definition.get("metadata") if isinstance(metadata_payload, dict): for meta_key, lookup_value in metadata_payload.items(): if isinstance(lookup_value, dict): for lookup_key, nested_value in lookup_value.items(): conn.execute( insert(self.metadata_lookup).values( schema_name=schema_name, meta_key=str(meta_key), lookup_key=str(lookup_key), lookup_value_json=json.dumps(nested_value), value_type=type(nested_value).__name__, ) ) else: conn.execute( insert(self.metadata_lookup).values( schema_name=schema_name, meta_key=str(meta_key), lookup_key=None, lookup_value_json=json.dumps(lookup_value), value_type=type(lookup_value).__name__, ) ) @staticmethod def _normalise_checks(payload: object) -> list[object]: if payload is None: return [] if isinstance(payload, list): return payload return [payload] @staticmethod def _parse_check_payload(payload: object, check_order: int) -> tuple[str, str | None, str]: check_name = f"check_{check_order}" statistics: object | None = None if isinstance(payload, dict): if "name" in payload and isinstance(payload["name"], str): check_name = payload["name"] statistics = payload.get("statistics") elif len(payload) == 1: key = next(iter(payload.keys())) check_name = str(key) value = payload[key] statistics = value else: statistics = payload.get("statistics") payload_json = json.dumps(payload) statistics_json = json.dumps(statistics) if statistics is not None else None return check_name, statistics_json, payload_json @staticmethod def _optional_bool(value: object) -> bool | None: if value is None: return None if isinstance(value, bool): return value return bool(value) @staticmethod def _optional_text(value: object) -> str | None: if value is None: return None return str(value)