expressions.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  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. import math
  9. import re
  10. from typing import Any
  11. MAX_SOURCE_LENGTH = 20_000
  12. MAX_AST_DEPTH = 40
  13. MAX_AST_NODES = 500
  14. MAX_REGEX_LENGTH = 500
  15. ALLOWED_FUNCTIONS = frozenset(
  16. {
  17. "matches",
  18. "lower",
  19. "upper",
  20. "trim",
  21. "length",
  22. "coalesce",
  23. "date",
  24. "timestamp",
  25. "abs",
  26. "round",
  27. }
  28. )
  29. SUPPORTED_BACKENDS = frozenset({"postgresql", "mysql", "polars"})
  30. _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
  31. _FIELD_TYPE_ALIASES = {
  32. "bigint": "integer",
  33. "bool": "boolean",
  34. "character varying": "string",
  35. "datetime": "timestamp",
  36. "int": "integer",
  37. "numeric": "decimal",
  38. "text": "string",
  39. "varchar": "string",
  40. }
  41. _FIELD_TYPES = {
  42. "binary",
  43. "boolean",
  44. "date",
  45. "decimal",
  46. "double",
  47. "float",
  48. "integer",
  49. "json",
  50. "string",
  51. "timestamp",
  52. "timestamptz",
  53. }
  54. _NUMERIC_TYPES = {"integer", "decimal", "float", "double"}
  55. _LITERAL_TYPES = {"boolean", "decimal", "integer", "null", "string"}
  56. _BINARY_OPERATORS = {
  57. "||",
  58. "&&",
  59. "==",
  60. "!=",
  61. "<",
  62. "<=",
  63. ">",
  64. ">=",
  65. "+",
  66. "-",
  67. "*",
  68. "/",
  69. "%",
  70. }
  71. _PRECEDENCE = {
  72. "||": 10,
  73. "&&": 20,
  74. "==": 30,
  75. "!=": 30,
  76. "<": 30,
  77. "<=": 30,
  78. ">": 30,
  79. ">=": 30,
  80. "+": 40,
  81. "-": 40,
  82. "*": 50,
  83. "/": 50,
  84. "%": 50,
  85. }
  86. @dataclass(frozen=True)
  87. class _Token:
  88. kind: str
  89. value: Any
  90. position: int
  91. def _error(position: int, message: str) -> ValueError:
  92. return ValueError(f"expression {message} at position {position}")
  93. class _Lexer:
  94. def __init__(self, source: str):
  95. self.source = source
  96. self.length = len(source)
  97. self.position = 0
  98. def tokens(self) -> list[_Token]:
  99. result: list[_Token] = []
  100. while self.position < self.length:
  101. char = self.source[self.position]
  102. if char.isspace():
  103. self.position += 1
  104. elif char in "'\"":
  105. result.append(self._string())
  106. elif char.isdigit():
  107. result.append(self._number())
  108. elif char.isascii() and (char.isalpha() or char == "_"):
  109. result.append(self._identifier())
  110. elif char in "(),":
  111. result.append(_Token(char, char, self.position))
  112. self.position += 1
  113. else:
  114. result.append(self._operator())
  115. result.append(_Token("EOF", None, self.position))
  116. return result
  117. def _string(self) -> _Token:
  118. start = self.position
  119. quote = self.source[self.position]
  120. self.position += 1
  121. pieces: list[str] = []
  122. escapes = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
  123. while self.position < self.length:
  124. char = self.source[self.position]
  125. self.position += 1
  126. if char == quote:
  127. value = "".join(pieces)
  128. if len(value) > 4_000:
  129. raise _error(start, "string literal exceeds 4000 characters")
  130. return _Token("LITERAL", ("string", value), start)
  131. if char != "\\":
  132. pieces.append(char)
  133. continue
  134. if self.position >= self.length:
  135. raise _error(start, "contains an unfinished string escape")
  136. escaped = self.source[self.position]
  137. self.position += 1
  138. if escaped in escapes:
  139. pieces.append(escapes[escaped])
  140. elif escaped in {"\\", "'", '\"'}:
  141. pieces.append(escaped)
  142. elif escaped == "u":
  143. encoded = self.source[self.position : self.position + 4]
  144. if len(encoded) != 4 or not re.fullmatch(r"[0-9a-fA-F]{4}", encoded):
  145. raise _error(start, "contains an invalid unicode escape")
  146. pieces.append(chr(int(encoded, 16)))
  147. self.position += 4
  148. else:
  149. raise _error(start, "contains an unsupported string escape")
  150. raise _error(start, "contains an unterminated string")
  151. def _number(self) -> _Token:
  152. start = self.position
  153. while self.position < self.length and self.source[self.position].isdigit():
  154. self.position += 1
  155. kind = "integer"
  156. if self.position < self.length and self.source[self.position] == ".":
  157. kind = "decimal"
  158. self.position += 1
  159. decimal_start = self.position
  160. while self.position < self.length and self.source[self.position].isdigit():
  161. self.position += 1
  162. if self.position == decimal_start:
  163. raise _error(start, "contains an invalid numeric literal")
  164. raw = self.source[start : self.position]
  165. value: int | float = int(raw) if kind == "integer" else float(raw)
  166. if isinstance(value, float) and not math.isfinite(value):
  167. raise _error(start, "contains a non-finite numeric literal")
  168. return _Token("LITERAL", (kind, value), start)
  169. def _identifier(self) -> _Token:
  170. start = self.position
  171. self.position += 1
  172. while self.position < self.length:
  173. char = self.source[self.position]
  174. if not (char.isascii() and (char.isalnum() or char == "_")):
  175. break
  176. self.position += 1
  177. value = self.source[start : self.position]
  178. if len(value) > 128:
  179. raise _error(start, "identifier exceeds 128 characters")
  180. lowered = value.lower()
  181. if lowered == "true":
  182. return _Token("LITERAL", ("boolean", True), start)
  183. if lowered == "false":
  184. return _Token("LITERAL", ("boolean", False), start)
  185. if lowered == "null":
  186. return _Token("LITERAL", ("null", None), start)
  187. return _Token("IDENT", value, start)
  188. def _operator(self) -> _Token:
  189. start = self.position
  190. for operator in ("&&", "||", "==", "!=", "<=", ">="):
  191. if self.source.startswith(operator, start):
  192. self.position += len(operator)
  193. return _Token("OP", operator, start)
  194. char = self.source[self.position]
  195. if char in "!<>+-*/%":
  196. self.position += 1
  197. return _Token("OP", char, start)
  198. raise _error(start, f"contains unexpected character {char!r}")
  199. class _Parser:
  200. def __init__(self, source: str):
  201. self.tokens = _Lexer(source).tokens()
  202. self.index = 0
  203. @property
  204. def current(self) -> _Token:
  205. return self.tokens[self.index]
  206. def consume(self, kind: str, value: str | None = None) -> _Token:
  207. token = self.current
  208. if token.kind != kind or (value is not None and token.value != value):
  209. expected = value if value is not None else kind
  210. raise _error(token.position, f"expected {expected!r}")
  211. self.index += 1
  212. return token
  213. def parse(self) -> dict[str, Any]:
  214. expression = self._expression(0)
  215. if self.current.kind != "EOF":
  216. raise _error(self.current.position, "contains unexpected trailing input")
  217. return expression
  218. def _expression(self, minimum_precedence: int) -> dict[str, Any]:
  219. token = self.current
  220. self.index += 1
  221. if token.kind == "LITERAL":
  222. literal_type, value = token.value
  223. left = {"kind": "literal", "type": literal_type, "value": value}
  224. elif token.kind == "IDENT":
  225. if self.current.kind == "(":
  226. self.index += 1
  227. arguments: list[dict[str, Any]] = []
  228. if self.current.kind != ")":
  229. while True:
  230. arguments.append(self._expression(0))
  231. if self.current.kind != ",":
  232. break
  233. self.index += 1
  234. self.consume(")")
  235. left = {
  236. "kind": "call",
  237. "function": token.value,
  238. "arguments": arguments,
  239. }
  240. else:
  241. left = {"kind": "identifier", "name": token.value}
  242. elif token.kind == "OP" and token.value in {"!", "-"}:
  243. left = {
  244. "kind": "unary",
  245. "operator": token.value,
  246. "operand": self._expression(60),
  247. }
  248. elif token.kind == "(":
  249. left = self._expression(0)
  250. self.consume(")")
  251. else:
  252. raise _error(token.position, "contains an unexpected token")
  253. while self.current.kind == "OP":
  254. operator = self.current.value
  255. precedence = _PRECEDENCE.get(operator)
  256. if precedence is None or precedence < minimum_precedence:
  257. break
  258. self.index += 1
  259. left = {
  260. "kind": "binary",
  261. "operator": operator,
  262. "left": left,
  263. "right": self._expression(precedence + 1),
  264. }
  265. return left
  266. def parse_expression(source: str) -> dict:
  267. """Parse a V1 expression into the closed, JSON-serializable AST."""
  268. if not isinstance(source, str) or not source.strip():
  269. raise ValueError("expression source is required")
  270. if len(source) > MAX_SOURCE_LENGTH:
  271. raise ValueError(f"expression source exceeds {MAX_SOURCE_LENGTH} characters")
  272. ast = _Parser(source).parse()
  273. _validate_ast(ast)
  274. return ast
  275. def _validate_ast(ast: Any) -> None:
  276. count = 0
  277. def walk(node: Any, depth: int) -> None:
  278. nonlocal count
  279. if depth > MAX_AST_DEPTH:
  280. raise ValueError(f"expression AST depth exceeds {MAX_AST_DEPTH}")
  281. count += 1
  282. if count > MAX_AST_NODES:
  283. raise ValueError(f"expression AST node count exceeds {MAX_AST_NODES}")
  284. if not isinstance(node, dict):
  285. raise ValueError("expression AST node must be an object")
  286. kind = node.get("kind")
  287. if kind == "literal":
  288. if set(node) != {"kind", "type", "value"}:
  289. raise ValueError("literal AST node must have a closed shape")
  290. literal_type = node.get("type")
  291. value = node.get("value")
  292. if literal_type not in _LITERAL_TYPES:
  293. raise ValueError("literal AST node has an unsupported type")
  294. if literal_type == "null" and value is not None:
  295. raise ValueError("null literal must have a null value")
  296. if literal_type == "boolean" and not isinstance(value, bool):
  297. raise ValueError("boolean literal must have a boolean value")
  298. if literal_type == "string" and (
  299. not isinstance(value, str) or len(value) > 4_000
  300. ):
  301. raise ValueError("string literal is invalid")
  302. if literal_type == "integer" and (
  303. isinstance(value, bool) or not isinstance(value, int)
  304. ):
  305. raise ValueError("integer literal is invalid")
  306. if literal_type == "decimal" and (
  307. isinstance(value, bool)
  308. or not isinstance(value, (int, float))
  309. or not math.isfinite(float(value))
  310. ):
  311. raise ValueError("decimal literal is invalid")
  312. return
  313. if kind == "identifier":
  314. if set(node) != {"kind", "name"} or not isinstance(node.get("name"), str):
  315. raise ValueError("identifier AST node must have a closed shape")
  316. if _IDENTIFIER.fullmatch(node["name"]) is None:
  317. raise ValueError("identifier AST node has an invalid name")
  318. return
  319. if kind == "unary":
  320. if set(node) != {"kind", "operator", "operand"}:
  321. raise ValueError("unary AST node must have a closed shape")
  322. if node.get("operator") not in {"!", "-"}:
  323. raise ValueError("unary AST node has an unsupported operator")
  324. walk(node.get("operand"), depth + 1)
  325. return
  326. if kind == "binary":
  327. if set(node) != {"kind", "operator", "left", "right"}:
  328. raise ValueError("binary AST node must have a closed shape")
  329. if node.get("operator") not in _BINARY_OPERATORS:
  330. raise ValueError("binary AST node has an unsupported operator")
  331. walk(node.get("left"), depth + 1)
  332. walk(node.get("right"), depth + 1)
  333. return
  334. if kind == "call":
  335. if set(node) != {"kind", "function", "arguments"}:
  336. raise ValueError("call AST node must have a closed shape")
  337. function = node.get("function")
  338. arguments = node.get("arguments")
  339. if not isinstance(function, str) or _IDENTIFIER.fullmatch(function) is None:
  340. raise ValueError("call AST node has an invalid function")
  341. if not isinstance(arguments, list) or len(arguments) > 100:
  342. raise ValueError("call AST arguments must be a bounded array")
  343. for argument in arguments:
  344. walk(argument, depth + 1)
  345. # This resource bound is independent of schema typing, so RuleSpec
  346. # normalization can reject an unsafe regex before compilation.
  347. if (
  348. function == "matches"
  349. and len(arguments) >= 2
  350. and arguments[1].get("kind") == "literal"
  351. and arguments[1].get("type") == "string"
  352. ):
  353. if len(arguments[1]["value"]) > MAX_REGEX_LENGTH:
  354. raise ValueError(
  355. f"regex pattern exceeds {MAX_REGEX_LENGTH} characters"
  356. )
  357. return
  358. raise ValueError("expression AST node kind is unsupported")
  359. walk(ast, 1)
  360. def _normalized_fields(fields: Any) -> dict[str, str]:
  361. if not isinstance(fields, dict) or len(fields) > 1_000:
  362. raise ValueError("expression fields must be a bounded object")
  363. normalized: dict[str, str] = {}
  364. for name, field_type in fields.items():
  365. if not isinstance(name, str) or _IDENTIFIER.fullmatch(name) is None:
  366. raise ValueError("expression field names must be identifiers")
  367. if not isinstance(field_type, str):
  368. raise ValueError(f"expression field {name} type is invalid")
  369. result_type = _FIELD_TYPE_ALIASES.get(
  370. field_type.strip().lower(), field_type.strip().lower()
  371. )
  372. if result_type not in _FIELD_TYPES:
  373. raise ValueError(f"expression field {name} has an unsupported type")
  374. normalized[name] = result_type
  375. return normalized
  376. def _is_numeric(value_type: str) -> bool:
  377. return value_type in _NUMERIC_TYPES
  378. def _promote_numeric(left: str, right: str) -> str:
  379. order = {"integer": 0, "decimal": 1, "float": 2, "double": 3}
  380. return max((left, right), key=lambda value: order[value])
  381. def _compatible(left: str, right: str) -> bool:
  382. return left == "null" or right == "null" or left == right or (
  383. _is_numeric(left) and _is_numeric(right)
  384. )
  385. def _require_arguments(function: str, arguments: list[dict], expected: int | tuple[int, int]) -> None:
  386. if isinstance(expected, int):
  387. valid = len(arguments) == expected
  388. else:
  389. valid = expected[0] <= len(arguments) <= expected[1]
  390. if not valid:
  391. raise ValueError(f"function {function} received an unsupported argument count")
  392. def type_check_expression(ast: dict, fields: dict[str, str]) -> str:
  393. """Validate schema-bound identifiers and return the expression result type."""
  394. _validate_ast(ast)
  395. normalized_fields = _normalized_fields(fields)
  396. def check(node: dict) -> str:
  397. kind = node["kind"]
  398. if kind == "literal":
  399. return node["type"]
  400. if kind == "identifier":
  401. name = node["name"]
  402. if name not in normalized_fields:
  403. raise ValueError(f"unknown expression field: {name}")
  404. return normalized_fields[name]
  405. if kind == "unary":
  406. operand_type = check(node["operand"])
  407. operator = node["operator"]
  408. if operator == "!":
  409. if operand_type != "boolean":
  410. raise ValueError("logical not requires a boolean operand")
  411. return "boolean"
  412. if not _is_numeric(operand_type):
  413. raise ValueError("numeric negation requires a numeric operand")
  414. return operand_type
  415. if kind == "binary":
  416. left_type = check(node["left"])
  417. right_type = check(node["right"])
  418. operator = node["operator"]
  419. if operator in {"&&", "||"}:
  420. if left_type != "boolean" or right_type != "boolean":
  421. raise ValueError("logical operators require boolean operands")
  422. return "boolean"
  423. if operator in {"+", "-", "*", "/", "%"}:
  424. if not _is_numeric(left_type) or not _is_numeric(right_type):
  425. raise ValueError("arithmetic operators require numeric operands")
  426. return _promote_numeric(left_type, right_type)
  427. if not _compatible(left_type, right_type):
  428. raise ValueError(
  429. "comparison operands must have compatible types; use an explicit cast"
  430. )
  431. return "boolean"
  432. function = node["function"]
  433. arguments = node["arguments"]
  434. if function not in ALLOWED_FUNCTIONS:
  435. raise ValueError(f"unsupported expression function: {function}")
  436. argument_types = [check(argument) for argument in arguments]
  437. if function == "matches":
  438. _require_arguments(function, arguments, 2)
  439. if argument_types != ["string", "string"]:
  440. raise ValueError("matches requires string arguments")
  441. pattern = arguments[1]
  442. if pattern["kind"] != "literal" or pattern["type"] != "string":
  443. raise ValueError("matches pattern must be a string literal")
  444. if len(pattern["value"]) > MAX_REGEX_LENGTH:
  445. raise ValueError(f"regex pattern exceeds {MAX_REGEX_LENGTH} characters")
  446. try:
  447. re.compile(pattern["value"])
  448. except re.error as exc:
  449. raise ValueError("regex pattern is invalid") from exc
  450. return "boolean"
  451. if function in {"lower", "upper", "trim"}:
  452. _require_arguments(function, arguments, 1)
  453. if argument_types != ["string"]:
  454. raise ValueError(f"{function} requires a string argument")
  455. return "string"
  456. if function == "length":
  457. _require_arguments(function, arguments, 1)
  458. if argument_types != ["string"]:
  459. raise ValueError("length requires a string argument")
  460. return "integer"
  461. if function == "coalesce":
  462. _require_arguments(function, arguments, (2, 100))
  463. concrete = [value for value in argument_types if value != "null"]
  464. if not concrete:
  465. return "null"
  466. if not all(_compatible(concrete[0], value) for value in concrete[1:]):
  467. raise ValueError("coalesce arguments must have compatible types")
  468. if all(_is_numeric(value) for value in concrete):
  469. return max(concrete, key=lambda value: {"integer": 0, "decimal": 1, "float": 2, "double": 3}[value])
  470. return concrete[0]
  471. if function == "date":
  472. _require_arguments(function, arguments, 1)
  473. if argument_types[0] not in {"string", "date", "timestamp", "timestamptz"}:
  474. raise ValueError("date requires a string or temporal argument")
  475. return "date"
  476. if function == "timestamp":
  477. _require_arguments(function, arguments, 1)
  478. if argument_types[0] not in {"string", "date", "timestamp", "timestamptz"}:
  479. raise ValueError("timestamp requires a string or temporal argument")
  480. return "timestamp"
  481. if function == "abs":
  482. _require_arguments(function, arguments, 1)
  483. if not _is_numeric(argument_types[0]):
  484. raise ValueError("abs requires a numeric argument")
  485. return argument_types[0]
  486. _require_arguments(function, arguments, (1, 2))
  487. if not _is_numeric(argument_types[0]):
  488. raise ValueError("round requires a numeric value")
  489. if len(argument_types) == 2 and argument_types[1] != "integer":
  490. raise ValueError("round precision requires an integer")
  491. return argument_types[0]
  492. return check(ast)
  493. def backend_support(ast: dict) -> frozenset[str]:
  494. """Return backends that can preserve this grammar's V1 semantics.
  495. The result is intentionally a capability analysis, not a compiler. It
  496. validates the closed AST again so hand-authored JSON cannot bypass the
  497. function allowlist before a compiler sees it.
  498. """
  499. _validate_ast(ast)
  500. def validate_functions(node: dict) -> None:
  501. if node["kind"] == "call":
  502. if node["function"] not in ALLOWED_FUNCTIONS:
  503. raise ValueError(
  504. f"unsupported expression function: {node['function']}"
  505. )
  506. for argument in node["arguments"]:
  507. validate_functions(argument)
  508. elif node["kind"] == "unary":
  509. validate_functions(node["operand"])
  510. elif node["kind"] == "binary":
  511. validate_functions(node["left"])
  512. validate_functions(node["right"])
  513. validate_functions(ast)
  514. return SUPPORTED_BACKENDS