|
25 | 25 | from tests.integration.src.sqlbuild.cli.commands.main._test_types import ( |
26 | 26 | CustomHostFailureIntegrationTestCase, |
27 | 27 | CustomHostInterruptIntegrationTestCase, |
| 28 | + CustomHostOutcomeTestCase, |
28 | 29 | CustomHostSplitIntegrationTestCase, |
29 | 30 | CustomRuleCacheSequenceTestCase, |
| 31 | + CustomRuleSurfaceTestCase, |
30 | 32 | DynamicPivotRulesIntegrationTestCase, |
31 | 33 | EarlyLintInputsTestCase, |
32 | 34 | EmptyRuleSelectionTestCase, |
@@ -74,6 +76,112 @@ def final_directory(*, model: Model, ctx: RuleContext) -> list[Finding]: |
74 | 76 | _CUSTOM_CACHE_MODEL_SQL: str = 'MODEL (description "Orders");\nSELECT 1 AS order_id\n' |
75 | 77 | _CUSTOM_CACHE_EDITED_SQL: str = _CUSTOM_CACHE_MODEL_SQL.replace("SELECT 1", "SELECT 2") |
76 | 78 |
|
| 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 | + |
77 | 185 | _EARLY_LINT_SLOW_HOOKS: int = 8 |
78 | 186 | _EARLY_LINT_SLOW_EXPANSION_S: float = 0.5 |
79 | 187 | _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 |
3006 | 3114 | observed.append((timings["rule_cache_hits"], timings["rule_cache_misses"])) |
3007 | 3115 |
|
3008 | 3116 | 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