|
|
@@ -8,9 +8,12 @@ runtime. Compilers consume the resulting AST in a later release.
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
-import math
|
|
|
+from datetime import date as Date
|
|
|
+from datetime import datetime
|
|
|
+from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
|
|
import re
|
|
|
from typing import Any
|
|
|
+from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
MAX_SOURCE_LENGTH = 20_000
|
|
|
@@ -32,6 +35,21 @@ ALLOWED_FUNCTIONS = frozenset(
|
|
|
}
|
|
|
)
|
|
|
SUPPORTED_BACKENDS = frozenset({"postgresql", "mysql", "polars"})
|
|
|
+_FUNCTION_BACKENDS = {
|
|
|
+ "matches": SUPPORTED_BACKENDS,
|
|
|
+ "lower": SUPPORTED_BACKENDS,
|
|
|
+ "upper": SUPPORTED_BACKENDS,
|
|
|
+ "trim": SUPPORTED_BACKENDS,
|
|
|
+ "length": SUPPORTED_BACKENDS,
|
|
|
+ "coalesce": SUPPORTED_BACKENDS,
|
|
|
+ "date": SUPPORTED_BACKENDS,
|
|
|
+ # MySQL timezone conversion depends on server timezone tables; do not
|
|
|
+ # advertise it before a deployment can prove that dependency.
|
|
|
+ "timestamp": frozenset({"postgresql", "polars"}),
|
|
|
+ "abs": SUPPORTED_BACKENDS,
|
|
|
+ # Polars rounding mode cannot be assumed to match the V1 decimal policy.
|
|
|
+ "round": frozenset({"postgresql", "mysql"}),
|
|
|
+}
|
|
|
|
|
|
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
|
|
_FIELD_TYPE_ALIASES = {
|
|
|
@@ -59,6 +77,7 @@ _FIELD_TYPES = {
|
|
|
}
|
|
|
_NUMERIC_TYPES = {"integer", "decimal", "float", "double"}
|
|
|
_LITERAL_TYPES = {"boolean", "decimal", "integer", "null", "string"}
|
|
|
+_DECIMAL_LEXEME = re.compile(r"^[0-9]+\.[0-9]+$")
|
|
|
_BINARY_OPERATORS = {
|
|
|
"||",
|
|
|
"&&",
|
|
|
@@ -177,9 +196,9 @@ class _Lexer:
|
|
|
if self.position == decimal_start:
|
|
|
raise _error(start, "contains an invalid numeric literal")
|
|
|
raw = self.source[start : self.position]
|
|
|
- value: int | float = int(raw) if kind == "integer" else float(raw)
|
|
|
- if isinstance(value, float) and not math.isfinite(value):
|
|
|
- raise _error(start, "contains a non-finite numeric literal")
|
|
|
+ # Decimal tokens keep their lexeme in the JSON AST. A float here
|
|
|
+ # would destroy source precision before a compiler receives it.
|
|
|
+ value: int | str = int(raw) if kind == "integer" else raw
|
|
|
return _Token("LITERAL", (kind, value), start)
|
|
|
|
|
|
def _identifier(self) -> _Token:
|
|
|
@@ -246,6 +265,11 @@ class _Parser:
|
|
|
left = {"kind": "literal", "type": literal_type, "value": value}
|
|
|
elif token.kind == "IDENT":
|
|
|
if self.current.kind == "(":
|
|
|
+ if token.value not in ALLOWED_FUNCTIONS:
|
|
|
+ raise _error(
|
|
|
+ token.position,
|
|
|
+ f"uses unsupported expression function {token.value!r}",
|
|
|
+ )
|
|
|
self.index += 1
|
|
|
arguments: list[dict[str, Any]] = []
|
|
|
if self.current.kind != ")":
|
|
|
@@ -334,9 +358,8 @@ def _validate_ast(ast: Any) -> None:
|
|
|
):
|
|
|
raise ValueError("integer literal is invalid")
|
|
|
if literal_type == "decimal" and (
|
|
|
- isinstance(value, bool)
|
|
|
- or not isinstance(value, (int, float))
|
|
|
- or not math.isfinite(float(value))
|
|
|
+ not isinstance(value, str)
|
|
|
+ or _DECIMAL_LEXEME.fullmatch(value) is None
|
|
|
):
|
|
|
raise ValueError("decimal literal is invalid")
|
|
|
return
|
|
|
@@ -368,6 +391,8 @@ def _validate_ast(ast: Any) -> None:
|
|
|
arguments = node.get("arguments")
|
|
|
if not isinstance(function, str) or _IDENTIFIER.fullmatch(function) is None:
|
|
|
raise ValueError("call AST node has an invalid function")
|
|
|
+ if function not in ALLOWED_FUNCTIONS:
|
|
|
+ raise ValueError(f"unsupported expression function: {function}")
|
|
|
if not isinstance(arguments, list) or len(arguments) > 100:
|
|
|
raise ValueError("call AST arguments must be a bounded array")
|
|
|
for argument in arguments:
|
|
|
@@ -538,6 +563,59 @@ def type_check_expression(ast: dict, fields: dict[str, str]) -> str:
|
|
|
return check(ast)
|
|
|
|
|
|
|
|
|
+def validate_rule_expressions(
|
|
|
+ rule_spec: dict[str, Any], fields: dict[str, str]
|
|
|
+) -> dict[str, dict[str, Any]]:
|
|
|
+ """Type-check every canonical expression in a schema-bound RuleSpec.
|
|
|
+
|
|
|
+ Callers must supply fields from a server-owned schema snapshot, never an
|
|
|
+ authoring request. The return value is immutable-plan metadata rather
|
|
|
+ than executable code and lets release reject unsupported backends early.
|
|
|
+ """
|
|
|
+
|
|
|
+ if not isinstance(rule_spec, dict) or not isinstance(
|
|
|
+ rule_spec.get("steps"), list
|
|
|
+ ):
|
|
|
+ raise ValueError("rule spec steps are required for expression validation")
|
|
|
+ result: dict[str, dict[str, Any]] = {}
|
|
|
+ for index, step in enumerate(rule_spec["steps"]):
|
|
|
+ if not isinstance(step, dict):
|
|
|
+ raise ValueError("rule step must be an object")
|
|
|
+ operation = step.get("op")
|
|
|
+ ast = step.get("expression_ast")
|
|
|
+ if operation in {"assert", "filter"} and ast is None:
|
|
|
+ raise ValueError(f"{operation} expression is required")
|
|
|
+ if ast is None:
|
|
|
+ continue
|
|
|
+ expression_type = type_check_expression(ast, fields)
|
|
|
+ if operation in {"assert", "filter"} and expression_type != "boolean":
|
|
|
+ raise ValueError(f"{operation} expression must return boolean")
|
|
|
+ supported = backend_support(ast)
|
|
|
+ if not supported:
|
|
|
+ raise ValueError("expression has no supported execution backend")
|
|
|
+ step_id = step.get("id")
|
|
|
+ key = step_id if isinstance(step_id, str) else str(index)
|
|
|
+ result[key] = {
|
|
|
+ "result_type": expression_type,
|
|
|
+ "backends": supported,
|
|
|
+ }
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _portable_regex(pattern: str) -> bool:
|
|
|
+ """Keep only the portable regular-expression subset advertised by V1."""
|
|
|
+
|
|
|
+ if len(pattern) > MAX_REGEX_LENGTH:
|
|
|
+ return False
|
|
|
+ if re.search(r"\(\?|\\[1-9]|\\[pP]", pattern):
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ re.compile(pattern)
|
|
|
+ except re.error:
|
|
|
+ return False
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
def backend_support(ast: dict) -> frozenset[str]:
|
|
|
"""Return backends that can preserve this grammar's V1 semantics.
|
|
|
|
|
|
@@ -548,19 +626,166 @@ def backend_support(ast: dict) -> frozenset[str]:
|
|
|
|
|
|
_validate_ast(ast)
|
|
|
|
|
|
- def validate_functions(node: dict) -> None:
|
|
|
- if node["kind"] == "call":
|
|
|
- if node["function"] not in ALLOWED_FUNCTIONS:
|
|
|
- raise ValueError(
|
|
|
- f"unsupported expression function: {node['function']}"
|
|
|
- )
|
|
|
- for argument in node["arguments"]:
|
|
|
- validate_functions(argument)
|
|
|
- elif node["kind"] == "unary":
|
|
|
- validate_functions(node["operand"])
|
|
|
- elif node["kind"] == "binary":
|
|
|
- validate_functions(node["left"])
|
|
|
- validate_functions(node["right"])
|
|
|
-
|
|
|
- validate_functions(ast)
|
|
|
- return SUPPORTED_BACKENDS
|
|
|
+ def support(node: dict) -> frozenset[str]:
|
|
|
+ kind = node["kind"]
|
|
|
+ if kind in {"literal", "identifier"}:
|
|
|
+ return SUPPORTED_BACKENDS
|
|
|
+ if kind == "unary":
|
|
|
+ return support(node["operand"])
|
|
|
+ if kind == "binary":
|
|
|
+ return support(node["left"]) & support(node["right"])
|
|
|
+ function = node["function"]
|
|
|
+ supported = _FUNCTION_BACKENDS[function]
|
|
|
+ for argument in node["arguments"]:
|
|
|
+ supported = supported & support(argument)
|
|
|
+ if function == "matches":
|
|
|
+ arguments = node["arguments"]
|
|
|
+ if (
|
|
|
+ len(arguments) != 2
|
|
|
+ or arguments[1]["kind"] != "literal"
|
|
|
+ or arguments[1]["type"] != "string"
|
|
|
+ or not _portable_regex(arguments[1]["value"])
|
|
|
+ ):
|
|
|
+ return frozenset()
|
|
|
+ return frozenset(supported)
|
|
|
+
|
|
|
+ return support(ast)
|
|
|
+
|
|
|
+
|
|
|
+def _decimal(value: Any) -> Decimal:
|
|
|
+ if isinstance(value, Decimal):
|
|
|
+ return value
|
|
|
+ if isinstance(value, bool):
|
|
|
+ raise ValueError("reference evaluator expected a numeric value")
|
|
|
+ try:
|
|
|
+ if isinstance(value, float):
|
|
|
+ return Decimal(str(value))
|
|
|
+ return Decimal(value)
|
|
|
+ except (InvalidOperation, TypeError, ValueError) as exc:
|
|
|
+ raise ValueError("reference evaluator expected a numeric value") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _timestamp(value: Any, timezone: ZoneInfo) -> datetime:
|
|
|
+ if isinstance(value, datetime):
|
|
|
+ result = value
|
|
|
+ elif isinstance(value, Date):
|
|
|
+ result = datetime.combine(value, datetime.min.time())
|
|
|
+ elif isinstance(value, str):
|
|
|
+ try:
|
|
|
+ result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ValueError("reference evaluator received an invalid timestamp") from exc
|
|
|
+ else:
|
|
|
+ raise ValueError("reference evaluator expected a temporal value")
|
|
|
+ if result.tzinfo is None:
|
|
|
+ result = result.replace(tzinfo=timezone)
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _sql_and(left: Any, right: Any) -> bool | None:
|
|
|
+ if left is False or right is False:
|
|
|
+ return False
|
|
|
+ if left is None or right is None:
|
|
|
+ return None
|
|
|
+ return bool(left and right)
|
|
|
+
|
|
|
+
|
|
|
+def _sql_or(left: Any, right: Any) -> bool | None:
|
|
|
+ if left is True or right is True:
|
|
|
+ return True
|
|
|
+ if left is None or right is None:
|
|
|
+ return None
|
|
|
+ return bool(left or right)
|
|
|
+
|
|
|
+
|
|
|
+def evaluate_expression(
|
|
|
+ ast: dict, row: dict[str, Any], *, timezone: str = "UTC"
|
|
|
+) -> Any:
|
|
|
+ """Execute only the closed AST as a deterministic semantic test oracle.
|
|
|
+
|
|
|
+ This is intentionally not a production runner. It has no access to a
|
|
|
+ filesystem, network, Python callables, SQL, or model-generated code.
|
|
|
+ """
|
|
|
+
|
|
|
+ _validate_ast(ast)
|
|
|
+ if not isinstance(row, dict):
|
|
|
+ raise ValueError("reference evaluator row must be an object")
|
|
|
+ try:
|
|
|
+ timezone_info = ZoneInfo(timezone)
|
|
|
+ except Exception as exc:
|
|
|
+ raise ValueError("reference evaluator timezone is invalid") from exc
|
|
|
+
|
|
|
+ def evaluate(node: dict) -> Any:
|
|
|
+ kind = node["kind"]
|
|
|
+ if kind == "literal":
|
|
|
+ if node["type"] == "decimal":
|
|
|
+ return Decimal(node["value"])
|
|
|
+ return node["value"]
|
|
|
+ if kind == "identifier":
|
|
|
+ if node["name"] not in row:
|
|
|
+ raise ValueError(f"reference evaluator missing field: {node['name']}")
|
|
|
+ return row[node["name"]]
|
|
|
+ if kind == "unary":
|
|
|
+ value = evaluate(node["operand"])
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
+ return (not value) if node["operator"] == "!" else -_decimal(value)
|
|
|
+ if kind == "binary":
|
|
|
+ left = evaluate(node["left"])
|
|
|
+ right = evaluate(node["right"])
|
|
|
+ operator = node["operator"]
|
|
|
+ if operator == "&&":
|
|
|
+ return _sql_and(left, right)
|
|
|
+ if operator == "||":
|
|
|
+ return _sql_or(left, right)
|
|
|
+ if left is None or right is None:
|
|
|
+ return None
|
|
|
+ if operator in {"+", "-", "*", "/", "%"}:
|
|
|
+ left_decimal = _decimal(left)
|
|
|
+ right_decimal = _decimal(right)
|
|
|
+ return {
|
|
|
+ "+": left_decimal + right_decimal,
|
|
|
+ "-": left_decimal - right_decimal,
|
|
|
+ "*": left_decimal * right_decimal,
|
|
|
+ "/": left_decimal / right_decimal,
|
|
|
+ "%": left_decimal % right_decimal,
|
|
|
+ }[operator]
|
|
|
+ if operator == "==":
|
|
|
+ return left == right
|
|
|
+ if operator == "!=":
|
|
|
+ return left != right
|
|
|
+ if operator == "<":
|
|
|
+ return left < right
|
|
|
+ if operator == "<=":
|
|
|
+ return left <= right
|
|
|
+ if operator == ">":
|
|
|
+ return left > right
|
|
|
+ return left >= right
|
|
|
+ function = node["function"]
|
|
|
+ arguments = [evaluate(argument) for argument in node["arguments"]]
|
|
|
+ if function == "coalesce":
|
|
|
+ return next((value for value in arguments if value is not None), None)
|
|
|
+ if any(value is None for value in arguments):
|
|
|
+ return None
|
|
|
+ if function == "matches":
|
|
|
+ return re.search(arguments[1], arguments[0]) is not None
|
|
|
+ if function == "lower":
|
|
|
+ return arguments[0].lower()
|
|
|
+ if function == "upper":
|
|
|
+ return arguments[0].upper()
|
|
|
+ if function == "trim":
|
|
|
+ return arguments[0].strip()
|
|
|
+ if function == "length":
|
|
|
+ return len(arguments[0])
|
|
|
+ if function == "date":
|
|
|
+ return _timestamp(arguments[0], timezone_info).astimezone(timezone_info).date()
|
|
|
+ if function == "timestamp":
|
|
|
+ return _timestamp(arguments[0], timezone_info)
|
|
|
+ if function == "abs":
|
|
|
+ return abs(_decimal(arguments[0]))
|
|
|
+ precision = int(arguments[1]) if len(arguments) == 2 else 0
|
|
|
+ return _decimal(arguments[0]).quantize(
|
|
|
+ Decimal(1).scaleb(-precision), rounding=ROUND_HALF_UP
|
|
|
+ )
|
|
|
+
|
|
|
+ return evaluate(ast)
|