expressions.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
  1. """Closed, typed rule expressions for governed DataOps rules.
  2. The module deliberately owns both parsing and validation. It produces JSON
  3. only; expressions are never delegated to Python, SQL, JavaScript, or a model
  4. runtime. Compilers consume the resulting AST in a later release.
  5. """
  6. from __future__ import annotations
  7. from dataclasses import dataclass
  8. from datetime import date as Date
  9. from datetime import datetime
  10. from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
  11. import re
  12. from typing import Any
  13. from zoneinfo import ZoneInfo
  14. MAX_SOURCE_LENGTH = 20_000
  15. MAX_AST_DEPTH = 40
  16. MAX_AST_NODES = 500
  17. MAX_REGEX_LENGTH = 500
  18. ALLOWED_FUNCTIONS = frozenset(
  19. {
  20. "matches",
  21. "lower",
  22. "upper",
  23. "trim",
  24. "length",
  25. "coalesce",
  26. "date",
  27. "timestamp",
  28. "abs",
  29. "round",
  30. }
  31. )
  32. SUPPORTED_BACKENDS = frozenset({"postgresql", "mysql", "polars"})
  33. _FUNCTION_BACKENDS = {
  34. "matches": SUPPORTED_BACKENDS,
  35. "lower": SUPPORTED_BACKENDS,
  36. "upper": SUPPORTED_BACKENDS,
  37. "trim": SUPPORTED_BACKENDS,
  38. "length": SUPPORTED_BACKENDS,
  39. "coalesce": SUPPORTED_BACKENDS,
  40. "date": SUPPORTED_BACKENDS,
  41. # MySQL timezone conversion depends on server timezone tables; do not
  42. # advertise it before a deployment can prove that dependency.
  43. "timestamp": frozenset({"postgresql", "polars"}),
  44. "abs": SUPPORTED_BACKENDS,
  45. # Polars rounding mode cannot be assumed to match the V1 decimal policy.
  46. "round": frozenset({"postgresql", "mysql"}),
  47. }
  48. _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
  49. _FIELD_TYPE_ALIASES = {
  50. "bigint": "integer",
  51. "bool": "boolean",
  52. "character varying": "string",
  53. "datetime": "timestamp",
  54. "int": "integer",
  55. "numeric": "decimal",
  56. "text": "string",
  57. "varchar": "string",
  58. }
  59. _FIELD_TYPES = {
  60. "binary",
  61. "boolean",
  62. "date",
  63. "decimal",
  64. "double",
  65. "float",
  66. "integer",
  67. "json",
  68. "string",
  69. "timestamp",
  70. "timestamptz",
  71. }
  72. _NUMERIC_TYPES = {"integer", "decimal", "float", "double"}
  73. _LITERAL_TYPES = {"boolean", "decimal", "integer", "null", "string"}
  74. _DECIMAL_LEXEME = re.compile(r"^[0-9]+\.[0-9]+$")
  75. _BINARY_OPERATORS = {
  76. "||",
  77. "&&",
  78. "==",
  79. "!=",
  80. "<",
  81. "<=",
  82. ">",
  83. ">=",
  84. "+",
  85. "-",
  86. "*",
  87. "/",
  88. "%",
  89. }
  90. _PRECEDENCE = {
  91. "||": 10,
  92. "&&": 20,
  93. "==": 30,
  94. "!=": 30,
  95. "<": 30,
  96. "<=": 30,
  97. ">": 30,
  98. ">=": 30,
  99. "+": 40,
  100. "-": 40,
  101. "*": 50,
  102. "/": 50,
  103. "%": 50,
  104. }
  105. @dataclass(frozen=True)
  106. class _Token:
  107. kind: str
  108. value: Any
  109. position: int
  110. def _error(position: int, message: str) -> ValueError:
  111. return ValueError(f"expression {message} at position {position}")
  112. class _Lexer:
  113. def __init__(self, source: str):
  114. self.source = source
  115. self.length = len(source)
  116. self.position = 0
  117. def tokens(self) -> list[_Token]:
  118. result: list[_Token] = []
  119. while self.position < self.length:
  120. char = self.source[self.position]
  121. if char.isspace():
  122. self.position += 1
  123. elif char in "'\"":
  124. result.append(self._string())
  125. elif char.isdigit():
  126. result.append(self._number())
  127. elif char.isascii() and (char.isalpha() or char == "_"):
  128. result.append(self._identifier())
  129. elif char in "(),":
  130. result.append(_Token(char, char, self.position))
  131. self.position += 1
  132. else:
  133. result.append(self._operator())
  134. result.append(_Token("EOF", None, self.position))
  135. return result
  136. def _string(self) -> _Token:
  137. start = self.position
  138. quote = self.source[self.position]
  139. self.position += 1
  140. pieces: list[str] = []
  141. escapes = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
  142. while self.position < self.length:
  143. char = self.source[self.position]
  144. self.position += 1
  145. if char == quote:
  146. value = "".join(pieces)
  147. if len(value) > 4_000:
  148. raise _error(start, "string literal exceeds 4000 characters")
  149. return _Token("LITERAL", ("string", value), start)
  150. if char != "\\":
  151. pieces.append(char)
  152. continue
  153. if self.position >= self.length:
  154. raise _error(start, "contains an unfinished string escape")
  155. escaped = self.source[self.position]
  156. self.position += 1
  157. if escaped in escapes:
  158. pieces.append(escapes[escaped])
  159. elif escaped in {"\\", "'", '\"'}:
  160. pieces.append(escaped)
  161. elif escaped == "u":
  162. encoded = self.source[self.position : self.position + 4]
  163. if len(encoded) != 4 or not re.fullmatch(r"[0-9a-fA-F]{4}", encoded):
  164. raise _error(start, "contains an invalid unicode escape")
  165. pieces.append(chr(int(encoded, 16)))
  166. self.position += 4
  167. else:
  168. raise _error(start, "contains an unsupported string escape")
  169. raise _error(start, "contains an unterminated string")
  170. def _number(self) -> _Token:
  171. start = self.position
  172. while self.position < self.length and self.source[self.position].isdigit():
  173. self.position += 1
  174. kind = "integer"
  175. if self.position < self.length and self.source[self.position] == ".":
  176. kind = "decimal"
  177. self.position += 1
  178. decimal_start = self.position
  179. while self.position < self.length and self.source[self.position].isdigit():
  180. self.position += 1
  181. if self.position == decimal_start:
  182. raise _error(start, "contains an invalid numeric literal")
  183. raw = self.source[start : self.position]
  184. # Decimal tokens keep their lexeme in the JSON AST. A float here
  185. # would destroy source precision before a compiler receives it.
  186. value: int | str = int(raw) if kind == "integer" else raw
  187. return _Token("LITERAL", (kind, value), start)
  188. def _identifier(self) -> _Token:
  189. start = self.position
  190. self.position += 1
  191. while self.position < self.length:
  192. char = self.source[self.position]
  193. if not (char.isascii() and (char.isalnum() or char == "_")):
  194. break
  195. self.position += 1
  196. value = self.source[start : self.position]
  197. if len(value) > 128:
  198. raise _error(start, "identifier exceeds 128 characters")
  199. lowered = value.lower()
  200. if lowered == "true":
  201. return _Token("LITERAL", ("boolean", True), start)
  202. if lowered == "false":
  203. return _Token("LITERAL", ("boolean", False), start)
  204. if lowered == "null":
  205. return _Token("LITERAL", ("null", None), start)
  206. return _Token("IDENT", value, start)
  207. def _operator(self) -> _Token:
  208. start = self.position
  209. for operator in ("&&", "||", "==", "!=", "<=", ">="):
  210. if self.source.startswith(operator, start):
  211. self.position += len(operator)
  212. return _Token("OP", operator, start)
  213. char = self.source[self.position]
  214. if char in "!<>+-*/%":
  215. self.position += 1
  216. return _Token("OP", char, start)
  217. raise _error(start, f"contains unexpected character {char!r}")
  218. class _Parser:
  219. def __init__(self, source: str):
  220. self.tokens = _Lexer(source).tokens()
  221. self.index = 0
  222. @property
  223. def current(self) -> _Token:
  224. return self.tokens[self.index]
  225. def consume(self, kind: str, value: str | None = None) -> _Token:
  226. token = self.current
  227. if token.kind != kind or (value is not None and token.value != value):
  228. expected = value if value is not None else kind
  229. raise _error(token.position, f"expected {expected!r}")
  230. self.index += 1
  231. return token
  232. def parse(self) -> dict[str, Any]:
  233. expression = self._expression(0)
  234. if self.current.kind != "EOF":
  235. raise _error(self.current.position, "contains unexpected trailing input")
  236. return expression
  237. def _expression(self, minimum_precedence: int) -> dict[str, Any]:
  238. token = self.current
  239. self.index += 1
  240. if token.kind == "LITERAL":
  241. literal_type, value = token.value
  242. left = {"kind": "literal", "type": literal_type, "value": value}
  243. elif token.kind == "IDENT":
  244. if self.current.kind == "(":
  245. if token.value not in ALLOWED_FUNCTIONS:
  246. raise _error(
  247. token.position,
  248. f"uses unsupported expression function {token.value!r}",
  249. )
  250. self.index += 1
  251. arguments: list[dict[str, Any]] = []
  252. if self.current.kind != ")":
  253. while True:
  254. arguments.append(self._expression(0))
  255. if self.current.kind != ",":
  256. break
  257. self.index += 1
  258. self.consume(")")
  259. left = {
  260. "kind": "call",
  261. "function": token.value,
  262. "arguments": arguments,
  263. }
  264. else:
  265. left = {"kind": "identifier", "name": token.value}
  266. elif token.kind == "OP" and token.value in {"!", "-"}:
  267. left = {
  268. "kind": "unary",
  269. "operator": token.value,
  270. "operand": self._expression(60),
  271. }
  272. elif token.kind == "(":
  273. left = self._expression(0)
  274. self.consume(")")
  275. else:
  276. raise _error(token.position, "contains an unexpected token")
  277. while self.current.kind == "OP":
  278. operator = self.current.value
  279. precedence = _PRECEDENCE.get(operator)
  280. if precedence is None or precedence < minimum_precedence:
  281. break
  282. self.index += 1
  283. left = {
  284. "kind": "binary",
  285. "operator": operator,
  286. "left": left,
  287. "right": self._expression(precedence + 1),
  288. }
  289. return left
  290. def parse_expression(source: str) -> dict:
  291. """Parse a V1 expression into the closed, JSON-serializable AST."""
  292. if not isinstance(source, str) or not source.strip():
  293. raise ValueError("expression source is required")
  294. if len(source) > MAX_SOURCE_LENGTH:
  295. raise ValueError(f"expression source exceeds {MAX_SOURCE_LENGTH} characters")
  296. ast = _Parser(source).parse()
  297. _validate_ast(ast)
  298. return ast
  299. def _validate_ast(ast: Any) -> None:
  300. count = 0
  301. def walk(node: Any, depth: int) -> None:
  302. nonlocal count
  303. if depth > MAX_AST_DEPTH:
  304. raise ValueError(f"expression AST depth exceeds {MAX_AST_DEPTH}")
  305. count += 1
  306. if count > MAX_AST_NODES:
  307. raise ValueError(f"expression AST node count exceeds {MAX_AST_NODES}")
  308. if not isinstance(node, dict):
  309. raise ValueError("expression AST node must be an object")
  310. kind = node.get("kind")
  311. if kind == "literal":
  312. if set(node) != {"kind", "type", "value"}:
  313. raise ValueError("literal AST node must have a closed shape")
  314. literal_type = node.get("type")
  315. value = node.get("value")
  316. if literal_type not in _LITERAL_TYPES:
  317. raise ValueError("literal AST node has an unsupported type")
  318. if literal_type == "null" and value is not None:
  319. raise ValueError("null literal must have a null value")
  320. if literal_type == "boolean" and not isinstance(value, bool):
  321. raise ValueError("boolean literal must have a boolean value")
  322. if literal_type == "string" and (
  323. not isinstance(value, str) or len(value) > 4_000
  324. ):
  325. raise ValueError("string literal is invalid")
  326. if literal_type == "integer" and (
  327. isinstance(value, bool) or not isinstance(value, int)
  328. ):
  329. raise ValueError("integer literal is invalid")
  330. if literal_type == "decimal" and (
  331. not isinstance(value, str)
  332. or _DECIMAL_LEXEME.fullmatch(value) is None
  333. ):
  334. raise ValueError("decimal literal is invalid")
  335. return
  336. if kind == "identifier":
  337. if set(node) != {"kind", "name"} or not isinstance(node.get("name"), str):
  338. raise ValueError("identifier AST node must have a closed shape")
  339. if _IDENTIFIER.fullmatch(node["name"]) is None:
  340. raise ValueError("identifier AST node has an invalid name")
  341. return
  342. if kind == "unary":
  343. if set(node) != {"kind", "operator", "operand"}:
  344. raise ValueError("unary AST node must have a closed shape")
  345. if node.get("operator") not in {"!", "-"}:
  346. raise ValueError("unary AST node has an unsupported operator")
  347. walk(node.get("operand"), depth + 1)
  348. return
  349. if kind == "binary":
  350. if set(node) != {"kind", "operator", "left", "right"}:
  351. raise ValueError("binary AST node must have a closed shape")
  352. if node.get("operator") not in _BINARY_OPERATORS:
  353. raise ValueError("binary AST node has an unsupported operator")
  354. walk(node.get("left"), depth + 1)
  355. walk(node.get("right"), depth + 1)
  356. return
  357. if kind == "call":
  358. if set(node) != {"kind", "function", "arguments"}:
  359. raise ValueError("call AST node must have a closed shape")
  360. function = node.get("function")
  361. arguments = node.get("arguments")
  362. if not isinstance(function, str) or _IDENTIFIER.fullmatch(function) is None:
  363. raise ValueError("call AST node has an invalid function")
  364. if function not in ALLOWED_FUNCTIONS:
  365. raise ValueError(f"unsupported expression function: {function}")
  366. if not isinstance(arguments, list) or len(arguments) > 100:
  367. raise ValueError("call AST arguments must be a bounded array")
  368. for argument in arguments:
  369. walk(argument, depth + 1)
  370. # This resource bound is independent of schema typing, so RuleSpec
  371. # normalization can reject an unsafe regex before compilation.
  372. if (
  373. function == "matches"
  374. and len(arguments) >= 2
  375. and arguments[1].get("kind") == "literal"
  376. and arguments[1].get("type") == "string"
  377. ):
  378. if len(arguments[1]["value"]) > MAX_REGEX_LENGTH:
  379. raise ValueError(
  380. f"regex pattern exceeds {MAX_REGEX_LENGTH} characters"
  381. )
  382. return
  383. raise ValueError("expression AST node kind is unsupported")
  384. walk(ast, 1)
  385. def _normalized_fields(fields: Any) -> dict[str, str]:
  386. if not isinstance(fields, dict) or len(fields) > 1_000:
  387. raise ValueError("expression fields must be a bounded object")
  388. normalized: dict[str, str] = {}
  389. for name, field_type in fields.items():
  390. if not isinstance(name, str) or _IDENTIFIER.fullmatch(name) is None:
  391. raise ValueError("expression field names must be identifiers")
  392. if not isinstance(field_type, str):
  393. raise ValueError(f"expression field {name} type is invalid")
  394. result_type = _FIELD_TYPE_ALIASES.get(
  395. field_type.strip().lower(), field_type.strip().lower()
  396. )
  397. if result_type not in _FIELD_TYPES:
  398. raise ValueError(f"expression field {name} has an unsupported type")
  399. normalized[name] = result_type
  400. return normalized
  401. def _is_numeric(value_type: str) -> bool:
  402. return value_type in _NUMERIC_TYPES
  403. def _promote_numeric(left: str, right: str) -> str:
  404. order = {"integer": 0, "decimal": 1, "float": 2, "double": 3}
  405. return max((left, right), key=lambda value: order[value])
  406. def _compatible(left: str, right: str) -> bool:
  407. return left == "null" or right == "null" or left == right or (
  408. _is_numeric(left) and _is_numeric(right)
  409. )
  410. def _require_arguments(function: str, arguments: list[dict], expected: int | tuple[int, int]) -> None:
  411. if isinstance(expected, int):
  412. valid = len(arguments) == expected
  413. else:
  414. valid = expected[0] <= len(arguments) <= expected[1]
  415. if not valid:
  416. raise ValueError(f"function {function} received an unsupported argument count")
  417. def type_check_expression(ast: dict, fields: dict[str, str]) -> str:
  418. """Validate schema-bound identifiers and return the expression result type."""
  419. _validate_ast(ast)
  420. normalized_fields = _normalized_fields(fields)
  421. def check(node: dict) -> str:
  422. kind = node["kind"]
  423. if kind == "literal":
  424. return node["type"]
  425. if kind == "identifier":
  426. name = node["name"]
  427. if name not in normalized_fields:
  428. raise ValueError(f"unknown expression field: {name}")
  429. return normalized_fields[name]
  430. if kind == "unary":
  431. operand_type = check(node["operand"])
  432. operator = node["operator"]
  433. if operator == "!":
  434. if operand_type != "boolean":
  435. raise ValueError("logical not requires a boolean operand")
  436. return "boolean"
  437. if not _is_numeric(operand_type):
  438. raise ValueError("numeric negation requires a numeric operand")
  439. return operand_type
  440. if kind == "binary":
  441. left_type = check(node["left"])
  442. right_type = check(node["right"])
  443. operator = node["operator"]
  444. if operator in {"&&", "||"}:
  445. if left_type != "boolean" or right_type != "boolean":
  446. raise ValueError("logical operators require boolean operands")
  447. return "boolean"
  448. if operator in {"+", "-", "*", "/", "%"}:
  449. if not _is_numeric(left_type) or not _is_numeric(right_type):
  450. raise ValueError("arithmetic operators require numeric operands")
  451. return _promote_numeric(left_type, right_type)
  452. if not _compatible(left_type, right_type):
  453. raise ValueError(
  454. "comparison operands must have compatible types; use an explicit cast"
  455. )
  456. return "boolean"
  457. function = node["function"]
  458. arguments = node["arguments"]
  459. if function not in ALLOWED_FUNCTIONS:
  460. raise ValueError(f"unsupported expression function: {function}")
  461. argument_types = [check(argument) for argument in arguments]
  462. if function == "matches":
  463. _require_arguments(function, arguments, 2)
  464. if argument_types != ["string", "string"]:
  465. raise ValueError("matches requires string arguments")
  466. pattern = arguments[1]
  467. if pattern["kind"] != "literal" or pattern["type"] != "string":
  468. raise ValueError("matches pattern must be a string literal")
  469. if len(pattern["value"]) > MAX_REGEX_LENGTH:
  470. raise ValueError(f"regex pattern exceeds {MAX_REGEX_LENGTH} characters")
  471. try:
  472. re.compile(pattern["value"])
  473. except re.error as exc:
  474. raise ValueError("regex pattern is invalid") from exc
  475. return "boolean"
  476. if function in {"lower", "upper", "trim"}:
  477. _require_arguments(function, arguments, 1)
  478. if argument_types != ["string"]:
  479. raise ValueError(f"{function} requires a string argument")
  480. return "string"
  481. if function == "length":
  482. _require_arguments(function, arguments, 1)
  483. if argument_types != ["string"]:
  484. raise ValueError("length requires a string argument")
  485. return "integer"
  486. if function == "coalesce":
  487. _require_arguments(function, arguments, (2, 100))
  488. concrete = [value for value in argument_types if value != "null"]
  489. if not concrete:
  490. return "null"
  491. if not all(_compatible(concrete[0], value) for value in concrete[1:]):
  492. raise ValueError("coalesce arguments must have compatible types")
  493. if all(_is_numeric(value) for value in concrete):
  494. return max(concrete, key=lambda value: {"integer": 0, "decimal": 1, "float": 2, "double": 3}[value])
  495. return concrete[0]
  496. if function == "date":
  497. _require_arguments(function, arguments, 1)
  498. if argument_types[0] not in {"string", "date", "timestamp", "timestamptz"}:
  499. raise ValueError("date requires a string or temporal argument")
  500. return "date"
  501. if function == "timestamp":
  502. _require_arguments(function, arguments, 1)
  503. if argument_types[0] not in {"string", "date", "timestamp", "timestamptz"}:
  504. raise ValueError("timestamp requires a string or temporal argument")
  505. return "timestamp"
  506. if function == "abs":
  507. _require_arguments(function, arguments, 1)
  508. if not _is_numeric(argument_types[0]):
  509. raise ValueError("abs requires a numeric argument")
  510. return argument_types[0]
  511. _require_arguments(function, arguments, (1, 2))
  512. if not _is_numeric(argument_types[0]):
  513. raise ValueError("round requires a numeric value")
  514. if len(argument_types) == 2 and argument_types[1] != "integer":
  515. raise ValueError("round precision requires an integer")
  516. return argument_types[0]
  517. return check(ast)
  518. def validate_rule_expressions(
  519. rule_spec: dict[str, Any], fields: dict[str, str]
  520. ) -> dict[str, dict[str, Any]]:
  521. """Type-check every canonical expression in a schema-bound RuleSpec.
  522. Callers must supply fields from a server-owned schema snapshot, never an
  523. authoring request. The return value is immutable-plan metadata rather
  524. than executable code and lets release reject unsupported backends early.
  525. """
  526. if not isinstance(rule_spec, dict) or not isinstance(
  527. rule_spec.get("steps"), list
  528. ):
  529. raise ValueError("rule spec steps are required for expression validation")
  530. result: dict[str, dict[str, Any]] = {}
  531. for index, step in enumerate(rule_spec["steps"]):
  532. if not isinstance(step, dict):
  533. raise ValueError("rule step must be an object")
  534. operation = step.get("op")
  535. ast = step.get("expression_ast")
  536. if operation in {"assert", "filter"} and ast is None:
  537. raise ValueError(f"{operation} expression is required")
  538. if ast is None:
  539. continue
  540. expression_type = type_check_expression(ast, fields)
  541. if operation in {"assert", "filter"} and expression_type != "boolean":
  542. raise ValueError(f"{operation} expression must return boolean")
  543. supported = backend_support(ast)
  544. if not supported:
  545. raise ValueError("expression has no supported execution backend")
  546. step_id = step.get("id")
  547. key = step_id if isinstance(step_id, str) else str(index)
  548. result[key] = {
  549. "result_type": expression_type,
  550. "backends": supported,
  551. }
  552. return result
  553. def _portable_regex(pattern: str) -> bool:
  554. """Keep only the portable regular-expression subset advertised by V1."""
  555. if len(pattern) > MAX_REGEX_LENGTH:
  556. return False
  557. if re.search(r"\(\?|\\[1-9]|\\[pP]", pattern):
  558. return False
  559. try:
  560. re.compile(pattern)
  561. except re.error:
  562. return False
  563. return True
  564. def backend_support(ast: dict) -> frozenset[str]:
  565. """Return backends that can preserve this grammar's V1 semantics.
  566. The result is intentionally a capability analysis, not a compiler. It
  567. validates the closed AST again so hand-authored JSON cannot bypass the
  568. function allowlist before a compiler sees it.
  569. """
  570. _validate_ast(ast)
  571. def support(node: dict) -> frozenset[str]:
  572. kind = node["kind"]
  573. if kind in {"literal", "identifier"}:
  574. return SUPPORTED_BACKENDS
  575. if kind == "unary":
  576. return support(node["operand"])
  577. if kind == "binary":
  578. return support(node["left"]) & support(node["right"])
  579. function = node["function"]
  580. supported = _FUNCTION_BACKENDS[function]
  581. for argument in node["arguments"]:
  582. supported = supported & support(argument)
  583. if function == "matches":
  584. arguments = node["arguments"]
  585. if (
  586. len(arguments) != 2
  587. or arguments[1]["kind"] != "literal"
  588. or arguments[1]["type"] != "string"
  589. or not _portable_regex(arguments[1]["value"])
  590. ):
  591. return frozenset()
  592. return frozenset(supported)
  593. return support(ast)
  594. def _decimal(value: Any) -> Decimal:
  595. if isinstance(value, Decimal):
  596. return value
  597. if isinstance(value, bool):
  598. raise ValueError("reference evaluator expected a numeric value")
  599. try:
  600. if isinstance(value, float):
  601. return Decimal(str(value))
  602. return Decimal(value)
  603. except (InvalidOperation, TypeError, ValueError) as exc:
  604. raise ValueError("reference evaluator expected a numeric value") from exc
  605. def _timestamp(value: Any, timezone: ZoneInfo) -> datetime:
  606. if isinstance(value, datetime):
  607. result = value
  608. elif isinstance(value, Date):
  609. result = datetime.combine(value, datetime.min.time())
  610. elif isinstance(value, str):
  611. try:
  612. result = datetime.fromisoformat(value.replace("Z", "+00:00"))
  613. except ValueError as exc:
  614. raise ValueError("reference evaluator received an invalid timestamp") from exc
  615. else:
  616. raise ValueError("reference evaluator expected a temporal value")
  617. if result.tzinfo is None:
  618. result = result.replace(tzinfo=timezone)
  619. return result
  620. def _sql_and(left: Any, right: Any) -> bool | None:
  621. if left is False or right is False:
  622. return False
  623. if left is None or right is None:
  624. return None
  625. return bool(left and right)
  626. def _sql_or(left: Any, right: Any) -> bool | None:
  627. if left is True or right is True:
  628. return True
  629. if left is None or right is None:
  630. return None
  631. return bool(left or right)
  632. def evaluate_expression(
  633. ast: dict, row: dict[str, Any], *, timezone: str = "UTC"
  634. ) -> Any:
  635. """Execute only the closed AST as a deterministic semantic test oracle.
  636. This is intentionally not a production runner. It has no access to a
  637. filesystem, network, Python callables, SQL, or model-generated code.
  638. """
  639. _validate_ast(ast)
  640. if not isinstance(row, dict):
  641. raise ValueError("reference evaluator row must be an object")
  642. try:
  643. timezone_info = ZoneInfo(timezone)
  644. except Exception as exc:
  645. raise ValueError("reference evaluator timezone is invalid") from exc
  646. def evaluate(node: dict) -> Any:
  647. kind = node["kind"]
  648. if kind == "literal":
  649. if node["type"] == "decimal":
  650. return Decimal(node["value"])
  651. return node["value"]
  652. if kind == "identifier":
  653. if node["name"] not in row:
  654. raise ValueError(f"reference evaluator missing field: {node['name']}")
  655. return row[node["name"]]
  656. if kind == "unary":
  657. value = evaluate(node["operand"])
  658. if value is None:
  659. return None
  660. return (not value) if node["operator"] == "!" else -_decimal(value)
  661. if kind == "binary":
  662. left = evaluate(node["left"])
  663. right = evaluate(node["right"])
  664. operator = node["operator"]
  665. if operator == "&&":
  666. return _sql_and(left, right)
  667. if operator == "||":
  668. return _sql_or(left, right)
  669. if left is None or right is None:
  670. return None
  671. if operator in {"+", "-", "*", "/", "%"}:
  672. left_decimal = _decimal(left)
  673. right_decimal = _decimal(right)
  674. return {
  675. "+": left_decimal + right_decimal,
  676. "-": left_decimal - right_decimal,
  677. "*": left_decimal * right_decimal,
  678. "/": left_decimal / right_decimal,
  679. "%": left_decimal % right_decimal,
  680. }[operator]
  681. if operator == "==":
  682. return left == right
  683. if operator == "!=":
  684. return left != right
  685. if operator == "<":
  686. return left < right
  687. if operator == "<=":
  688. return left <= right
  689. if operator == ">":
  690. return left > right
  691. return left >= right
  692. function = node["function"]
  693. arguments = [evaluate(argument) for argument in node["arguments"]]
  694. if function == "coalesce":
  695. return next((value for value in arguments if value is not None), None)
  696. if any(value is None for value in arguments):
  697. return None
  698. if function == "matches":
  699. return re.search(arguments[1], arguments[0]) is not None
  700. if function == "lower":
  701. return arguments[0].lower()
  702. if function == "upper":
  703. return arguments[0].upper()
  704. if function == "trim":
  705. return arguments[0].strip()
  706. if function == "length":
  707. return len(arguments[0])
  708. if function == "date":
  709. return _timestamp(arguments[0], timezone_info).astimezone(timezone_info).date()
  710. if function == "timestamp":
  711. return _timestamp(arguments[0], timezone_info)
  712. if function == "abs":
  713. return abs(_decimal(arguments[0]))
  714. precision = int(arguments[1]) if len(arguments) == 2 else 0
  715. return _decimal(arguments[0]).quantize(
  716. Decimal(1).scaleb(-precision), rounding=ROUND_HALF_UP
  717. )
  718. return evaluate(ast)