Audit queries
=============
Helpers that turn a history table into a diff or net-delta query.
Each helper returns a SQLAlchemy :class:`~sqlalchemy.Select` and
registers nothing on metadata — pipe it into
:class:`~codegen_database.declarative.CodegenDatabaseViewMixin` to expose as a view, or
run it directly against a connection.
What is exposed
---------------
Four functions, re-exported at :mod:`codegen_database.ext.audit` and at the top-level
``codegen_database`` package:
.. list-table::
:header-rows: 1
:widths: 25 38 38
* - Layout
- Per-transition
- Net delta over a window
* - Append-only (SCD Type 2)
- :func:`~codegen_database.ext.audit.construct_append_only_diff_query`
- :func:`~codegen_database.ext.audit.construct_append_only_changed_between_query`
* - EAV
- :func:`~codegen_database.ext.audit.construct_eav_diff_query`
- :func:`~codegen_database.ext.audit.construct_eav_changed_between_query`
All four emit typed ``
_before`` / ``_after`` pairs
(``value_before`` / ``value_after`` for EAV). No JSONB.
Pick the row matching the backing table:
- **Append-only** (each row is a full version of an entity) — pass the
factory's ``ctx["attributes"]`` table, or any hand-rolled SCD Type 2
log.
- **EAV** (each row is one attribute's value at one point in time) —
pass the factory's ``ctx["attribute"]`` table, or any log with the
same shape.
How to use
----------
Expose as a declarative view:
.. code-block:: python
from codegen_database import CodegenDatabaseViewMixin, construct_append_only_diff_query
class ProductDiffs(CodegenDatabaseViewMixin, Base):
__tablename__ = "product_diffs"
__query__ = construct_append_only_diff_query(
ProductVersions.__table__, key_cols=["sku"],
)
Or run the query directly:
.. code-block:: python
from sqlalchemy import create_engine
from codegen_database import construct_append_only_diff_query
engine = create_engine(DATABASE_URL)
with engine.connect() as conn:
rows = conn.execute(
construct_append_only_diff_query(table, key_cols=["sku"])
).all()
Append-only
-----------
``construct_append_only_diff_query`` — per-transition
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``LAG()`` partitioned by *key_cols*, ordered by *ts_col*. Emits one
row per transition where at least one tracked column changed
(``IS DISTINCT FROM``, so ``NULL``/non-``NULL`` counts as a change).
The first row per key is included as a creation event with
``*_before = NULL``.
Default *tracked_cols* is every column except *key_cols*, *ts_col*,
and the primary-key columns (so surrogate version IDs don't leak
into the diff).
.. code-block:: sql
SELECT sku, changed_at,
name_before, name_after,
price_before, price_after,
stock_before, stock_after
FROM (
SELECT sku,
created_at AS changed_at,
lag(name) OVER w AS name_before, name AS name_after,
lag(price) OVER w AS price_before, price AS price_after,
lag(stock) OVER w AS stock_before, stock AS stock_after
FROM product_versions
WINDOW w AS (PARTITION BY sku ORDER BY created_at)
) t
WHERE name_before IS DISTINCT FROM name_after
OR price_before IS DISTINCT FROM price_after
OR stock_before IS DISTINCT FROM stock_after
ORDER BY sku, changed_at;
``construct_append_only_changed_between_query`` — net delta
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Two ``DISTINCT ON (key)`` snapshots (state-as-of *start*,
state-as-of *end*, both inclusive), ``FULL OUTER JOIN``\ ed on the
key. Intermediate versions inside the window are collapsed — only
the net delta survives. Entities created inside the window appear
with ``NULL`` on the before side; entities whose last version is
before *end* appear with ``NULL`` on the after side.
.. code-block:: sql
SELECT coalesce(after_.sku, before_.sku) AS sku,
before_.name AS name_before, after_.name AS name_after,
before_.price AS price_before, after_.price AS price_after,
before_.stock AS stock_before, after_.stock AS stock_after
FROM (SELECT DISTINCT ON (sku) sku, name, price, stock
FROM product_versions WHERE created_at <= :start
ORDER BY sku, created_at DESC) AS before_
FULL OUTER JOIN
(SELECT DISTINCT ON (sku) sku, name, price, stock
FROM product_versions WHERE created_at <= :end
ORDER BY sku, created_at DESC) AS after_
ON before_.sku = after_.sku
WHERE before_.name IS DISTINCT FROM after_.name
OR before_.price IS DISTINCT FROM after_.price
OR before_.stock IS DISTINCT FROM after_.stock;
EAV
---
EAV diff output is **long**, not wide: one row per
``(entity, attribute)`` transition, with a single ``value_before`` /
``value_after`` pair. All typed ``*_value`` columns are cast to
``text`` and coalesced into one expression — each EAV row has exactly
one non-null value (check-constrained), so the coalesce is loss-free.
*value_cols* is auto-detected by a ``_value`` name suffix.
``construct_eav_diff_query`` — per-attribute transition
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``LAG()`` partitioned by ``(entity, attribute)``, ordered by
*ts_col*. First observation per pair comes through with
``value_before = NULL``. Optional ``attributes=[...]`` filter
restricts to named attributes.
.. code-block:: sql
SELECT entity_id, attribute_name, changed_at,
value_before, value_after
FROM (
SELECT entity_id, attribute_name, changed_at,
lag(value) OVER (PARTITION BY entity_id, attribute_name
ORDER BY changed_at) AS value_before,
value AS value_after
FROM (
SELECT entity_id, attribute_name,
created_at AS changed_at,
coalesce(CAST(string_value AS TEXT),
CAST(integer_value AS TEXT)) AS value
FROM eav_attribute
-- optional: WHERE attribute_name IN :attributes
) eav_rows
) lagged
WHERE value_before IS DISTINCT FROM value_after
ORDER BY entity_id, attribute_name, changed_at;
``construct_eav_changed_between_query`` — net delta
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Same ``DISTINCT ON`` + ``FULL OUTER JOIN`` pattern as the append-only
version, keyed by ``(entity, attribute)``.
.. code-block:: sql
SELECT coalesce(after_.entity_id, before_.entity_id) AS entity_id,
coalesce(after_.attribute_name, before_.attribute_name) AS attribute_name,
before_.value AS value_before,
after_.value AS value_after
FROM (SELECT DISTINCT ON (entity_id, attribute_name)
entity_id, attribute_name,
coalesce(CAST(string_value AS TEXT),
CAST(integer_value AS TEXT)) AS value
FROM eav_attribute WHERE created_at <= :start
ORDER BY entity_id, attribute_name, created_at DESC) AS before_
FULL OUTER JOIN
(SELECT DISTINCT ON (entity_id, attribute_name)
entity_id, attribute_name,
coalesce(CAST(string_value AS TEXT),
CAST(integer_value AS TEXT)) AS value
FROM eav_attribute WHERE created_at <= :end
ORDER BY entity_id, attribute_name, created_at DESC) AS after_
ON before_.entity_id = after_.entity_id
AND before_.attribute_name = after_.attribute_name
WHERE before_.value IS DISTINCT FROM after_.value;
Full signatures live in the :doc:`api` reference.