|
|
@@ -0,0 +1,894 @@
|
|
|
+"""Compile canonical RuleSpecs into closed, portable Polars batch plans."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import copy
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+import re
|
|
|
+from decimal import Decimal, InvalidOperation
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from app.core.common.identifiers import ensure_governance_uid
|
|
|
+from app.core.data_rules.compilers.base import RuleCompiler
|
|
|
+from app.core.data_rules.contracts import read_rule_spec, rule_spec_hash
|
|
|
+from app.core.data_rules.execution_contracts import (
|
|
|
+ canonical_schema_hash,
|
|
|
+ validate_dataset_binding,
|
|
|
+ validate_schema_snapshot,
|
|
|
+)
|
|
|
+from app.core.data_rules.expressions import (
|
|
|
+ backend_support,
|
|
|
+ type_check_expression,
|
|
|
+ validate_rule_expressions,
|
|
|
+)
|
|
|
+
|
|
|
+COMPILER_VERSION = "dataops-polars-1.42.1"
|
|
|
+PLAN_SCHEMA_VERSION = "1.0"
|
|
|
+RESULT_CONTRACT = {
|
|
|
+ "rows_in": "counted",
|
|
|
+ "rows_out": "counted",
|
|
|
+ "rows_rejected": "counted",
|
|
|
+ "violations": "counted",
|
|
|
+}
|
|
|
+SUPPORTED_OPERATIONS = {
|
|
|
+ "aggregate",
|
|
|
+ "assert",
|
|
|
+ "cast",
|
|
|
+ "deduplicate",
|
|
|
+ "derive",
|
|
|
+ "fill_null",
|
|
|
+ "filter",
|
|
|
+ "lookup_join",
|
|
|
+ "map_values",
|
|
|
+ "mask",
|
|
|
+ "normalize_text",
|
|
|
+ "regex_replace",
|
|
|
+}
|
|
|
+SUPPORTED_AGGREGATES = {"count", "max", "mean", "min", "sum"}
|
|
|
+SUPPORTED_MASK_POLICIES = {"preserve_last_4", "redact"}
|
|
|
+_PLAN_KEYS = {
|
|
|
+ "schema_version",
|
|
|
+ "compiler_version",
|
|
|
+ "rule_version_id",
|
|
|
+ "rule_spec_hash",
|
|
|
+ "input_schema_snapshot_id",
|
|
|
+ "input_schema_hash",
|
|
|
+ "input_fields",
|
|
|
+ "output_schema_snapshot_id",
|
|
|
+ "output_schema_hash",
|
|
|
+ "output_fields",
|
|
|
+ "input_binding_id",
|
|
|
+ "input_binding_hash",
|
|
|
+ "output_binding_id",
|
|
|
+ "output_binding_hash",
|
|
|
+ "resource_limits",
|
|
|
+ "operations",
|
|
|
+ "result_contract",
|
|
|
+}
|
|
|
+_LIMIT_KEYS = {"max_rows", "max_artifact_bytes", "memory_limit_bytes"}
|
|
|
+_FORBIDDEN = re.compile(
|
|
|
+ r"\b(?:generated_python|python|pickle(?:d)?|callable|module)\b"
|
|
|
+ r"|(?:file|https?|s3|gs|ftp)://",
|
|
|
+ re.IGNORECASE,
|
|
|
+)
|
|
|
+_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
|
|
+_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
|
+_PORTABLE_REGEX_FORBIDDEN = re.compile(r"\(\?|\\[1-9]|\\[pP]")
|
|
|
+_NUMERIC_TYPES = {"integer", "decimal", "float", "double"}
|
|
|
+_CAST_TYPES = {
|
|
|
+ "boolean",
|
|
|
+ "date",
|
|
|
+ "decimal",
|
|
|
+ "double",
|
|
|
+ "float",
|
|
|
+ "integer",
|
|
|
+ "string",
|
|
|
+ "timestamp",
|
|
|
+ "timestamptz",
|
|
|
+}
|
|
|
+_OPERATION_KEYS = {
|
|
|
+ "aggregate": {"op", "group_by", "aggregations"},
|
|
|
+ "assert": {
|
|
|
+ "op",
|
|
|
+ "expression_ast",
|
|
|
+ "on_failure",
|
|
|
+ "severity",
|
|
|
+ "step_id",
|
|
|
+ },
|
|
|
+ "cast": {"op", "column", "to", "on_error"},
|
|
|
+ "deduplicate": {"op", "keys", "order_by", "keep"},
|
|
|
+ "derive": {"op", "target", "expression_ast"},
|
|
|
+ "fill_null": {"op", "column", "value"},
|
|
|
+ "filter": {"op", "expression_ast"},
|
|
|
+ "lookup_join": {
|
|
|
+ "op",
|
|
|
+ "lookup_binding_id",
|
|
|
+ "lookup_binding_hash",
|
|
|
+ "lookup_schema_snapshot_id",
|
|
|
+ "lookup_schema_hash",
|
|
|
+ "lookup_fields",
|
|
|
+ "left_on",
|
|
|
+ "right_on",
|
|
|
+ "select",
|
|
|
+ "how",
|
|
|
+ },
|
|
|
+ "map_values": {"op", "column", "mapping"},
|
|
|
+ "mask": {"op", "column", "policy_id", "policy_kind"},
|
|
|
+ "normalize_text": {
|
|
|
+ "op",
|
|
|
+ "column",
|
|
|
+ "trim",
|
|
|
+ "lowercase",
|
|
|
+ "uppercase",
|
|
|
+ },
|
|
|
+ "regex_replace": {"op", "column", "pattern", "replacement"},
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def _canonical_json(value: Any) -> str:
|
|
|
+ try:
|
|
|
+ return json.dumps(
|
|
|
+ value,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ ensure_ascii=False,
|
|
|
+ )
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise ValueError("bound Polars plan must be JSON serializable") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _hash(value: Any) -> str:
|
|
|
+ return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def _uid(value: Any, label: str) -> str:
|
|
|
+ try:
|
|
|
+ return ensure_governance_uid({"uid": str(value)})
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ValueError(f"{label} must be a valid UUIDv7") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _identifier(value: Any, label: str) -> str:
|
|
|
+ normalized = str(value or "")
|
|
|
+ if _IDENTIFIER.fullmatch(normalized) is None:
|
|
|
+ raise ValueError(f"{label} must be an identifier")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _snapshot(value: Any, label: str) -> dict[str, Any]:
|
|
|
+ if not isinstance(value, dict):
|
|
|
+ raise ValueError(f"{label} must be an object")
|
|
|
+ snapshot_id = _uid(value.get("id"), f"{label} id")
|
|
|
+ normalized = validate_schema_snapshot(
|
|
|
+ {key: item for key, item in value.items() if key != "id"}
|
|
|
+ )
|
|
|
+ return {"id": snapshot_id, **normalized}
|
|
|
+
|
|
|
+
|
|
|
+def _binding(value: Any, label: str) -> dict[str, Any]:
|
|
|
+ if not isinstance(value, dict):
|
|
|
+ raise ValueError(f"{label} must be an object")
|
|
|
+ binding_id = _uid(value.get("id"), f"{label} id")
|
|
|
+ binding_hash = value.get("binding_hash")
|
|
|
+ if binding_hash is not None and _DIGEST.fullmatch(
|
|
|
+ str(binding_hash)
|
|
|
+ ) is None:
|
|
|
+ raise ValueError(f"{label} binding_hash must be a sha256 digest")
|
|
|
+ normalized = validate_dataset_binding(
|
|
|
+ {
|
|
|
+ key: item
|
|
|
+ for key, item in value.items()
|
|
|
+ if key not in {"id", "binding_hash"}
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "id": binding_id,
|
|
|
+ **normalized,
|
|
|
+ **({"binding_hash": str(binding_hash)} if binding_hash else {}),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def bound_dataset_binding_hash(value: Any) -> str:
|
|
|
+ """Hash a canonical deployment binding without exposing it in the plan."""
|
|
|
+
|
|
|
+ binding = _binding(value, "dataset binding")
|
|
|
+ return binding.get("binding_hash") or _hash(binding)
|
|
|
+
|
|
|
+
|
|
|
+def _reject_forbidden(value: Any) -> None:
|
|
|
+ if isinstance(value, dict):
|
|
|
+ for key, item in value.items():
|
|
|
+ _reject_forbidden(key)
|
|
|
+ _reject_forbidden(item)
|
|
|
+ elif isinstance(value, list):
|
|
|
+ for item in value:
|
|
|
+ _reject_forbidden(item)
|
|
|
+ elif isinstance(value, str) and _FORBIDDEN.search(value):
|
|
|
+ raise ValueError("bound Polars plan contains forbidden runtime content")
|
|
|
+
|
|
|
+
|
|
|
+def _closed(value: Any, keys: set[str], label: str) -> dict[str, Any]:
|
|
|
+ if not isinstance(value, dict):
|
|
|
+ raise ValueError(f"{label} must be an object")
|
|
|
+ if set(value) != keys:
|
|
|
+ raise ValueError(f"{label} must have a closed shape")
|
|
|
+ return copy.deepcopy(value)
|
|
|
+
|
|
|
+
|
|
|
+def _limits(value: Any) -> dict[str, int]:
|
|
|
+ result = _closed(value, _LIMIT_KEYS, "Polars resource limits")
|
|
|
+ for key, minimum, maximum in (
|
|
|
+ ("max_rows", 1, 10_000_000),
|
|
|
+ ("max_artifact_bytes", 1024, 2 * 1024 * 1024 * 1024),
|
|
|
+ ("memory_limit_bytes", 16 * 1024 * 1024, 16 * 1024 * 1024 * 1024),
|
|
|
+ ):
|
|
|
+ item = result[key]
|
|
|
+ if isinstance(item, bool) or not isinstance(item, int):
|
|
|
+ raise ValueError(f"{key} must be an integer")
|
|
|
+ if item < minimum or item > maximum:
|
|
|
+ raise ValueError(f"{key} is outside the supported range")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _backend(value: Any) -> dict[str, Any]:
|
|
|
+ keys = {
|
|
|
+ "max_rows",
|
|
|
+ "max_artifact_bytes",
|
|
|
+ "memory_limit_bytes",
|
|
|
+ "masking_policies",
|
|
|
+ "lookup_bindings",
|
|
|
+ }
|
|
|
+ result = _closed(value, keys, "Polars backend capabilities")
|
|
|
+ result["resource_limits"] = _limits(
|
|
|
+ {key: result.pop(key) for key in _LIMIT_KEYS}
|
|
|
+ )
|
|
|
+ policies = result["masking_policies"]
|
|
|
+ if not isinstance(policies, dict) or len(policies) > 100:
|
|
|
+ raise ValueError("masking policies must be a bounded object")
|
|
|
+ normalized_policies = {}
|
|
|
+ for policy_id, policy_kind in policies.items():
|
|
|
+ identifier = _identifier(policy_id, "masking policy id")
|
|
|
+ if policy_kind not in SUPPORTED_MASK_POLICIES:
|
|
|
+ raise ValueError("masking policy kind is unsupported")
|
|
|
+ normalized_policies[identifier] = policy_kind
|
|
|
+ result["masking_policies"] = normalized_policies
|
|
|
+ if not isinstance(result["lookup_bindings"], dict) or len(
|
|
|
+ result["lookup_bindings"]
|
|
|
+ ) > 100:
|
|
|
+ raise ValueError("lookup bindings must be a bounded object")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _fields(snapshot: dict[str, Any]) -> dict[str, str]:
|
|
|
+ return {field["name"]: field["type"] for field in snapshot["fields"]}
|
|
|
+
|
|
|
+
|
|
|
+def _field_list(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
+ return copy.deepcopy(snapshot["fields"])
|
|
|
+
|
|
|
+
|
|
|
+def _value_matches_type(value: Any, field_type: str) -> bool:
|
|
|
+ if value is None:
|
|
|
+ return True
|
|
|
+ if field_type == "string":
|
|
|
+ return isinstance(value, str)
|
|
|
+ if field_type == "boolean":
|
|
|
+ return isinstance(value, bool)
|
|
|
+ if field_type == "integer":
|
|
|
+ return isinstance(value, int) and not isinstance(value, bool)
|
|
|
+ if field_type in _NUMERIC_TYPES:
|
|
|
+ if isinstance(value, bool):
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ Decimal(str(value))
|
|
|
+ return True
|
|
|
+ except (InvalidOperation, TypeError, ValueError):
|
|
|
+ return False
|
|
|
+ if field_type in {"date", "timestamp", "timestamptz"}:
|
|
|
+ return isinstance(value, str)
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _portable_regex(value: Any) -> str:
|
|
|
+ if not isinstance(value, str) or not value or len(value) > 500:
|
|
|
+ raise ValueError("regex pattern is invalid")
|
|
|
+ if _PORTABLE_REGEX_FORBIDDEN.search(value):
|
|
|
+ raise ValueError("regex pattern is outside the portable subset")
|
|
|
+ try:
|
|
|
+ re.compile(value)
|
|
|
+ except re.error as exc:
|
|
|
+ raise ValueError("regex pattern is invalid") from exc
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _string_list(
|
|
|
+ value: Any, label: str, *, non_empty: bool = True
|
|
|
+) -> list[str]:
|
|
|
+ if not isinstance(value, list) or len(value) > 100:
|
|
|
+ raise ValueError(f"{label} must be a bounded array")
|
|
|
+ if non_empty and not value:
|
|
|
+ raise ValueError(f"{label} must not be empty")
|
|
|
+ result = [_identifier(item, f"{label} item") for item in value]
|
|
|
+ if len(result) != len(set(result)):
|
|
|
+ raise ValueError(f"{label} must not contain duplicates")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _operation(value: Any) -> dict[str, Any]:
|
|
|
+ if not isinstance(value, dict):
|
|
|
+ raise ValueError("Polars operation must be an object")
|
|
|
+ operation = value.get("op")
|
|
|
+ keys = _OPERATION_KEYS.get(operation)
|
|
|
+ if keys is None:
|
|
|
+ raise ValueError("unsupported Polars operation")
|
|
|
+ result = _closed(value, keys, f"Polars {operation} operation")
|
|
|
+ _reject_forbidden(result)
|
|
|
+ for key in (
|
|
|
+ "column",
|
|
|
+ "target",
|
|
|
+ "policy_id",
|
|
|
+ "step_id",
|
|
|
+ ):
|
|
|
+ if key in result:
|
|
|
+ result[key] = _identifier(result[key], f"{operation} {key}")
|
|
|
+ if operation == "normalize_text":
|
|
|
+ flags = [
|
|
|
+ result["trim"],
|
|
|
+ result["lowercase"],
|
|
|
+ result["uppercase"],
|
|
|
+ ]
|
|
|
+ if not all(isinstance(item, bool) for item in flags):
|
|
|
+ raise ValueError("normalize_text flags must be boolean")
|
|
|
+ if not any(flags) or (result["lowercase"] and result["uppercase"]):
|
|
|
+ raise ValueError("normalize_text operation is invalid")
|
|
|
+ if (
|
|
|
+ operation in {"assert", "derive", "filter"}
|
|
|
+ and "polars" not in backend_support(result["expression_ast"])
|
|
|
+ ):
|
|
|
+ raise ValueError("expression is unsupported by Polars")
|
|
|
+ if operation == "assert" and (
|
|
|
+ result["on_failure"] != "reject"
|
|
|
+ or result["severity"]
|
|
|
+ not in {
|
|
|
+ "info",
|
|
|
+ "warning",
|
|
|
+ "error",
|
|
|
+ "critical",
|
|
|
+ }
|
|
|
+ ):
|
|
|
+ raise ValueError("assert operation is invalid")
|
|
|
+ if operation == "cast" and (
|
|
|
+ result["to"] not in _CAST_TYPES or result["on_error"] != "fail"
|
|
|
+ ):
|
|
|
+ raise ValueError("cast operation is invalid")
|
|
|
+ if operation == "deduplicate":
|
|
|
+ _string_list(result["keys"], "deduplicate keys")
|
|
|
+ _string_list(result["order_by"], "deduplicate order_by")
|
|
|
+ if result["keep"] not in {"first", "last"}:
|
|
|
+ raise ValueError("deduplicate operation is invalid")
|
|
|
+ if operation == "regex_replace":
|
|
|
+ _portable_regex(result["pattern"])
|
|
|
+ if not isinstance(result["replacement"], str):
|
|
|
+ raise ValueError("regex replacement must be a string")
|
|
|
+ if operation == "map_values" and (
|
|
|
+ not isinstance(result["mapping"], dict)
|
|
|
+ or not result["mapping"]
|
|
|
+ or not all(
|
|
|
+ isinstance(key, str) and isinstance(item, str)
|
|
|
+ for key, item in result["mapping"].items()
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise ValueError("map_values operation is invalid")
|
|
|
+ if operation == "mask" and result["policy_kind"] not in (
|
|
|
+ SUPPORTED_MASK_POLICIES
|
|
|
+ ):
|
|
|
+ raise ValueError("mask operation is invalid")
|
|
|
+ if operation == "aggregate":
|
|
|
+ _string_list(result["group_by"], "aggregate group_by")
|
|
|
+ if not isinstance(result["aggregations"], list) or not result[
|
|
|
+ "aggregations"
|
|
|
+ ]:
|
|
|
+ raise ValueError("aggregate operation is invalid")
|
|
|
+ for aggregate in result["aggregations"]:
|
|
|
+ if not isinstance(aggregate, dict) or set(aggregate) != {
|
|
|
+ "target",
|
|
|
+ "function",
|
|
|
+ "column",
|
|
|
+ }:
|
|
|
+ raise ValueError("aggregate operation is invalid")
|
|
|
+ _identifier(aggregate["target"], "aggregate target")
|
|
|
+ _identifier(aggregate["column"], "aggregate column")
|
|
|
+ if aggregate["function"] not in SUPPORTED_AGGREGATES:
|
|
|
+ raise ValueError("aggregate operation is invalid")
|
|
|
+ if operation == "lookup_join":
|
|
|
+ for key in (
|
|
|
+ "lookup_binding_hash",
|
|
|
+ "lookup_schema_hash",
|
|
|
+ ):
|
|
|
+ if _DIGEST.fullmatch(str(result[key] or "")) is None:
|
|
|
+ raise ValueError(f"{key} must be a sha256 digest")
|
|
|
+ _uid(result["lookup_binding_id"], "lookup_binding_id")
|
|
|
+ _uid(result["lookup_schema_snapshot_id"], "lookup_schema_snapshot_id")
|
|
|
+ if canonical_schema_hash(result["lookup_fields"]) != result[
|
|
|
+ "lookup_schema_hash"
|
|
|
+ ]:
|
|
|
+ raise ValueError("lookup schema fields do not match their hash")
|
|
|
+ left_on = _string_list(result["left_on"], "lookup left_on")
|
|
|
+ right_on = _string_list(result["right_on"], "lookup right_on")
|
|
|
+ if len(left_on) != len(right_on):
|
|
|
+ raise ValueError("lookup join keys must have equal lengths")
|
|
|
+ if result["how"] not in {"inner", "left"}:
|
|
|
+ raise ValueError("lookup join operation is invalid")
|
|
|
+ if (
|
|
|
+ not isinstance(result["select"], dict)
|
|
|
+ or not result["select"]
|
|
|
+ or not all(
|
|
|
+ _IDENTIFIER.fullmatch(str(key or ""))
|
|
|
+ and _IDENTIFIER.fullmatch(str(item or ""))
|
|
|
+ for key, item in result["select"].items()
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise ValueError("lookup select is invalid")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def validate_bound_polars_plan(value: Any) -> dict[str, Any]:
|
|
|
+ plan = _closed(value, _PLAN_KEYS, "bound Polars plan")
|
|
|
+ if plan["schema_version"] != PLAN_SCHEMA_VERSION:
|
|
|
+ raise ValueError("unsupported bound Polars plan schema version")
|
|
|
+ if plan["compiler_version"] != COMPILER_VERSION:
|
|
|
+ raise ValueError("unsupported bound Polars compiler version")
|
|
|
+ for key in (
|
|
|
+ "rule_version_id",
|
|
|
+ "input_schema_snapshot_id",
|
|
|
+ "output_schema_snapshot_id",
|
|
|
+ "input_binding_id",
|
|
|
+ "output_binding_id",
|
|
|
+ ):
|
|
|
+ plan[key] = _uid(plan[key], key)
|
|
|
+ for key in (
|
|
|
+ "rule_spec_hash",
|
|
|
+ "input_schema_hash",
|
|
|
+ "output_schema_hash",
|
|
|
+ "input_binding_hash",
|
|
|
+ "output_binding_hash",
|
|
|
+ ):
|
|
|
+ if _DIGEST.fullmatch(str(plan[key] or "")) is None:
|
|
|
+ raise ValueError(f"{key} must be a sha256 digest")
|
|
|
+ for prefix in ("input", "output"):
|
|
|
+ fields = plan[f"{prefix}_fields"]
|
|
|
+ if canonical_schema_hash(fields) != plan[f"{prefix}_schema_hash"]:
|
|
|
+ raise ValueError(f"{prefix} schema fields do not match their hash")
|
|
|
+ plan["resource_limits"] = _limits(plan["resource_limits"])
|
|
|
+ raw_operations = plan["operations"]
|
|
|
+ if (
|
|
|
+ not isinstance(raw_operations, list)
|
|
|
+ or not raw_operations
|
|
|
+ or len(raw_operations) > 200
|
|
|
+ ):
|
|
|
+ raise ValueError("Polars operations must be a non-empty bounded array")
|
|
|
+ plan["operations"] = [_operation(item) for item in raw_operations]
|
|
|
+ if plan["result_contract"] != RESULT_CONTRACT:
|
|
|
+ raise ValueError("bound Polars result contract is unsupported")
|
|
|
+ _reject_forbidden(plan)
|
|
|
+ _canonical_json(plan)
|
|
|
+ return plan
|
|
|
+
|
|
|
+
|
|
|
+def bound_polars_plan_hash(value: Any) -> str:
|
|
|
+ return _hash(validate_bound_polars_plan(value))
|
|
|
+
|
|
|
+
|
|
|
+class PolarsRuleCompiler(RuleCompiler):
|
|
|
+ """Compile only allowlisted operators and canonical server-owned context."""
|
|
|
+
|
|
|
+ def compile(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ rule_version: dict[str, Any],
|
|
|
+ input_schema: dict[str, Any],
|
|
|
+ output_schema: dict[str, Any],
|
|
|
+ input_binding: dict[str, Any],
|
|
|
+ output_binding: dict[str, Any],
|
|
|
+ backend: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ if not isinstance(rule_version, dict) or rule_version.get(
|
|
|
+ "status"
|
|
|
+ ) != "published":
|
|
|
+ raise ValueError("only published rule versions may be compiled")
|
|
|
+ rule_version_id = _uid(rule_version.get("id"), "rule_version_id")
|
|
|
+ spec = read_rule_spec(rule_version.get("rule_spec"))
|
|
|
+ if rule_version.get("spec_hash") != rule_spec_hash(spec):
|
|
|
+ raise ValueError("published rule version spec hash does not match")
|
|
|
+ source_schema = _snapshot(input_schema, "input schema")
|
|
|
+ target_schema = _snapshot(output_schema, "output schema")
|
|
|
+ source_binding = _binding(input_binding, "input binding")
|
|
|
+ target_binding = _binding(output_binding, "output binding")
|
|
|
+ capabilities = _backend(backend)
|
|
|
+ if (
|
|
|
+ spec["input_schema_ref"] != source_schema["schema_ref"]
|
|
|
+ or spec["output_schema_ref"] != target_schema["schema_ref"]
|
|
|
+ ):
|
|
|
+ raise ValueError("rule schema references do not match snapshots")
|
|
|
+ if (
|
|
|
+ source_binding["schema_snapshot_id"] != source_schema["id"]
|
|
|
+ or target_binding["schema_snapshot_id"] != target_schema["id"]
|
|
|
+ ):
|
|
|
+ raise ValueError("binding schema snapshot does not match")
|
|
|
+ if source_binding["access_mode"] not in {"read", "read_write"}:
|
|
|
+ raise ValueError("input binding is not readable")
|
|
|
+ if target_binding["access_mode"] not in {"write", "read_write"}:
|
|
|
+ raise ValueError("output binding is not writable")
|
|
|
+ if (
|
|
|
+ source_binding["object_kind"] != "parquet_artifact"
|
|
|
+ or target_binding["object_kind"] != "parquet_artifact"
|
|
|
+ ):
|
|
|
+ raise ValueError("Polars batch rules require Parquet artifact bindings")
|
|
|
+ field_types = _fields(source_schema)
|
|
|
+ supported = validate_rule_expressions(spec, field_types)
|
|
|
+ if "polars" not in supported:
|
|
|
+ raise ValueError("rule expressions are unsupported by polars")
|
|
|
+ output_types = _fields(target_schema)
|
|
|
+ current_fields = list(field_types)
|
|
|
+ operations = []
|
|
|
+
|
|
|
+ for step in spec["steps"]:
|
|
|
+ op = step["op"]
|
|
|
+ if op not in SUPPORTED_OPERATIONS:
|
|
|
+ raise ValueError(f"unsupported Polars rule operation: {op}")
|
|
|
+ if op in {
|
|
|
+ "cast",
|
|
|
+ "fill_null",
|
|
|
+ "map_values",
|
|
|
+ "mask",
|
|
|
+ "normalize_text",
|
|
|
+ "regex_replace",
|
|
|
+ }:
|
|
|
+ column = _identifier(step.get("column"), f"{op} column")
|
|
|
+ if column not in field_types:
|
|
|
+ raise ValueError(f"unknown Polars rule column: {column}")
|
|
|
+ if op == "normalize_text":
|
|
|
+ if field_types[column] != "string":
|
|
|
+ raise ValueError("normalize_text requires a string field type")
|
|
|
+ trim = bool(step.get("trim", False))
|
|
|
+ lowercase = bool(step.get("lowercase", False))
|
|
|
+ uppercase = bool(step.get("uppercase", False))
|
|
|
+ if lowercase and uppercase:
|
|
|
+ raise ValueError("text cannot be lowercased and uppercased")
|
|
|
+ if not (trim or lowercase or uppercase):
|
|
|
+ raise ValueError("normalize_text has no operation")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "column": column,
|
|
|
+ "trim": trim,
|
|
|
+ "lowercase": lowercase,
|
|
|
+ "uppercase": uppercase,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ elif op == "regex_replace":
|
|
|
+ if field_types[column] != "string":
|
|
|
+ raise ValueError("regex_replace requires a string field type")
|
|
|
+ replacement = step.get("replacement")
|
|
|
+ if not isinstance(replacement, str):
|
|
|
+ raise ValueError("regex replacement must be a string")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "column": column,
|
|
|
+ "pattern": _portable_regex(step.get("pattern")),
|
|
|
+ "replacement": replacement,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ elif op == "fill_null":
|
|
|
+ if not _value_matches_type(step.get("value"), field_types[column]):
|
|
|
+ raise ValueError("fill_null value does not match the field type")
|
|
|
+ operations.append(
|
|
|
+ {"op": op, "column": column, "value": step.get("value")}
|
|
|
+ )
|
|
|
+ elif op in {"assert", "filter"}:
|
|
|
+ result_type = type_check_expression(
|
|
|
+ step["expression_ast"], field_types
|
|
|
+ )
|
|
|
+ if result_type != "boolean":
|
|
|
+ raise ValueError(f"{op} expression must return boolean")
|
|
|
+ operation = {
|
|
|
+ "op": op,
|
|
|
+ "expression_ast": copy.deepcopy(step["expression_ast"]),
|
|
|
+ }
|
|
|
+ if op == "assert":
|
|
|
+ if step.get("on_failure") != "reject":
|
|
|
+ raise ValueError(
|
|
|
+ "Polars assert currently supports reject only"
|
|
|
+ )
|
|
|
+ operation.update(
|
|
|
+ {
|
|
|
+ "on_failure": "reject",
|
|
|
+ "severity": step.get("severity", "error"),
|
|
|
+ "step_id": step["id"],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ operations.append(operation)
|
|
|
+ elif op == "derive":
|
|
|
+ target = _identifier(step.get("target"), "derive target")
|
|
|
+ if target not in output_types:
|
|
|
+ raise ValueError("derive target is not in the output schema")
|
|
|
+ result_type = type_check_expression(
|
|
|
+ step["expression_ast"], field_types
|
|
|
+ )
|
|
|
+ if result_type != output_types[target]:
|
|
|
+ raise ValueError(
|
|
|
+ "derive expression type does not match its target type"
|
|
|
+ )
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "target": target,
|
|
|
+ "expression_ast": copy.deepcopy(
|
|
|
+ step["expression_ast"]
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ field_types[target] = result_type
|
|
|
+ if target not in current_fields:
|
|
|
+ current_fields.append(target)
|
|
|
+ elif op == "map_values":
|
|
|
+ if field_types[column] != "string":
|
|
|
+ raise ValueError("map_values requires a string field type")
|
|
|
+ mapping = step.get("mapping")
|
|
|
+ if (
|
|
|
+ not isinstance(mapping, dict)
|
|
|
+ or not mapping
|
|
|
+ or len(mapping) > 1_000
|
|
|
+ or not all(
|
|
|
+ isinstance(key, str) and isinstance(value, str)
|
|
|
+ for key, value in mapping.items()
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise ValueError("map_values requires a string mapping")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "column": column,
|
|
|
+ "mapping": dict(sorted(mapping.items())),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ elif op == "cast":
|
|
|
+ if step.get("on_error", "fail") != "fail":
|
|
|
+ raise ValueError("Polars cast supports only fail on_error")
|
|
|
+ target_type = str(step.get("to") or "").lower()
|
|
|
+ if target_type not in _CAST_TYPES:
|
|
|
+ raise ValueError("unsupported Polars cast target")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "column": column,
|
|
|
+ "to": target_type,
|
|
|
+ "on_error": "fail",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ field_types[column] = target_type
|
|
|
+ elif op == "deduplicate":
|
|
|
+ keys = _string_list(step.get("keys"), "deduplicate keys")
|
|
|
+ order_by = _string_list(
|
|
|
+ step.get("order_by"), "deduplicate order_by"
|
|
|
+ )
|
|
|
+ if not set(keys + order_by) <= set(current_fields):
|
|
|
+ raise ValueError("deduplicate references unknown fields")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "keys": keys,
|
|
|
+ "order_by": order_by,
|
|
|
+ "keep": step.get("keep", "first"),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ elif op == "mask":
|
|
|
+ if field_types[column] != "string":
|
|
|
+ raise ValueError("mask requires a string field type")
|
|
|
+ policy_id = _identifier(step.get("policy"), "masking policy id")
|
|
|
+ policy_kind = capabilities["masking_policies"].get(policy_id)
|
|
|
+ if policy_kind is None:
|
|
|
+ raise ValueError("masking policy is not registered")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "column": column,
|
|
|
+ "policy_id": policy_id,
|
|
|
+ "policy_kind": policy_kind,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ elif op == "aggregate":
|
|
|
+ group_by = _string_list(step.get("group_by"), "aggregate group_by")
|
|
|
+ if not set(group_by) <= set(current_fields):
|
|
|
+ raise ValueError("aggregate references unknown group fields")
|
|
|
+ raw_aggregations = step.get("aggregations")
|
|
|
+ if (
|
|
|
+ not isinstance(raw_aggregations, dict)
|
|
|
+ or not raw_aggregations
|
|
|
+ or len(raw_aggregations) > 100
|
|
|
+ ):
|
|
|
+ raise ValueError("aggregations must be a bounded object")
|
|
|
+ aggregations = []
|
|
|
+ next_types = {name: field_types[name] for name in group_by}
|
|
|
+ for target, definition in sorted(raw_aggregations.items()):
|
|
|
+ target = _identifier(target, "aggregate target")
|
|
|
+ if not isinstance(definition, dict) or set(definition) != {
|
|
|
+ "function",
|
|
|
+ "column",
|
|
|
+ }:
|
|
|
+ raise ValueError(
|
|
|
+ "aggregate definition must have a closed shape"
|
|
|
+ )
|
|
|
+ function = definition["function"]
|
|
|
+ column_name = _identifier(
|
|
|
+ definition["column"], "aggregate column"
|
|
|
+ )
|
|
|
+ if function not in SUPPORTED_AGGREGATES:
|
|
|
+ raise ValueError("aggregate function is unsupported")
|
|
|
+ if column_name not in field_types:
|
|
|
+ raise ValueError("aggregate column is unknown")
|
|
|
+ result_type = (
|
|
|
+ "integer"
|
|
|
+ if function == "count"
|
|
|
+ else (
|
|
|
+ "double"
|
|
|
+ if function == "mean"
|
|
|
+ else field_types[column_name]
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if output_types.get(target) != result_type:
|
|
|
+ raise ValueError(
|
|
|
+ "aggregate result type does not match output schema"
|
|
|
+ )
|
|
|
+ next_types[target] = result_type
|
|
|
+ aggregations.append(
|
|
|
+ {
|
|
|
+ "target": target,
|
|
|
+ "function": function,
|
|
|
+ "column": column_name,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "group_by": group_by,
|
|
|
+ "aggregations": aggregations,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ field_types = next_types
|
|
|
+ current_fields = [*group_by, *sorted(raw_aggregations)]
|
|
|
+ elif op == "lookup_join":
|
|
|
+ lookup = step.get("lookup")
|
|
|
+ lookup_keys = {
|
|
|
+ "binding_id",
|
|
|
+ "left_on",
|
|
|
+ "right_on",
|
|
|
+ "select",
|
|
|
+ "how",
|
|
|
+ }
|
|
|
+ if not isinstance(lookup, dict) or set(lookup) != lookup_keys:
|
|
|
+ raise ValueError("lookup_join lookup must have a closed shape")
|
|
|
+ binding_id = _uid(
|
|
|
+ lookup["binding_id"], "lookup binding id"
|
|
|
+ )
|
|
|
+ context = capabilities["lookup_bindings"].get(binding_id)
|
|
|
+ if not isinstance(context, dict) or set(context) != {
|
|
|
+ "binding",
|
|
|
+ "schema",
|
|
|
+ }:
|
|
|
+ raise ValueError(
|
|
|
+ "canonical lookup binding was not resolved server side"
|
|
|
+ )
|
|
|
+ lookup_binding = _binding(
|
|
|
+ context["binding"], "lookup binding"
|
|
|
+ )
|
|
|
+ lookup_schema = _snapshot(context["schema"], "lookup schema")
|
|
|
+ if (
|
|
|
+ lookup_binding["id"] != binding_id
|
|
|
+ or lookup_binding["schema_snapshot_id"]
|
|
|
+ != lookup_schema["id"]
|
|
|
+ or lookup_binding["access_mode"]
|
|
|
+ not in {"read", "read_write"}
|
|
|
+ or lookup_binding["object_kind"] != "parquet_artifact"
|
|
|
+ ):
|
|
|
+ raise ValueError("canonical lookup binding is invalid")
|
|
|
+ left_on = _string_list(lookup["left_on"], "lookup left_on")
|
|
|
+ right_on = _string_list(lookup["right_on"], "lookup right_on")
|
|
|
+ if len(left_on) != len(right_on):
|
|
|
+ raise ValueError("lookup join keys must have equal lengths")
|
|
|
+ lookup_types = _fields(lookup_schema)
|
|
|
+ if (
|
|
|
+ not set(left_on) <= set(field_types)
|
|
|
+ or not set(right_on) <= set(lookup_types)
|
|
|
+ or any(
|
|
|
+ field_types[left] != lookup_types[right]
|
|
|
+ for left, right in zip(
|
|
|
+ left_on, right_on, strict=True
|
|
|
+ )
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise ValueError("lookup join key types do not match")
|
|
|
+ select = lookup["select"]
|
|
|
+ if (
|
|
|
+ not isinstance(select, dict)
|
|
|
+ or not select
|
|
|
+ or len(select) > 100
|
|
|
+ ):
|
|
|
+ raise ValueError("lookup select must be a bounded object")
|
|
|
+ normalized_select = {}
|
|
|
+ for target, source in sorted(select.items()):
|
|
|
+ target = _identifier(target, "lookup target")
|
|
|
+ source = _identifier(source, "lookup source")
|
|
|
+ if source not in lookup_types or target not in output_types:
|
|
|
+ raise ValueError("lookup select field is unknown")
|
|
|
+ if lookup_types[source] != output_types[target]:
|
|
|
+ raise ValueError(
|
|
|
+ "lookup selected type does not match output schema"
|
|
|
+ )
|
|
|
+ normalized_select[target] = source
|
|
|
+ field_types[target] = lookup_types[source]
|
|
|
+ if target not in current_fields:
|
|
|
+ current_fields.append(target)
|
|
|
+ if lookup["how"] not in {"inner", "left"}:
|
|
|
+ raise ValueError("lookup join kind is unsupported")
|
|
|
+ operations.append(
|
|
|
+ {
|
|
|
+ "op": op,
|
|
|
+ "lookup_binding_id": lookup_binding["id"],
|
|
|
+ "lookup_binding_hash": (
|
|
|
+ lookup_binding.get("binding_hash")
|
|
|
+ or _hash(lookup_binding)
|
|
|
+ ),
|
|
|
+ "lookup_schema_snapshot_id": lookup_schema["id"],
|
|
|
+ "lookup_schema_hash": lookup_schema["schema_hash"],
|
|
|
+ "lookup_fields": _field_list(lookup_schema),
|
|
|
+ "left_on": left_on,
|
|
|
+ "right_on": right_on,
|
|
|
+ "select": normalized_select,
|
|
|
+ "how": lookup["how"],
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ output_names = [field["name"] for field in target_schema["fields"]]
|
|
|
+ if not set(output_names) <= set(current_fields):
|
|
|
+ raise ValueError("compiled Polars plan cannot produce the output schema")
|
|
|
+ if any(field_types[name] != output_types[name] for name in output_names):
|
|
|
+ raise ValueError(
|
|
|
+ "compiled Polars field types do not match the output schema"
|
|
|
+ )
|
|
|
+ plan = validate_bound_polars_plan(
|
|
|
+ {
|
|
|
+ "schema_version": PLAN_SCHEMA_VERSION,
|
|
|
+ "compiler_version": COMPILER_VERSION,
|
|
|
+ "rule_version_id": rule_version_id,
|
|
|
+ "rule_spec_hash": rule_version["spec_hash"],
|
|
|
+ "input_schema_snapshot_id": source_schema["id"],
|
|
|
+ "input_schema_hash": source_schema["schema_hash"],
|
|
|
+ "input_fields": _field_list(source_schema),
|
|
|
+ "output_schema_snapshot_id": target_schema["id"],
|
|
|
+ "output_schema_hash": target_schema["schema_hash"],
|
|
|
+ "output_fields": _field_list(target_schema),
|
|
|
+ "input_binding_id": source_binding["id"],
|
|
|
+ "input_binding_hash": (
|
|
|
+ source_binding.get("binding_hash")
|
|
|
+ or _hash(source_binding)
|
|
|
+ ),
|
|
|
+ "output_binding_id": target_binding["id"],
|
|
|
+ "output_binding_hash": (
|
|
|
+ target_binding.get("binding_hash")
|
|
|
+ or _hash(target_binding)
|
|
|
+ ),
|
|
|
+ "resource_limits": capabilities["resource_limits"],
|
|
|
+ "operations": operations,
|
|
|
+ "result_contract": RESULT_CONTRACT,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "backend": "polars_batch",
|
|
|
+ "compiler_version": COMPILER_VERSION,
|
|
|
+ "plan": plan,
|
|
|
+ "plan_hash": bound_polars_plan_hash(plan),
|
|
|
+ "status": "compiled",
|
|
|
+ }
|