230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
"""Composer SQL emission — golden-string tests against a representative recon.
|
|
|
|
The fixture mirrors enough of the financial dataset to exercise: metric
|
|
with default filter, group_by with single hop and multi-hop joins, typed
|
|
date_range filter on a real DATE column, and ORDER BY by both metric and
|
|
dimension. Adjust goldens cautiously — if a string changes, confirm the
|
|
new SQL still semantically matches before updating the test.
|
|
"""
|
|
|
|
from api.composer import compose
|
|
from api.composer.types import Filter, OrderBy, Pick, PickValidationError
|
|
from api.recon.types import Column, Metric, Recon, Relationship, Table
|
|
|
|
import pytest
|
|
|
|
|
|
def _fixture_recon() -> Recon:
|
|
cols_account = [
|
|
Column("account_id", "BIGINT", False),
|
|
Column("district_id", "BIGINT", True),
|
|
]
|
|
cols_district = [
|
|
Column("district_id", "BIGINT", False),
|
|
Column("A2", "TEXT", True),
|
|
Column("A3", "TEXT", True),
|
|
]
|
|
cols_loan = [
|
|
Column("loan_id", "BIGINT", False),
|
|
Column("account_id", "BIGINT", True),
|
|
Column("amount", "BIGINT", True),
|
|
Column("status", "TEXT", True),
|
|
Column("date", "DATE", True),
|
|
]
|
|
rels = [
|
|
Relationship("account", "district_id", "district", "district_id"),
|
|
Relationship("loan", "account_id", "account", "account_id"),
|
|
]
|
|
c2t: dict[str, list[str]] = {}
|
|
for t in [Table("account", "accounts", cols_account),
|
|
Table("district", "districts", cols_district),
|
|
Table("loan", "loans", cols_loan)]:
|
|
for c in t.columns:
|
|
c2t.setdefault(c.name, []).append(t.name)
|
|
for k in c2t:
|
|
c2t[k] = sorted(c2t[k])
|
|
|
|
metrics = {
|
|
"loan_default_rate": Metric(
|
|
name="loan_default_rate",
|
|
description="Default rate over finished loans.",
|
|
sql="AVG(CASE WHEN status = 'B' THEN 1.0 WHEN status = 'A' THEN 0.0 END)",
|
|
from_table="loan",
|
|
filter="status IN ('A','B')",
|
|
unit="ratio",
|
|
),
|
|
"loan_volume": Metric(
|
|
name="loan_volume",
|
|
description="Total CZK lent.",
|
|
sql="SUM(amount)",
|
|
from_table="loan",
|
|
filter=None,
|
|
unit="CZK",
|
|
),
|
|
}
|
|
return Recon(
|
|
schema="financial",
|
|
tables={
|
|
"account": Table("account", "accounts", cols_account),
|
|
"district": Table("district", "districts", cols_district),
|
|
"loan": Table("loan", "loans", cols_loan),
|
|
},
|
|
metrics=metrics,
|
|
column_to_tables=c2t,
|
|
relationships=rels,
|
|
)
|
|
|
|
|
|
# ── Aggregate, no group_by ──────────────────────────────────
|
|
|
|
def test_metric_only_no_group_by():
|
|
r = _fixture_recon()
|
|
pick = Pick(kind="aggregate", metric="loan_volume")
|
|
out = compose(pick, r)
|
|
assert out.sql == 'SELECT SUM("l"."amount") AS "loan_volume" FROM "loan" AS "l"'
|
|
assert out.used_tables == ["loan"]
|
|
|
|
|
|
def test_metric_with_default_filter_no_group_by():
|
|
r = _fixture_recon()
|
|
pick = Pick(kind="aggregate", metric="loan_default_rate")
|
|
out = compose(pick, r)
|
|
expected = (
|
|
"SELECT AVG(CASE WHEN \"l\".\"status\" = 'B' THEN 1.0 "
|
|
"WHEN \"l\".\"status\" = 'A' THEN 0.0 END) AS \"loan_default_rate\" "
|
|
"FROM \"loan\" AS \"l\" "
|
|
"WHERE \"l\".\"status\" IN ('A', 'B')"
|
|
)
|
|
assert out.sql == expected
|
|
|
|
|
|
# ── Group by — single hop ───────────────────────────────────
|
|
|
|
def test_group_by_multi_hop_join():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_default_rate",
|
|
group_by=["A2"],
|
|
limit=10,
|
|
)
|
|
out = compose(pick, r)
|
|
# loan → account → district, A2 owned by district.
|
|
assert 'FROM "loan" AS "l"' in out.sql
|
|
assert 'JOIN "account" AS "a" ON "l"."account_id" = "a"."account_id"' in out.sql
|
|
assert (
|
|
'JOIN "district" AS "d" ON "a"."district_id" = "d"."district_id"' in out.sql
|
|
)
|
|
assert 'GROUP BY "A2"' in out.sql
|
|
# default ORDER BY metric DESC
|
|
assert 'ORDER BY "loan_default_rate" DESC' in out.sql
|
|
assert "LIMIT 10" in out.sql
|
|
assert set(out.used_tables) == {"loan", "account", "district"}
|
|
|
|
|
|
# ── Filters ─────────────────────────────────────────────────
|
|
|
|
def test_where_equals_filter():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_volume",
|
|
where=[Filter(column="status", equals="D")],
|
|
)
|
|
out = compose(pick, r)
|
|
assert "WHERE \"l\".\"status\" = 'D'" in out.sql
|
|
|
|
|
|
def test_where_in_values_filter():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_volume",
|
|
where=[Filter(column="status", in_values=["C", "D"])],
|
|
)
|
|
out = compose(pick, r)
|
|
assert "\"l\".\"status\" IN ('C', 'D')" in out.sql
|
|
|
|
|
|
def test_where_date_range_on_real_date_column():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_default_rate",
|
|
where=[Filter(column="date", date_range="1996")],
|
|
)
|
|
out = compose(pick, r)
|
|
assert "\"l\".\"date\" BETWEEN '1996-01-01' AND '1996-12-31'" in out.sql
|
|
# metric's default filter still ANDed in
|
|
assert "\"l\".\"status\" IN ('A', 'B')" in out.sql
|
|
|
|
|
|
def test_metric_filter_ands_with_pick_filter():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_default_rate",
|
|
where=[Filter(column="date", date_range="1996")],
|
|
)
|
|
out = compose(pick, r)
|
|
# Both filters in the same WHERE clause, joined by AND.
|
|
assert " AND " in out.sql
|
|
|
|
|
|
# ── ORDER BY by dimension ───────────────────────────────────
|
|
|
|
def test_order_by_dimension():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_volume",
|
|
group_by=["A3"],
|
|
order_by=OrderBy(by="dimension", direction="asc", dimension="A3"),
|
|
)
|
|
out = compose(pick, r)
|
|
assert 'ORDER BY "A3" ASC' in out.sql
|
|
|
|
|
|
def test_order_by_dimension_not_in_group_by_raises():
|
|
r = _fixture_recon()
|
|
pick = Pick(
|
|
kind="aggregate",
|
|
metric="loan_volume",
|
|
group_by=["A2"],
|
|
order_by=OrderBy(by="dimension", direction="asc", dimension="A3"),
|
|
)
|
|
with pytest.raises(PickValidationError, match="not in group_by"):
|
|
compose(pick, r)
|
|
|
|
|
|
# ── Validation rejections ───────────────────────────────────
|
|
|
|
def test_unknown_metric_raises():
|
|
r = _fixture_recon()
|
|
pick = Pick(kind="aggregate", metric="moon_phase")
|
|
with pytest.raises(PickValidationError, match="not in recon"):
|
|
compose(pick, r)
|
|
|
|
|
|
def test_unknown_group_by_column_raises():
|
|
r = _fixture_recon()
|
|
pick = Pick(kind="aggregate", metric="loan_volume", group_by=["nonsense"])
|
|
with pytest.raises(ValueError, match="no table has a column named 'nonsense'"):
|
|
compose(pick, r)
|
|
|
|
|
|
def test_ambiguous_group_by_column_raises():
|
|
r = _fixture_recon()
|
|
# account_id lives on both account and loan; must be qualified.
|
|
pick = Pick(kind="aggregate", metric="loan_volume", group_by=["account_id"])
|
|
with pytest.raises(ValueError, match="ambiguous"):
|
|
compose(pick, r)
|
|
|
|
|
|
def test_qualified_disambiguates():
|
|
r = _fixture_recon()
|
|
pick = Pick(kind="aggregate", metric="loan_volume", group_by=["loan.account_id"])
|
|
out = compose(pick, r)
|
|
# Output alias becomes "loan_account_id" since the ref was qualified.
|
|
assert '"loan_account_id"' in out.sql
|