Source code for codegen_database.ext.ledger.functions

"""Function spec builder for ledger factories.

Provides :func:`ledger_event_function` which compiles a
:class:`~codegen_database.ext.ledger.events.LedgerEvent` into a
:class:`~codegen_database.functions.CodegenDatabaseFunctionSpec` ready to
unpack into :class:`~codegen_database.functions.CodegenDatabaseFunction`.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from sqlalchemy.dialects import postgresql as pg_dialect
from sqlalchemy_declarative_extensions.dialects.postgresql import (
    FunctionSecurity,
)

if TYPE_CHECKING:
    from sqlalchemy import Column, Table

    from codegen_database.factory.context import ContextSource

from codegen_database.errors import CodegenDatabaseValidationError
from codegen_database.ext.ledger.events import (
    LedgerEvent,
    ParamCollector,
    _desired_table_ref,
    _input_table_ref,
)
from codegen_database.functions import CodegenDatabaseFunctionSpec
from codegen_database.utils.query import compile_query
from codegen_database.utils.template import load_template

_TEMPLATES = (
    Path(__file__).resolve().parents[2] / "plugins" / "templates" / "ledger"
)


def _pg_type_str(col: Column) -> str:
    """Return the PostgreSQL type string for *col*.

    Args:
        col: A SQLAlchemy :class:`~sqlalchemy.Column`.

    Returns:
        Compiled PostgreSQL type string.

    """
    return col.type.compile(dialect=pg_dialect.dialect())


def _validate_single_event(
    event: LedgerEvent,
    root_table: Table,
) -> None:
    """Validate one event against the root table columns.

    Args:
        event: The event to validate.
        root_table: The ledger backing table.

    Raises:
        CodegenDatabaseValidationError: If any validation rule is violated.

    """
    if event.desired is not None and not event.diff_keys:
        msg = (
            f"LedgerEvent {event.name!r}: "
            f"diff_keys is required when desired is set."
        )
        raise CodegenDatabaseValidationError(msg)

    if event.existing is not None and event.desired is None:
        msg = (
            f"LedgerEvent {event.name!r}: existing requires desired to be set."
        )
        raise CodegenDatabaseValidationError(msg)

    col_names = {c.key for c in root_table.columns}

    for key in event.diff_keys:
        if key not in col_names:
            msg = (
                f"LedgerEvent {event.name!r}: "
                f"diff_key {key!r} is not a column on "
                f"the ledger table.  Available columns: "
                f"{sorted(col_names)}"
            )
            raise CodegenDatabaseValidationError(msg)


[docs] def ledger_event_function( source: ContextSource, event: LedgerEvent, ) -> CodegenDatabaseFunctionSpec: """Build a function spec for a single ledger event. Returns a :class:`~codegen_database.functions.CodegenDatabaseFunctionSpec`. The spec contains the SQL function body generated from the event lambdas and all metadata needed to register a PostgreSQL function via :class:`~codegen_database.functions.CodegenDatabaseFunction`. Args: source: A :class:`~codegen_database.factory.ledger.CodegenDatabaseLedger` instance or a :class:`~codegen_database.declarative.CodegenDatabaseBase` subclass using ``CodegenDatabaseLedger`` as its factory. event: The :class:`~codegen_database.ext.ledger.events.LedgerEvent` to compile into a function. Returns: A :class:`~codegen_database.functions.CodegenDatabaseFunctionSpec` ready to pass to :class:`~codegen_database.functions.CodegenDatabaseFunction`. Raises: CodegenDatabaseValidationError: If event configuration is invalid. """ ctx = source.ctx # raw_table: the backing table. Used for the INSERT (bypasses the # INSTEAD OF trigger so all rows land in one statement, which is # required for the double-entry balance check). raw_table: Table = ctx["raw_table"] # view_table (__root__): the writable view over raw_table. Used for # the RETURNS type so the function is created AFTER the table (views # are created after tables in migration ordering). view_table: Table = ctx["__root__"] schema: str = ctx.schemaname _validate_single_event(event, raw_table) backing_table = f"{schema}.{raw_table.name}" # Run input lambda with ParamCollector. collector = ParamCollector() input_select = event.input(collector) input_sql = compile_query(input_select) template_ctx: dict = { "input_sql": input_sql, "backing_table": backing_table, "diff_mode": event.desired is not None, } if event.desired is not None: # Build input table ref and run desired. input_ref = _input_table_ref(input_select) desired_select = event.desired(input_ref) desired_sql = compile_query(desired_select) # Build desired table ref and run existing. desired_ref = _desired_table_ref(desired_select) if event.existing is not None: existing_select = event.existing(raw_table, desired_ref, input_ref) existing_sql = compile_query(existing_select) else: # No existing: empty select with same columns. existing_sql = ( "SELECT " + ", ".join( f"NULL::{_pg_type_str(raw_table.c[k])}" if k in {c.key for c in raw_table.columns} else "NULL" for k in [ col.name for col in desired_select.selected_columns ] ) + " WHERE false" ) # Compute column lists. desired_col_names = [ col.name for col in desired_select.selected_columns ] all_cols = list(desired_col_names) # Existing cols: diff_keys + value, with NULL # padding for any extra columns (e.g. reason). existing_col_names = ( [col.name for col in existing_select.selected_columns] if event.existing is not None else [] ) existing_cols_padded: list[str] = [] for col_name in all_cols: if col_name in existing_col_names: existing_cols_padded.append(col_name) else: existing_cols_padded.append(f"NULL AS {col_name}") # Passthrough cols: in desired but not diff_keys or value. passthrough_cols = [ c for c in desired_col_names if c not in event.diff_keys and c != "value" ] template_ctx.update( desired_sql=desired_sql, existing_sql=existing_sql, all_cols=all_cols, desired_cols=", ".join(desired_col_names), existing_cols=", ".join(existing_cols_padded), diff_keys=list(event.diff_keys), passthrough_cols=passthrough_cols, ) else: # Simple mode: columns from input select. all_cols = [col.name for col in input_select.selected_columns] template_ctx["all_cols"] = all_cols fn_body: str = load_template(_TEMPLATES / "event.plpgsql.mako").render( **template_ctx ) fn_name = f"{schema}_{ctx.tablename}_{event.name}" return CodegenDatabaseFunctionSpec( name=fn_name, definition=fn_body, language="plpgsql", parameters=collector.function_params, returns=f"SETOF {schema}.{view_table.name}", security=FunctionSecurity.definer, )