|
@@ -0,0 +1,566 @@
|
|
|
|
|
+"""Closed, typed rule expressions for governed DataOps rules.
|
|
|
|
|
+
|
|
|
|
|
+The module deliberately owns both parsing and validation. It produces JSON
|
|
|
|
|
+only; expressions are never delegated to Python, SQL, JavaScript, or a model
|
|
|
|
|
+runtime. Compilers consume the resulting AST in a later release.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from dataclasses import dataclass
|
|
|
|
|
+import math
|
|
|
|
|
+import re
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+MAX_SOURCE_LENGTH = 20_000
|
|
|
|
|
+MAX_AST_DEPTH = 40
|
|
|
|
|
+MAX_AST_NODES = 500
|
|
|
|
|
+MAX_REGEX_LENGTH = 500
|
|
|
|
|
+ALLOWED_FUNCTIONS = frozenset(
|
|
|
|
|
+ {
|
|
|
|
|
+ "matches",
|
|
|
|
|
+ "lower",
|
|
|
|
|
+ "upper",
|
|
|
|
|
+ "trim",
|
|
|
|
|
+ "length",
|
|
|
|
|
+ "coalesce",
|
|
|
|
|
+ "date",
|
|
|
|
|
+ "timestamp",
|
|
|
|
|
+ "abs",
|
|
|
|
|
+ "round",
|
|
|
|
|
+ }
|
|
|
|
|
+)
|
|
|
|
|
+SUPPORTED_BACKENDS = frozenset({"postgresql", "mysql", "polars"})
|
|
|
|
|
+
|
|
|
|
|
+_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
|
|
|
|
+_FIELD_TYPE_ALIASES = {
|
|
|
|
|
+ "bigint": "integer",
|
|
|
|
|
+ "bool": "boolean",
|
|
|
|
|
+ "character varying": "string",
|
|
|
|
|
+ "datetime": "timestamp",
|
|
|
|
|
+ "int": "integer",
|
|
|
|
|
+ "numeric": "decimal",
|
|
|
|
|
+ "text": "string",
|
|
|
|
|
+ "varchar": "string",
|
|
|
|
|
+}
|
|
|
|
|
+_FIELD_TYPES = {
|
|
|
|
|
+ "binary",
|
|
|
|
|
+ "boolean",
|
|
|
|
|
+ "date",
|
|
|
|
|
+ "decimal",
|
|
|
|
|
+ "double",
|
|
|
|
|
+ "float",
|
|
|
|
|
+ "integer",
|
|
|
|
|
+ "json",
|
|
|
|
|
+ "string",
|
|
|
|
|
+ "timestamp",
|
|
|
|
|
+ "timestamptz",
|
|
|
|
|
+}
|
|
|
|
|
+_NUMERIC_TYPES = {"integer", "decimal", "float", "double"}
|
|
|
|
|
+_LITERAL_TYPES = {"boolean", "decimal", "integer", "null", "string"}
|
|
|
|
|
+_BINARY_OPERATORS = {
|
|
|
|
|
+ "||",
|
|
|
|
|
+ "&&",
|
|
|
|
|
+ "==",
|
|
|
|
|
+ "!=",
|
|
|
|
|
+ "<",
|
|
|
|
|
+ "<=",
|
|
|
|
|
+ ">",
|
|
|
|
|
+ ">=",
|
|
|
|
|
+ "+",
|
|
|
|
|
+ "-",
|
|
|
|
|
+ "*",
|
|
|
|
|
+ "/",
|
|
|
|
|
+ "%",
|
|
|
|
|
+}
|
|
|
|
|
+_PRECEDENCE = {
|
|
|
|
|
+ "||": 10,
|
|
|
|
|
+ "&&": 20,
|
|
|
|
|
+ "==": 30,
|
|
|
|
|
+ "!=": 30,
|
|
|
|
|
+ "<": 30,
|
|
|
|
|
+ "<=": 30,
|
|
|
|
|
+ ">": 30,
|
|
|
|
|
+ ">=": 30,
|
|
|
|
|
+ "+": 40,
|
|
|
|
|
+ "-": 40,
|
|
|
|
|
+ "*": 50,
|
|
|
|
|
+ "/": 50,
|
|
|
|
|
+ "%": 50,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
|
|
+class _Token:
|
|
|
|
|
+ kind: str
|
|
|
|
|
+ value: Any
|
|
|
|
|
+ position: int
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _error(position: int, message: str) -> ValueError:
|
|
|
|
|
+ return ValueError(f"expression {message} at position {position}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _Lexer:
|
|
|
|
|
+ def __init__(self, source: str):
|
|
|
|
|
+ self.source = source
|
|
|
|
|
+ self.length = len(source)
|
|
|
|
|
+ self.position = 0
|
|
|
|
|
+
|
|
|
|
|
+ def tokens(self) -> list[_Token]:
|
|
|
|
|
+ result: list[_Token] = []
|
|
|
|
|
+ while self.position < self.length:
|
|
|
|
|
+ char = self.source[self.position]
|
|
|
|
|
+ if char.isspace():
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ elif char in "'\"":
|
|
|
|
|
+ result.append(self._string())
|
|
|
|
|
+ elif char.isdigit():
|
|
|
|
|
+ result.append(self._number())
|
|
|
|
|
+ elif char.isascii() and (char.isalpha() or char == "_"):
|
|
|
|
|
+ result.append(self._identifier())
|
|
|
|
|
+ elif char in "(),":
|
|
|
|
|
+ result.append(_Token(char, char, self.position))
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ else:
|
|
|
|
|
+ result.append(self._operator())
|
|
|
|
|
+ result.append(_Token("EOF", None, self.position))
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+ def _string(self) -> _Token:
|
|
|
|
|
+ start = self.position
|
|
|
|
|
+ quote = self.source[self.position]
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ pieces: list[str] = []
|
|
|
|
|
+ escapes = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
|
|
|
|
|
+ while self.position < self.length:
|
|
|
|
|
+ char = self.source[self.position]
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ if char == quote:
|
|
|
|
|
+ value = "".join(pieces)
|
|
|
|
|
+ if len(value) > 4_000:
|
|
|
|
|
+ raise _error(start, "string literal exceeds 4000 characters")
|
|
|
|
|
+ return _Token("LITERAL", ("string", value), start)
|
|
|
|
|
+ if char != "\\":
|
|
|
|
|
+ pieces.append(char)
|
|
|
|
|
+ continue
|
|
|
|
|
+ if self.position >= self.length:
|
|
|
|
|
+ raise _error(start, "contains an unfinished string escape")
|
|
|
|
|
+ escaped = self.source[self.position]
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ if escaped in escapes:
|
|
|
|
|
+ pieces.append(escapes[escaped])
|
|
|
|
|
+ elif escaped in {"\\", "'", '\"'}:
|
|
|
|
|
+ pieces.append(escaped)
|
|
|
|
|
+ elif escaped == "u":
|
|
|
|
|
+ encoded = self.source[self.position : self.position + 4]
|
|
|
|
|
+ if len(encoded) != 4 or not re.fullmatch(r"[0-9a-fA-F]{4}", encoded):
|
|
|
|
|
+ raise _error(start, "contains an invalid unicode escape")
|
|
|
|
|
+ pieces.append(chr(int(encoded, 16)))
|
|
|
|
|
+ self.position += 4
|
|
|
|
|
+ else:
|
|
|
|
|
+ raise _error(start, "contains an unsupported string escape")
|
|
|
|
|
+ raise _error(start, "contains an unterminated string")
|
|
|
|
|
+
|
|
|
|
|
+ def _number(self) -> _Token:
|
|
|
|
|
+ start = self.position
|
|
|
|
|
+ while self.position < self.length and self.source[self.position].isdigit():
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ kind = "integer"
|
|
|
|
|
+ if self.position < self.length and self.source[self.position] == ".":
|
|
|
|
|
+ kind = "decimal"
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ decimal_start = self.position
|
|
|
|
|
+ while self.position < self.length and self.source[self.position].isdigit():
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ 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")
|
|
|
|
|
+ return _Token("LITERAL", (kind, value), start)
|
|
|
|
|
+
|
|
|
|
|
+ def _identifier(self) -> _Token:
|
|
|
|
|
+ start = self.position
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ while self.position < self.length:
|
|
|
|
|
+ char = self.source[self.position]
|
|
|
|
|
+ if not (char.isascii() and (char.isalnum() or char == "_")):
|
|
|
|
|
+ break
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ value = self.source[start : self.position]
|
|
|
|
|
+ if len(value) > 128:
|
|
|
|
|
+ raise _error(start, "identifier exceeds 128 characters")
|
|
|
|
|
+ lowered = value.lower()
|
|
|
|
|
+ if lowered == "true":
|
|
|
|
|
+ return _Token("LITERAL", ("boolean", True), start)
|
|
|
|
|
+ if lowered == "false":
|
|
|
|
|
+ return _Token("LITERAL", ("boolean", False), start)
|
|
|
|
|
+ if lowered == "null":
|
|
|
|
|
+ return _Token("LITERAL", ("null", None), start)
|
|
|
|
|
+ return _Token("IDENT", value, start)
|
|
|
|
|
+
|
|
|
|
|
+ def _operator(self) -> _Token:
|
|
|
|
|
+ start = self.position
|
|
|
|
|
+ for operator in ("&&", "||", "==", "!=", "<=", ">="):
|
|
|
|
|
+ if self.source.startswith(operator, start):
|
|
|
|
|
+ self.position += len(operator)
|
|
|
|
|
+ return _Token("OP", operator, start)
|
|
|
|
|
+ char = self.source[self.position]
|
|
|
|
|
+ if char in "!<>+-*/%":
|
|
|
|
|
+ self.position += 1
|
|
|
|
|
+ return _Token("OP", char, start)
|
|
|
|
|
+ raise _error(start, f"contains unexpected character {char!r}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _Parser:
|
|
|
|
|
+ def __init__(self, source: str):
|
|
|
|
|
+ self.tokens = _Lexer(source).tokens()
|
|
|
|
|
+ self.index = 0
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def current(self) -> _Token:
|
|
|
|
|
+ return self.tokens[self.index]
|
|
|
|
|
+
|
|
|
|
|
+ def consume(self, kind: str, value: str | None = None) -> _Token:
|
|
|
|
|
+ token = self.current
|
|
|
|
|
+ if token.kind != kind or (value is not None and token.value != value):
|
|
|
|
|
+ expected = value if value is not None else kind
|
|
|
|
|
+ raise _error(token.position, f"expected {expected!r}")
|
|
|
|
|
+ self.index += 1
|
|
|
|
|
+ return token
|
|
|
|
|
+
|
|
|
|
|
+ def parse(self) -> dict[str, Any]:
|
|
|
|
|
+ expression = self._expression(0)
|
|
|
|
|
+ if self.current.kind != "EOF":
|
|
|
|
|
+ raise _error(self.current.position, "contains unexpected trailing input")
|
|
|
|
|
+ return expression
|
|
|
|
|
+
|
|
|
|
|
+ def _expression(self, minimum_precedence: int) -> dict[str, Any]:
|
|
|
|
|
+ token = self.current
|
|
|
|
|
+ self.index += 1
|
|
|
|
|
+ if token.kind == "LITERAL":
|
|
|
|
|
+ literal_type, value = token.value
|
|
|
|
|
+ left = {"kind": "literal", "type": literal_type, "value": value}
|
|
|
|
|
+ elif token.kind == "IDENT":
|
|
|
|
|
+ if self.current.kind == "(":
|
|
|
|
|
+ self.index += 1
|
|
|
|
|
+ arguments: list[dict[str, Any]] = []
|
|
|
|
|
+ if self.current.kind != ")":
|
|
|
|
|
+ while True:
|
|
|
|
|
+ arguments.append(self._expression(0))
|
|
|
|
|
+ if self.current.kind != ",":
|
|
|
|
|
+ break
|
|
|
|
|
+ self.index += 1
|
|
|
|
|
+ self.consume(")")
|
|
|
|
|
+ left = {
|
|
|
|
|
+ "kind": "call",
|
|
|
|
|
+ "function": token.value,
|
|
|
|
|
+ "arguments": arguments,
|
|
|
|
|
+ }
|
|
|
|
|
+ else:
|
|
|
|
|
+ left = {"kind": "identifier", "name": token.value}
|
|
|
|
|
+ elif token.kind == "OP" and token.value in {"!", "-"}:
|
|
|
|
|
+ left = {
|
|
|
|
|
+ "kind": "unary",
|
|
|
|
|
+ "operator": token.value,
|
|
|
|
|
+ "operand": self._expression(60),
|
|
|
|
|
+ }
|
|
|
|
|
+ elif token.kind == "(":
|
|
|
|
|
+ left = self._expression(0)
|
|
|
|
|
+ self.consume(")")
|
|
|
|
|
+ else:
|
|
|
|
|
+ raise _error(token.position, "contains an unexpected token")
|
|
|
|
|
+
|
|
|
|
|
+ while self.current.kind == "OP":
|
|
|
|
|
+ operator = self.current.value
|
|
|
|
|
+ precedence = _PRECEDENCE.get(operator)
|
|
|
|
|
+ if precedence is None or precedence < minimum_precedence:
|
|
|
|
|
+ break
|
|
|
|
|
+ self.index += 1
|
|
|
|
|
+ left = {
|
|
|
|
|
+ "kind": "binary",
|
|
|
|
|
+ "operator": operator,
|
|
|
|
|
+ "left": left,
|
|
|
|
|
+ "right": self._expression(precedence + 1),
|
|
|
|
|
+ }
|
|
|
|
|
+ return left
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def parse_expression(source: str) -> dict:
|
|
|
|
|
+ """Parse a V1 expression into the closed, JSON-serializable AST."""
|
|
|
|
|
+
|
|
|
|
|
+ if not isinstance(source, str) or not source.strip():
|
|
|
|
|
+ raise ValueError("expression source is required")
|
|
|
|
|
+ if len(source) > MAX_SOURCE_LENGTH:
|
|
|
|
|
+ raise ValueError(f"expression source exceeds {MAX_SOURCE_LENGTH} characters")
|
|
|
|
|
+ ast = _Parser(source).parse()
|
|
|
|
|
+ _validate_ast(ast)
|
|
|
|
|
+ return ast
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _validate_ast(ast: Any) -> None:
|
|
|
|
|
+ count = 0
|
|
|
|
|
+
|
|
|
|
|
+ def walk(node: Any, depth: int) -> None:
|
|
|
|
|
+ nonlocal count
|
|
|
|
|
+ if depth > MAX_AST_DEPTH:
|
|
|
|
|
+ raise ValueError(f"expression AST depth exceeds {MAX_AST_DEPTH}")
|
|
|
|
|
+ count += 1
|
|
|
|
|
+ if count > MAX_AST_NODES:
|
|
|
|
|
+ raise ValueError(f"expression AST node count exceeds {MAX_AST_NODES}")
|
|
|
|
|
+ if not isinstance(node, dict):
|
|
|
|
|
+ raise ValueError("expression AST node must be an object")
|
|
|
|
|
+ kind = node.get("kind")
|
|
|
|
|
+ if kind == "literal":
|
|
|
|
|
+ if set(node) != {"kind", "type", "value"}:
|
|
|
|
|
+ raise ValueError("literal AST node must have a closed shape")
|
|
|
|
|
+ literal_type = node.get("type")
|
|
|
|
|
+ value = node.get("value")
|
|
|
|
|
+ if literal_type not in _LITERAL_TYPES:
|
|
|
|
|
+ raise ValueError("literal AST node has an unsupported type")
|
|
|
|
|
+ if literal_type == "null" and value is not None:
|
|
|
|
|
+ raise ValueError("null literal must have a null value")
|
|
|
|
|
+ if literal_type == "boolean" and not isinstance(value, bool):
|
|
|
|
|
+ raise ValueError("boolean literal must have a boolean value")
|
|
|
|
|
+ if literal_type == "string" and (
|
|
|
|
|
+ not isinstance(value, str) or len(value) > 4_000
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ValueError("string literal is invalid")
|
|
|
|
|
+ if literal_type == "integer" and (
|
|
|
|
|
+ isinstance(value, bool) or not isinstance(value, int)
|
|
|
|
|
+ ):
|
|
|
|
|
+ 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))
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ValueError("decimal literal is invalid")
|
|
|
|
|
+ return
|
|
|
|
|
+ if kind == "identifier":
|
|
|
|
|
+ if set(node) != {"kind", "name"} or not isinstance(node.get("name"), str):
|
|
|
|
|
+ raise ValueError("identifier AST node must have a closed shape")
|
|
|
|
|
+ if _IDENTIFIER.fullmatch(node["name"]) is None:
|
|
|
|
|
+ raise ValueError("identifier AST node has an invalid name")
|
|
|
|
|
+ return
|
|
|
|
|
+ if kind == "unary":
|
|
|
|
|
+ if set(node) != {"kind", "operator", "operand"}:
|
|
|
|
|
+ raise ValueError("unary AST node must have a closed shape")
|
|
|
|
|
+ if node.get("operator") not in {"!", "-"}:
|
|
|
|
|
+ raise ValueError("unary AST node has an unsupported operator")
|
|
|
|
|
+ walk(node.get("operand"), depth + 1)
|
|
|
|
|
+ return
|
|
|
|
|
+ if kind == "binary":
|
|
|
|
|
+ if set(node) != {"kind", "operator", "left", "right"}:
|
|
|
|
|
+ raise ValueError("binary AST node must have a closed shape")
|
|
|
|
|
+ if node.get("operator") not in _BINARY_OPERATORS:
|
|
|
|
|
+ raise ValueError("binary AST node has an unsupported operator")
|
|
|
|
|
+ walk(node.get("left"), depth + 1)
|
|
|
|
|
+ walk(node.get("right"), depth + 1)
|
|
|
|
|
+ return
|
|
|
|
|
+ if kind == "call":
|
|
|
|
|
+ if set(node) != {"kind", "function", "arguments"}:
|
|
|
|
|
+ raise ValueError("call AST node must have a closed shape")
|
|
|
|
|
+ function = node.get("function")
|
|
|
|
|
+ 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 not isinstance(arguments, list) or len(arguments) > 100:
|
|
|
|
|
+ raise ValueError("call AST arguments must be a bounded array")
|
|
|
|
|
+ for argument in arguments:
|
|
|
|
|
+ walk(argument, depth + 1)
|
|
|
|
|
+ # This resource bound is independent of schema typing, so RuleSpec
|
|
|
|
|
+ # normalization can reject an unsafe regex before compilation.
|
|
|
|
|
+ if (
|
|
|
|
|
+ function == "matches"
|
|
|
|
|
+ and len(arguments) >= 2
|
|
|
|
|
+ and arguments[1].get("kind") == "literal"
|
|
|
|
|
+ and arguments[1].get("type") == "string"
|
|
|
|
|
+ ):
|
|
|
|
|
+ if len(arguments[1]["value"]) > MAX_REGEX_LENGTH:
|
|
|
|
|
+ raise ValueError(
|
|
|
|
|
+ f"regex pattern exceeds {MAX_REGEX_LENGTH} characters"
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+ raise ValueError("expression AST node kind is unsupported")
|
|
|
|
|
+
|
|
|
|
|
+ walk(ast, 1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalized_fields(fields: Any) -> dict[str, str]:
|
|
|
|
|
+ if not isinstance(fields, dict) or len(fields) > 1_000:
|
|
|
|
|
+ raise ValueError("expression fields must be a bounded object")
|
|
|
|
|
+ normalized: dict[str, str] = {}
|
|
|
|
|
+ for name, field_type in fields.items():
|
|
|
|
|
+ if not isinstance(name, str) or _IDENTIFIER.fullmatch(name) is None:
|
|
|
|
|
+ raise ValueError("expression field names must be identifiers")
|
|
|
|
|
+ if not isinstance(field_type, str):
|
|
|
|
|
+ raise ValueError(f"expression field {name} type is invalid")
|
|
|
|
|
+ result_type = _FIELD_TYPE_ALIASES.get(
|
|
|
|
|
+ field_type.strip().lower(), field_type.strip().lower()
|
|
|
|
|
+ )
|
|
|
|
|
+ if result_type not in _FIELD_TYPES:
|
|
|
|
|
+ raise ValueError(f"expression field {name} has an unsupported type")
|
|
|
|
|
+ normalized[name] = result_type
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _is_numeric(value_type: str) -> bool:
|
|
|
|
|
+ return value_type in _NUMERIC_TYPES
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _promote_numeric(left: str, right: str) -> str:
|
|
|
|
|
+ order = {"integer": 0, "decimal": 1, "float": 2, "double": 3}
|
|
|
|
|
+ return max((left, right), key=lambda value: order[value])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _compatible(left: str, right: str) -> bool:
|
|
|
|
|
+ return left == "null" or right == "null" or left == right or (
|
|
|
|
|
+ _is_numeric(left) and _is_numeric(right)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _require_arguments(function: str, arguments: list[dict], expected: int | tuple[int, int]) -> None:
|
|
|
|
|
+ if isinstance(expected, int):
|
|
|
|
|
+ valid = len(arguments) == expected
|
|
|
|
|
+ else:
|
|
|
|
|
+ valid = expected[0] <= len(arguments) <= expected[1]
|
|
|
|
|
+ if not valid:
|
|
|
|
|
+ raise ValueError(f"function {function} received an unsupported argument count")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def type_check_expression(ast: dict, fields: dict[str, str]) -> str:
|
|
|
|
|
+ """Validate schema-bound identifiers and return the expression result type."""
|
|
|
|
|
+
|
|
|
|
|
+ _validate_ast(ast)
|
|
|
|
|
+ normalized_fields = _normalized_fields(fields)
|
|
|
|
|
+
|
|
|
|
|
+ def check(node: dict) -> str:
|
|
|
|
|
+ kind = node["kind"]
|
|
|
|
|
+ if kind == "literal":
|
|
|
|
|
+ return node["type"]
|
|
|
|
|
+ if kind == "identifier":
|
|
|
|
|
+ name = node["name"]
|
|
|
|
|
+ if name not in normalized_fields:
|
|
|
|
|
+ raise ValueError(f"unknown expression field: {name}")
|
|
|
|
|
+ return normalized_fields[name]
|
|
|
|
|
+ if kind == "unary":
|
|
|
|
|
+ operand_type = check(node["operand"])
|
|
|
|
|
+ operator = node["operator"]
|
|
|
|
|
+ if operator == "!":
|
|
|
|
|
+ if operand_type != "boolean":
|
|
|
|
|
+ raise ValueError("logical not requires a boolean operand")
|
|
|
|
|
+ return "boolean"
|
|
|
|
|
+ if not _is_numeric(operand_type):
|
|
|
|
|
+ raise ValueError("numeric negation requires a numeric operand")
|
|
|
|
|
+ return operand_type
|
|
|
|
|
+ if kind == "binary":
|
|
|
|
|
+ left_type = check(node["left"])
|
|
|
|
|
+ right_type = check(node["right"])
|
|
|
|
|
+ operator = node["operator"]
|
|
|
|
|
+ if operator in {"&&", "||"}:
|
|
|
|
|
+ if left_type != "boolean" or right_type != "boolean":
|
|
|
|
|
+ raise ValueError("logical operators require boolean operands")
|
|
|
|
|
+ return "boolean"
|
|
|
|
|
+ if operator in {"+", "-", "*", "/", "%"}:
|
|
|
|
|
+ if not _is_numeric(left_type) or not _is_numeric(right_type):
|
|
|
|
|
+ raise ValueError("arithmetic operators require numeric operands")
|
|
|
|
|
+ return _promote_numeric(left_type, right_type)
|
|
|
|
|
+ if not _compatible(left_type, right_type):
|
|
|
|
|
+ raise ValueError(
|
|
|
|
|
+ "comparison operands must have compatible types; use an explicit cast"
|
|
|
|
|
+ )
|
|
|
|
|
+ return "boolean"
|
|
|
|
|
+ function = node["function"]
|
|
|
|
|
+ arguments = node["arguments"]
|
|
|
|
|
+ if function not in ALLOWED_FUNCTIONS:
|
|
|
|
|
+ raise ValueError(f"unsupported expression function: {function}")
|
|
|
|
|
+ argument_types = [check(argument) for argument in arguments]
|
|
|
|
|
+ if function == "matches":
|
|
|
|
|
+ _require_arguments(function, arguments, 2)
|
|
|
|
|
+ if argument_types != ["string", "string"]:
|
|
|
|
|
+ raise ValueError("matches requires string arguments")
|
|
|
|
|
+ pattern = arguments[1]
|
|
|
|
|
+ if pattern["kind"] != "literal" or pattern["type"] != "string":
|
|
|
|
|
+ raise ValueError("matches pattern must be a string literal")
|
|
|
|
|
+ if len(pattern["value"]) > MAX_REGEX_LENGTH:
|
|
|
|
|
+ raise ValueError(f"regex pattern exceeds {MAX_REGEX_LENGTH} characters")
|
|
|
|
|
+ try:
|
|
|
|
|
+ re.compile(pattern["value"])
|
|
|
|
|
+ except re.error as exc:
|
|
|
|
|
+ raise ValueError("regex pattern is invalid") from exc
|
|
|
|
|
+ return "boolean"
|
|
|
|
|
+ if function in {"lower", "upper", "trim"}:
|
|
|
|
|
+ _require_arguments(function, arguments, 1)
|
|
|
|
|
+ if argument_types != ["string"]:
|
|
|
|
|
+ raise ValueError(f"{function} requires a string argument")
|
|
|
|
|
+ return "string"
|
|
|
|
|
+ if function == "length":
|
|
|
|
|
+ _require_arguments(function, arguments, 1)
|
|
|
|
|
+ if argument_types != ["string"]:
|
|
|
|
|
+ raise ValueError("length requires a string argument")
|
|
|
|
|
+ return "integer"
|
|
|
|
|
+ if function == "coalesce":
|
|
|
|
|
+ _require_arguments(function, arguments, (2, 100))
|
|
|
|
|
+ concrete = [value for value in argument_types if value != "null"]
|
|
|
|
|
+ if not concrete:
|
|
|
|
|
+ return "null"
|
|
|
|
|
+ if not all(_compatible(concrete[0], value) for value in concrete[1:]):
|
|
|
|
|
+ raise ValueError("coalesce arguments must have compatible types")
|
|
|
|
|
+ if all(_is_numeric(value) for value in concrete):
|
|
|
|
|
+ return max(concrete, key=lambda value: {"integer": 0, "decimal": 1, "float": 2, "double": 3}[value])
|
|
|
|
|
+ return concrete[0]
|
|
|
|
|
+ if function == "date":
|
|
|
|
|
+ _require_arguments(function, arguments, 1)
|
|
|
|
|
+ if argument_types[0] not in {"string", "date", "timestamp", "timestamptz"}:
|
|
|
|
|
+ raise ValueError("date requires a string or temporal argument")
|
|
|
|
|
+ return "date"
|
|
|
|
|
+ if function == "timestamp":
|
|
|
|
|
+ _require_arguments(function, arguments, 1)
|
|
|
|
|
+ if argument_types[0] not in {"string", "date", "timestamp", "timestamptz"}:
|
|
|
|
|
+ raise ValueError("timestamp requires a string or temporal argument")
|
|
|
|
|
+ return "timestamp"
|
|
|
|
|
+ if function == "abs":
|
|
|
|
|
+ _require_arguments(function, arguments, 1)
|
|
|
|
|
+ if not _is_numeric(argument_types[0]):
|
|
|
|
|
+ raise ValueError("abs requires a numeric argument")
|
|
|
|
|
+ return argument_types[0]
|
|
|
|
|
+ _require_arguments(function, arguments, (1, 2))
|
|
|
|
|
+ if not _is_numeric(argument_types[0]):
|
|
|
|
|
+ raise ValueError("round requires a numeric value")
|
|
|
|
|
+ if len(argument_types) == 2 and argument_types[1] != "integer":
|
|
|
|
|
+ raise ValueError("round precision requires an integer")
|
|
|
|
|
+ return argument_types[0]
|
|
|
|
|
+
|
|
|
|
|
+ return check(ast)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def backend_support(ast: dict) -> frozenset[str]:
|
|
|
|
|
+ """Return backends that can preserve this grammar's V1 semantics.
|
|
|
|
|
+
|
|
|
|
|
+ The result is intentionally a capability analysis, not a compiler. It
|
|
|
|
|
+ validates the closed AST again so hand-authored JSON cannot bypass the
|
|
|
|
|
+ function allowlist before a compiler sees it.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ _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
|