Skip to content

Commit 6998c6f

Browse files
committed
test: cover every rule fact, cache reuse and host outcomes through the native payload
Rules reading every public fact report the compiled values cold and warm and rerun only the edited model's invocation; failing, host-exiting and slow rules keep their results, messages and payload cleanup.
1 parent 8e46a48 commit 6998c6f

2 files changed

Lines changed: 249 additions & 0 deletions

File tree

‎tests/integration/src/sqlbuild/cli/commands/main/_test_types.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,3 +1177,24 @@ class TemplatedHeaderDiagnosticsTestCase:
11771177
amount_type: str
11781178
amount_description: str
11791179
expected_location: dict[str, object]
1180+
1181+
1182+
@dataclass(frozen=True)
1183+
class CustomRuleSurfaceTestCase:
1184+
"""Compiles after each `{path: contents}` edit with rules reading every public fact."""
1185+
1186+
description: str
1187+
project_files: dict[str, str]
1188+
edits: tuple[dict[str, str], ...]
1189+
expected_messages: tuple[str, ...]
1190+
expected_hits_and_misses: tuple[tuple[int, int], ...]
1191+
1192+
1193+
@dataclass(frozen=True)
1194+
class CustomHostOutcomeTestCase:
1195+
"""A compile whose custom rule reads facts, then fails, exits its host, or finishes slowly."""
1196+
1197+
description: str
1198+
rule_body: str
1199+
expected_exit_code: int
1200+
expected_messages: tuple[str, ...]

‎tests/integration/src/sqlbuild/cli/commands/main/test_rules.py‎

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
from tests.integration.src.sqlbuild.cli.commands.main._test_types import (
2626
CustomHostFailureIntegrationTestCase,
2727
CustomHostInterruptIntegrationTestCase,
28+
CustomHostOutcomeTestCase,
2829
CustomHostSplitIntegrationTestCase,
2930
CustomRuleCacheSequenceTestCase,
31+
CustomRuleSurfaceTestCase,
3032
DynamicPivotRulesIntegrationTestCase,
3133
EarlyLintInputsTestCase,
3234
EmptyRuleSelectionTestCase,
@@ -74,6 +76,112 @@ def final_directory(*, model: Model, ctx: RuleContext) -> list[Finding]:
7476
_CUSTOM_CACHE_MODEL_SQL: str = 'MODEL (description "Orders");\nSELECT 1 AS order_id\n'
7577
_CUSTOM_CACHE_EDITED_SQL: str = _CUSTOM_CACHE_MODEL_SQL.replace("SELECT 1", "SELECT 2")
7678

79+
_SURFACE_RULES: str = """from sqlbuild.rules import Finding, Model, Project, RuleContext, rule
80+
81+
82+
def _names(models: tuple[Model, ...]) -> str:
83+
return ",".join(model.name for model in models)
84+
85+
86+
@rule(code="XSQBRSURF001", message="model facts", remediation="Read every model fact.")
87+
def model_facts(*, model: Model, ctx: RuleContext) -> list[Finding]:
88+
sql = ctx.sql.for_model(model)
89+
declared = ";".join(
90+
f"{column.name}:{column.type}:{column.nullable}:{column.description}:"
91+
+ "/".join(
92+
f"{audit.definition_name}:{sorted(audit.arguments.items())}:{audit.severity}"
93+
for audit in column.audits
94+
)
95+
+ f":{column.location.path.as_posix()}:{column.location.line}"
96+
for column in ctx.columns.declared(model)
97+
)
98+
inferred = ctx.columns.inferred(model) or ()
99+
parts = (
100+
model.name,
101+
model.path.as_posix(),
102+
str(model.materialization),
103+
str(len(sql.authored.source)),
104+
str(len(sql.expanded.source)),
105+
_names(ctx.graph.dependencies(model)),
106+
_names(ctx.graph.dependents(model)),
107+
declared,
108+
";".join(f"{c.name}:{c.type}:{c.nullability.value}" for c in inferred),
109+
",".join(ctx.columns.names(model) or ()),
110+
str(ctx.contracts.enforced(model)),
111+
",".join(ctx.contracts.grain(model)),
112+
",".join(test.name for test in ctx.tests.for_model(model)),
113+
",".join(audit.name for audit in ctx.audits.for_model(model)),
114+
)
115+
return [ctx.finding(subject=model, message="|".join(parts))]
116+
117+
118+
@rule(code="XSQBRSURF002", message="project facts", remediation="Read every project fact.")
119+
def project_facts(*, project: Project, ctx: RuleContext) -> list[Finding]:
120+
facts = ctx.project
121+
parts = (
122+
str(project.model_count),
123+
str(project.target_name),
124+
_names(facts.models),
125+
",".join(source.name for source in facts.sources),
126+
",".join(seed.name for seed in facts.seeds),
127+
",".join(function.name for function in facts.functions),
128+
",".join(test.name for test in facts.tests),
129+
",".join(audit.name for audit in facts.audits),
130+
str(len(ctx.tests.all())),
131+
str(len(ctx.audits.all())),
132+
",".join(item.name for item in ctx.declarations.public_enums),
133+
",".join(item.name for item in ctx.declarations.public_constants),
134+
str(len(ctx.declarations.enums)),
135+
str(len(ctx.declarations.constants)),
136+
_names(facts.tree.resources_under("models/marts")),
137+
)
138+
return [ctx.finding(subject=facts.models[0], message="|".join(parts))]
139+
"""
140+
_SURFACE_FILES: dict[str, str] = {
141+
"sqlbuild_project.toml": (
142+
'name = "orders"\nadapter = "duckdb"\n\n[rules]\nselect = ["XSQBRSURF"]\n\n'
143+
"[rules.thresholds]\nmin_custom_rule_test_cases = 0\n"
144+
),
145+
"rules/surface.py": _SURFACE_RULES,
146+
"sources/raw.yml": (
147+
"sources:\n - name: raw_orders\n description: Raw orders.\n"
148+
' expression: "(SELECT 1 AS id, CAST(10.5 AS DOUBLE) AS amount)"\n'
149+
),
150+
"seeds/regions.csv": "region_id,name\n1,north\n",
151+
"seeds/regions.yml": (
152+
"seeds:\n - name: regions\n description: Regions.\n columns:\n"
153+
" - name: region_id\n type: INTEGER\n - name: name\n type: VARCHAR\n"
154+
),
155+
"models/staging/_sqlbuild/_constants/policy.sql": (
156+
"CONSTANT (name minimum_amount, value 7);\n"
157+
),
158+
"models/staging/_sqlbuild/_enums/order_status.sql": (
159+
"ENUM (name order_status, members [PAID, OPEN]);\n"
160+
),
161+
"models/staging/stg_orders.sql": (
162+
"MODEL (description 'Staged orders.', contract enforced, columns (\n"
163+
" order_id (type INTEGER, nullable false, description 'Order key',\n"
164+
" audits [not_null, accepted_values (values [1, 2], severity warn)]),\n"
165+
" amount (type DOUBLE),\n status (type order_status),\n));\n\n"
166+
'SELECT CAST(id AS INTEGER) AS order_id, amount, @enum("order_status").PAID AS status\n'
167+
'FROM __source("raw_orders") WHERE amount > @const("minimum_amount")\n'
168+
),
169+
"models/marts/order_totals.sql": (
170+
'MODEL (description "Order totals", materialized table);\n\n'
171+
'SELECT o.order_id, o.amount * 2 AS doubled_amount FROM __ref("stg_orders") AS o\n'
172+
),
173+
"tests/unit/test_order_totals.sql": (
174+
"TEST ();\n\nWITH\n__ref__stg_orders AS (SELECT 1 AS order_id, 10.0 AS amount),\n"
175+
"__expected__order_totals AS (SELECT 1 AS order_id, 20.0 AS doubled_amount)\n"
176+
"SELECT 1\n"
177+
),
178+
}
179+
_SURFACE_STAGING_EDIT: dict[str, str] = {
180+
"models/staging/stg_orders.sql": _SURFACE_FILES["models/staging/stg_orders.sql"].replace(
181+
"AS order_id, amount,", "AS order_id,\n amount,"
182+
)
183+
}
184+
77185
_EARLY_LINT_SLOW_HOOKS: int = 8
78186
_EARLY_LINT_SLOW_EXPANSION_S: float = 0.5
79187
_EARLY_LINT_EXPANSION_DELAYS_S: dict[str, float] = {"orders": 0.0}
@@ -3006,3 +3114,123 @@ def test_given_custom_rule_cache_when_inputs_change_then_only_unchanged_runs_hit
30063114
observed.append((timings["rule_cache_hits"], timings["rule_cache_misses"]))
30073115

30083116
assert tuple(observed) == test_case.expected_hits_and_misses
3117+
3118+
3119+
@pytest.mark.parametrize(
3120+
"test_case",
3121+
[
3122+
CustomRuleSurfaceTestCase(
3123+
description=(
3124+
"rules reading every public fact see the compiled values cold, reuse them warm, "
3125+
"and rerun only the edited model's invocation"
3126+
),
3127+
project_files=_SURFACE_FILES,
3128+
edits=({}, {}, _SURFACE_STAGING_EDIT),
3129+
expected_messages=(
3130+
"2|None|order_totals,stg_orders|raw_orders|regions||test_order_totals|"
3131+
"not_null,accepted_values,accepted_values|1|3|order_status|minimum_amount|1|1|"
3132+
"order_totals",
3133+
"order_totals|models/marts/order_totals.sql|table|137|79|stg_orders|||"
3134+
"order_id:INT:non_null;doubled_amount:DOUBLE:unknown|order_id,doubled_amount|"
3135+
"False||test_order_totals|",
3136+
"stg_orders|models/staging/stg_orders.sql|None|417|109||order_totals|"
3137+
"order_id:INTEGER:False:Order key:not_null:[]:None/accepted_values:"
3138+
"[('values', [1, 2])]:warn:models/staging/stg_orders.sql:2;"
3139+
"amount:DOUBLE:None:None::models/staging/stg_orders.sql:4;"
3140+
"status:VARCHAR:None:None:accepted_values:[('values', ('PAID', 'OPEN'))]:None:"
3141+
"models/staging/stg_orders.sql:5|"
3142+
"order_id:INT:unknown;amount:DOUBLE:unknown;status:TEXT:non_null|"
3143+
"order_id,amount,status|True|||not_null,accepted_values,accepted_values",
3144+
),
3145+
expected_hits_and_misses=((0, 4), (4, 0), (3, 1)),
3146+
)
3147+
],
3148+
ids=lambda case: case.description,
3149+
)
3150+
def test_given_rules_reading_every_fact_when_compiling_then_values_and_cache_reuse_hold(
3151+
test_case: CustomRuleSurfaceTestCase,
3152+
tmp_path: Path,
3153+
capsys: pytest.CaptureFixture[str],
3154+
) -> None:
3155+
write_project_files(project_dir=tmp_path, files=test_case.project_files)
3156+
observed: list[tuple[int, int]] = []
3157+
messages: list[tuple[str, ...]] = []
3158+
for edit in test_case.edits:
3159+
write_project_files(project_dir=tmp_path, files=edit)
3160+
_ = main(["--project-dir", str(tmp_path), "compile", "--json"])
3161+
output: dict[str, Any] = json.loads(capsys.readouterr().out)
3162+
timings: dict[str, int] = output["compile_timings"]
3163+
observed.append((timings["rule_cache_hits"], timings["rule_cache_misses"]))
3164+
messages.append(tuple(sorted(item["message"] for item in output["diagnostics"])))
3165+
config_path: Path = tmp_path / "sqlbuild_project.toml"
3166+
config_path.write_text(
3167+
config_path.read_text(encoding="utf-8") + "\n[rules.cache]\nenabled = false\n",
3168+
encoding="utf-8",
3169+
)
3170+
_ = main(["--project-dir", str(tmp_path), "compile", "--json", "--no-cache"])
3171+
uncached: dict[str, Any] = json.loads(capsys.readouterr().out)
3172+
3173+
assert tuple(observed) == test_case.expected_hits_and_misses
3174+
assert messages[0] == messages[1] == test_case.expected_messages
3175+
assert messages[2] == tuple(sorted(item["message"] for item in uncached["diagnostics"]))
3176+
3177+
3178+
@pytest.mark.parametrize(
3179+
"test_case",
3180+
[
3181+
CustomHostOutcomeTestCase(
3182+
description="a rule failing after lazy fact reads reports its model",
3183+
rule_body=(
3184+
"columns = ctx.columns.inferred(model)\n"
3185+
'if model.name == "orders_001":\n'
3186+
' raise ValueError(f"cannot inspect {columns[0].name}")\n'
3187+
"return []"
3188+
),
3189+
expected_exit_code=1,
3190+
expected_messages=(
3191+
"invalid rules lifecycle: custom host failed: rule XSQBRHOST001 failed for "
3192+
"models/orders_001.sql: cannot inspect order_id",
3193+
),
3194+
),
3195+
CustomHostOutcomeTestCase(
3196+
description="a rule exiting its host reports the host failure",
3197+
rule_body=(
3198+
"sql = ctx.sql.for_model(model).expanded.source\n"
3199+
'if model.name == "orders_002":\n'
3200+
" raise SystemExit(3)\n"
3201+
"return []"
3202+
),
3203+
expected_exit_code=1,
3204+
expected_messages=(
3205+
"invalid rules lifecycle: custom host failed: process exited with exit status: 3",
3206+
),
3207+
),
3208+
CustomHostOutcomeTestCase(
3209+
description="a slow rule reading lazy facts reports every finding",
3210+
rule_body=(
3211+
"_ = sum(range(2_000_000))\n"
3212+
"return [ctx.finding(subject=model, message=ctx.sql.for_model(model).expanded.source)]"
3213+
),
3214+
expected_exit_code=1,
3215+
expected_messages=(
3216+
"SELECT 0 AS order_id",
3217+
"SELECT 1 AS order_id",
3218+
"SELECT 2 AS order_id",
3219+
),
3220+
),
3221+
],
3222+
ids=lambda case: case.description,
3223+
)
3224+
def test_given_custom_rule_outcome_when_compiling_then_result_and_cleanup_are_reported(
3225+
test_case: CustomHostOutcomeTestCase,
3226+
tmp_path: Path,
3227+
capsys: pytest.CaptureFixture[str],
3228+
) -> None:
3229+
write_custom_host_project(project_dir=tmp_path, model_count=3, rule_body=test_case.rule_body)
3230+
3231+
exit_code: int = main(["--project-dir", str(tmp_path), "compile", "--json", "--no-cache"])
3232+
output: dict[str, Any] = json.loads(capsys.readouterr().out)
3233+
3234+
assert exit_code == test_case.expected_exit_code
3235+
assert tuple(item["message"] for item in output["diagnostics"]) == test_case.expected_messages
3236+
assert not tuple((tmp_path / "target" / "rules-cache" / "host-inputs").iterdir())

0 commit comments

Comments
 (0)