33import hashlib
44import json
55import os
6+ import shutil
67import subprocess
78import sys
89from collections .abc import Callable
@@ -411,6 +412,121 @@ def flagged_values(*, model: Model, ctx: RuleContext) -> list[Finding]:
411412}
412413
413414
415+ _ORDER_SLICE_COUNT : int = 45
416+ CUSTOM_RULES_EDIT_PROJECT : dict [str , str ] = {
417+ "sqlbuild_project.toml" : (
418+ 'name = "orders"\n adapter = "duckdb"\n \n '
419+ '[connection]\n database = "warehouse.duckdb"\n \n '
420+ '[rules]\n select = ["XSQBR"]\n \n '
421+ "[rules.thresholds]\n min_custom_rule_test_cases = 0\n "
422+ ),
423+ "sources/raw.yml" : (
424+ "sources:\n "
425+ " - name: raw_orders\n description: Raw orders.\n "
426+ ' expression: "(SELECT 1 AS id, CAST(10.5 AS DOUBLE) AS amount, 1 AS customer_id)"\n '
427+ " - name: raw_customers\n description: Raw customers.\n "
428+ ' expression: "(SELECT 1 AS customer_id)"\n '
429+ " - name: raw_products\n description: Raw products.\n "
430+ ' expression: "(SELECT 1 AS product_id)"\n '
431+ ),
432+ "models/staging/stg_orders.sql" : (
433+ 'MODEL (description "Staged orders");\n \n '
434+ 'SELECT id AS order_id, amount, customer_id FROM __source("raw_orders")\n '
435+ ),
436+ "models/staging/stg_customers.sql" : (
437+ 'MODEL (description "Staged customers");\n \n '
438+ 'SELECT customer_id FROM __source("raw_customers")\n '
439+ ),
440+ "models/marts/order_totals.sql" : (
441+ 'MODEL (description "Order totals");\n \n '
442+ 'SELECT order_id, amount * 2 AS doubled_amount FROM __ref("stg_orders") WHERE amount > 7\n '
443+ ),
444+ ** {
445+ f"models/intermediate/orders_{ index :03d} .sql" : (
446+ f'MODEL (description "Order slice { index } ");\n \n '
447+ 'SELECT order_id FROM __ref("stg_orders")\n '
448+ )
449+ for index in range (_ORDER_SLICE_COUNT )
450+ },
451+ "rules/approved_sources.yaml" : "raw_orders\n raw_customers\n " ,
452+ "rules/orders.py" : """from sqlbuild.rules import Finding, Model, Project, RuleContext, rule
453+
454+
455+ @rule(code="XSQBRORD101", message="Filters hard-code an amount", remediation="Use a constant.")
456+ def high_amount_filters(*, model: Model, ctx: RuleContext) -> list[Finding]:
457+ expanded: str = ctx.sql.for_model(model).expanded.source
458+ return [ctx.finding(subject=model)] if "amount > 9" in expanded else []
459+
460+
461+ @rule(code="XSQBRORD102", message="Staging models need a consumer", remediation="Use it.")
462+ def staging_consumers(*, model: Model, ctx: RuleContext) -> list[Finding]:
463+ if model.path.parent.name != "staging":
464+ return []
465+ return [] if ctx.graph.dependents(model) else [ctx.finding(subject=model)]
466+
467+
468+ @rule(code="XSQBRORD103", message="Marts must enforce a contract", remediation="Enforce it.")
469+ def mart_contracts(*, model: Model, ctx: RuleContext) -> list[Finding]:
470+ if model.path.parent.name != "marts":
471+ return []
472+ return [] if ctx.contracts.enforced(model) else [ctx.finding(subject=model)]
473+
474+
475+ @rule(code="XSQBRORD201", message="Sources must be approved", remediation="Approve it.")
476+ def approved_sources(*, project: Project, ctx: RuleContext) -> list[Finding]:
477+ del project
478+ approved: set[str] = set(ctx.project.tree.read_text("rules/approved_sources.yaml").split())
479+ names: list[str] = sorted(str(getattr(source, "name")) for source in ctx.project.sources)
480+ return [
481+ ctx.finding(subject="sources/raw.yml", message=f"Source {name} is not approved")
482+ for name in names
483+ if name not in approved
484+ ]
485+
486+
487+ @rule(code="XSQBRORD202", message="Marts must stay few", remediation="Consolidate them.")
488+ def few_marts(*, project: Project, ctx: RuleContext) -> list[Finding]:
489+ del project
490+ marts: list[Model] = [item for item in ctx.project.models if item.path.parent.name == "marts"]
491+ if len(marts) < 2:
492+ return []
493+ return [ctx.finding(subject=marts[0], message=f"{len(marts)} marts")]
494+ """ ,
495+ }
496+ STALE_SOURCE_FACTS_SITECUSTOMIZE : str = """from sqlbuild.rule_engine.classes import fact_digests
497+
498+ _digest = fact_digests.FactDigests.digest
499+
500+
501+ def _stale_sources(self, key):
502+ return "stale" if key == ("project.sources",) else _digest(self, key)
503+
504+
505+ fact_digests.FactDigests.digest = _stale_sources
506+ """
507+
508+
509+ def custom_rules_edit_chain (
510+ * , root : Path , edits : tuple [Any , ...], environment : tuple [tuple [str , str ], ...] = ()
511+ ) -> tuple [
512+ tuple [int , str , str ], tuple [tuple [int , str , str ], ...], tuple [tuple [int , str , str ], ...]
513+ ]:
514+ """Compile the custom-rule project cold, then after each edit beside a cache-free oracle."""
515+
516+ project_dir : Path = root / "orders"
517+ write_project_files (project_dir = project_dir , files = CUSTOM_RULES_EDIT_PROJECT )
518+ cold : tuple [int , str , str ] = rules_compile_outcome (project_dir , environment = environment )
519+ edited : list [tuple [int , str , str ]] = []
520+ oracles : list [tuple [int , str , str ]] = []
521+ for step , edit_case in enumerate (edits ):
522+ edit_case .edit (project_dir )
523+ edited .append (rules_compile_outcome (project_dir , environment = environment ))
524+ oracle_dir : Path = root / f"oracle_{ step } "
525+ shutil .copytree (project_dir , oracle_dir , ignore = shutil .ignore_patterns ("target" ))
526+ oracles .append (rules_compile_outcome (oracle_dir , "--no-cache" ))
527+ return cold , tuple (edited ), tuple (oracles )
528+
529+
414530def write_project_files (* , project_dir : Path , files : dict [str , str ]) -> None :
415531 """Write authored project files beneath one project directory."""
416532
@@ -420,7 +536,9 @@ def write_project_files(*, project_dir: Path, files: dict[str, str]) -> None:
420536 path .write_text (contents , encoding = "utf-8" )
421537
422538
423- def rules_compile_outcome (project_dir : Path , * arguments : str ) -> tuple [int , str , str ]:
539+ def rules_compile_outcome (
540+ project_dir : Path , * arguments : str , environment : tuple [tuple [str , str ], ...] = ()
541+ ) -> tuple [int , str , str ]:
424542 """Compile in a fresh process and return its exit code, diagnostics, and artifacts digest."""
425543
426544 result : subprocess .CompletedProcess [str ] = subprocess .run (
@@ -436,6 +554,7 @@ def rules_compile_outcome(project_dir: Path, *arguments: str) -> tuple[int, str,
436554 capture_output = True ,
437555 text = True ,
438556 check = False ,
557+ env = {** os .environ , ** dict (environment )},
439558 )
440559 payload : dict [str , Any ] = json .loads (result .stdout or "{}" )
441560 artifacts : Any = hashlib .sha256 ()
0 commit comments