Skip to content

Commit 5a67072

Browse files
authored
Merge pull request #275 from OpenSPP/reland/cel-domain
feat(spp_cel_domain): SQL CASE compiler, read-only smart-op lookup, translator cache tests (re-land from #76)
2 parents 7973bef + ce126f7 commit 5a67072

11 files changed

Lines changed: 895 additions & 14 deletions

File tree

spp_cel_domain/README.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ Dependencies
142142
Changelog
143143
=========
144144

145+
19.0.2.1.0
146+
~~~~~~~~~~
147+
148+
- feat(sql): compile CEL ternary expressions to SQL CASE via
149+
``to_sql_case``, with ``case_when``/``comparison`` builders and a
150+
right-associative ternary parsing fix
151+
- fix(translator): smart operator label lookup is read-only — never
152+
creates vocabulary records or uses ``sudo()`` during expression
153+
compilation
154+
- test(translator): add coverage for the CEL translation cache helpers
155+
145156
19.0.2.0.0
146157
~~~~~~~~~~
147158

spp_cel_domain/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"name": "CEL Domain Query Builder",
44
"summary": "Write simple CEL-like expressions to filter records (OpenSPP/OpenG2P friendly)",
5-
"version": "19.0.2.0.0",
5+
"version": "19.0.2.1.0",
66
"license": "LGPL-3",
77
"development_status": "Production/Stable",
88
"author": "OpenSPP.org, OpenSPP Community",

spp_cel_domain/models/cel_sql_builder.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,42 @@ def union(self, queries: list[SQL]) -> SQL:
361361
result = SQL("(%s UNION %s)", result, query)
362362
return result
363363

364+
# =========================================================================
365+
# CASE WHEN Builder
366+
# =========================================================================
367+
368+
def case_when(self, condition: SQL, then_expr: SQL, else_expr: SQL) -> SQL:
369+
"""Build a CASE WHEN ... THEN ... ELSE ... END expression.
370+
371+
Args:
372+
condition: SQL boolean expression for the WHEN clause
373+
then_expr: SQL expression for the THEN result
374+
else_expr: SQL expression for the ELSE result
375+
376+
Returns:
377+
SQL CASE expression
378+
"""
379+
return SQL("(CASE WHEN %s THEN %s ELSE %s END)", condition, then_expr, else_expr)
380+
381+
def comparison(self, left: SQL, op: str, right: SQL) -> SQL | None:
382+
"""Build a SQL comparison expression from two SQL operands.
383+
384+
Uses operator whitelist to prevent injection (same pattern as term()).
385+
386+
Args:
387+
left: Left-hand SQL expression
388+
op: Comparison operator (=, !=, >, >=, <, <=)
389+
right: Right-hand SQL expression
390+
391+
Returns:
392+
SQL comparison or None if operator is unsupported
393+
"""
394+
op_sql_map = {"=": "=", "==": "=", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<="}
395+
sql_op = op_sql_map.get(op)
396+
if sql_op is None:
397+
return None
398+
return SQL("%s " + sql_op + " %s", left, right)
399+
364400
# =========================================================================
365401
# Helpers for Default Domain Processing
366402
# =========================================================================

spp_cel_domain/models/cel_translator.py

Lines changed: 282 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from odoo import api, fields, models
88
from odoo.fields import Domain
9+
from odoo.tools.sql import SQL
910

1011
from ..exceptions import CELMetricsUnavailableError, CELValidationError
1112
from ..services import cel_parser as P
@@ -1186,18 +1187,9 @@ def _smart_op_domain(self, field: str, op: str, right: Any, model_name: str) ->
11861187
.search([("value", "=", right.capitalize())], limit=None)
11871188
)
11881189
if not direct and right.lower() in {"male", "female"}:
1189-
code_defaults = {"male": "M", "female": "F"}
1190-
direct = (
1191-
self.env[comodel] # nosemgrep: odoo-sudo-without-context
1192-
.with_context(active_test=False)
1193-
.sudo()
1194-
.create(
1195-
{
1196-
"value": right.capitalize(),
1197-
"code": code_defaults[right.lower()],
1198-
}
1199-
)
1200-
)
1190+
# Lookup-only: the gender label is seeded as module
1191+
# data, never created here. Absent => match nothing.
1192+
return [("id", "=", 0)]
12011193
if direct:
12021194
resolved_ids = direct.ids
12031195
if op in ("=", "=="):
@@ -1395,6 +1387,284 @@ def invalidate_cache(self):
13951387
"""Invalidate the translation cache. Call when profiles or system settings change."""
13961388
invalidate_translation_cache()
13971389

1390+
# =========================================================================
1391+
# CEL → SQL CASE WHEN (value expression compiler)
1392+
# =========================================================================
1393+
1394+
@api.model
1395+
def to_sql_case(self, expression: str, model: str, alias: str, cfg: dict[str, Any] | None = None) -> SQL | None:
1396+
"""Compile a CEL expression to a SQL value expression.
1397+
1398+
Unlike translate() which produces Odoo domain filters (for WHERE clauses),
1399+
this produces a SQL expression that returns a value (for SELECT columns).
1400+
Primarily used for CASE WHEN expressions from CEL ternaries.
1401+
1402+
Args:
1403+
expression: CEL expression string
1404+
model: Odoo model name (e.g., "res.partner")
1405+
alias: SQL table alias (e.g., "ind")
1406+
cfg: Optional configuration dictionary
1407+
1408+
Returns:
1409+
SQL object representing the value expression, or None if the
1410+
expression cannot be compiled to SQL (caller should fall back
1411+
to Python evaluation).
1412+
"""
1413+
try:
1414+
ast = P.parse(expression)
1415+
return self._ast_to_sql_expr(ast, model, alias, cfg or {}, {})
1416+
except (NotImplementedError, CELValidationError):
1417+
return None
1418+
except (SyntaxError, ValueError, KeyError, AttributeError) as e:
1419+
_logger.warning("CEL to SQL compilation failed for '%s': %s", expression[:80], e)
1420+
return None
1421+
1422+
def _ast_to_sql_expr( # noqa: C901
1423+
self,
1424+
node: Any,
1425+
model: str,
1426+
alias: str,
1427+
cfg: dict[str, Any],
1428+
ctx: dict[str, Any],
1429+
) -> SQL | None:
1430+
"""Recursively compile a CEL AST node to a SQL expression.
1431+
1432+
Returns SQL for supported nodes, None for unsupported ones.
1433+
"""
1434+
# --- Ternary -> CASE with multiple WHEN clauses ---
1435+
if isinstance(node, P.Ternary):
1436+
return self._ternary_to_case_sql(node, model, alias, cfg, ctx)
1437+
1438+
# --- Literals ---
1439+
if isinstance(node, P.Literal):
1440+
if node.value is None:
1441+
return SQL("NULL")
1442+
if isinstance(node.value, bool):
1443+
return SQL("TRUE") if node.value else SQL("FALSE")
1444+
if isinstance(node.value, str):
1445+
return SQL("%s", node.value)
1446+
if isinstance(node.value, int | float):
1447+
return SQL("%s", node.value)
1448+
return None
1449+
1450+
# --- Compare ---
1451+
if isinstance(node, P.Compare):
1452+
return self._compare_to_sql(node, model, alias, cfg, ctx)
1453+
1454+
# --- Ident / Attr -> column reference ---
1455+
if isinstance(node, P.Ident):
1456+
if node.name == "r":
1457+
# "r" alone doesn't make sense as a value expression
1458+
return None
1459+
# CEL reserved words that are values
1460+
if node.name == "null":
1461+
return SQL("NULL")
1462+
if node.name == "true":
1463+
return SQL("TRUE")
1464+
if node.name == "false":
1465+
return SQL("FALSE")
1466+
# Plain field name
1467+
field_name = self._normalize_field_name(model, node.name)
1468+
return SQL("%s.%s", SQL.identifier(alias), SQL.identifier(field_name))
1469+
1470+
if isinstance(node, P.Attr):
1471+
return self._attr_to_sql_column(node, model, alias, cfg, ctx)
1472+
1473+
# --- Boolean connectives ---
1474+
if isinstance(node, P.And):
1475+
left = self._ast_to_sql_expr(node.left, model, alias, cfg, ctx)
1476+
right = self._ast_to_sql_expr(node.right, model, alias, cfg, ctx)
1477+
if left is None or right is None:
1478+
return None
1479+
return SQL("(%s AND %s)", left, right)
1480+
1481+
if isinstance(node, P.Or):
1482+
left = self._ast_to_sql_expr(node.left, model, alias, cfg, ctx)
1483+
right = self._ast_to_sql_expr(node.right, model, alias, cfg, ctx)
1484+
if left is None or right is None:
1485+
return None
1486+
return SQL("(%s OR %s)", left, right)
1487+
1488+
if isinstance(node, P.Not):
1489+
expr = self._ast_to_sql_expr(node.expr, model, alias, cfg, ctx)
1490+
if expr is None:
1491+
return None
1492+
return SQL("NOT (%s)", expr)
1493+
1494+
# --- Unsupported: BinOp, Neg, InOp, Call ---
1495+
return None
1496+
1497+
def _ternary_to_case_sql(
1498+
self,
1499+
node: P.Ternary,
1500+
model: str,
1501+
alias: str,
1502+
cfg: dict[str, Any],
1503+
ctx: dict[str, Any],
1504+
) -> SQL | None:
1505+
"""Flatten a chain of nested Ternary nodes into a single CASE expression.
1506+
1507+
CEL: a ? x : b ? y : c ? z : w
1508+
SQL: CASE WHEN a THEN x WHEN b THEN y WHEN c THEN z ELSE w END
1509+
1510+
This avoids nested CASE WHEN that causes SQL type mismatch issues.
1511+
"""
1512+
when_clauses = []
1513+
current = node
1514+
1515+
while isinstance(current, P.Ternary):
1516+
cond_sql = self._ast_to_sql_expr(current.condition, model, alias, cfg, ctx)
1517+
then_sql = self._ast_to_sql_expr(current.true_expr, model, alias, cfg, ctx)
1518+
if cond_sql is None or then_sql is None:
1519+
return None
1520+
when_clauses.append((cond_sql, then_sql))
1521+
current = current.false_expr
1522+
1523+
# The final false_expr is the ELSE value
1524+
else_sql = self._ast_to_sql_expr(current, model, alias, cfg, ctx)
1525+
if else_sql is None:
1526+
return None
1527+
1528+
# Build: CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ... ELSE vn END
1529+
parts = [SQL("CASE")]
1530+
for cond, then in when_clauses:
1531+
parts.append(SQL("WHEN %s THEN %s", cond, then))
1532+
parts.append(SQL("ELSE %s END", else_sql))
1533+
1534+
return SQL(" ").join(parts)
1535+
1536+
def _compare_to_sql(
1537+
self,
1538+
cmp: P.Compare,
1539+
model: str,
1540+
alias: str,
1541+
cfg: dict[str, Any],
1542+
ctx: dict[str, Any],
1543+
) -> SQL | None:
1544+
"""Compile a Compare AST node to a SQL boolean expression."""
1545+
from .cel_sql_builder import SQLBuilder
1546+
1547+
builder = SQLBuilder(self.env)
1548+
opmap = {"EQ": "=", "NE": "!=", "GT": ">", "GE": ">=", "LT": "<", "LE": "<="}
1549+
1550+
# --- age_years(r.field) <op> N -> date arithmetic ---
1551+
if (
1552+
isinstance(cmp.left, P.Call)
1553+
and isinstance(cmp.left.func, P.Ident)
1554+
and cmp.left.func.name == "age_years"
1555+
and cmp.left.args
1556+
):
1557+
return self._age_years_compare_to_sql(cmp, model, alias, cfg, ctx, opmap)
1558+
1559+
# --- null comparisons: field == null / field != null ---
1560+
right_is_null = (isinstance(cmp.right, P.Literal) and cmp.right.value is None) or (
1561+
isinstance(cmp.right, P.Ident) and cmp.right.name == "null"
1562+
)
1563+
left_is_null = (isinstance(cmp.left, P.Literal) and cmp.left.value is None) or (
1564+
isinstance(cmp.left, P.Ident) and cmp.left.name == "null"
1565+
)
1566+
1567+
if right_is_null:
1568+
left_sql = self._ast_to_sql_expr(cmp.left, model, alias, cfg, ctx)
1569+
if left_sql is None:
1570+
return None
1571+
if cmp.op == "EQ":
1572+
return SQL("%s IS NULL", left_sql)
1573+
elif cmp.op == "NE":
1574+
return SQL("%s IS NOT NULL", left_sql)
1575+
return None
1576+
1577+
if left_is_null:
1578+
right_sql = self._ast_to_sql_expr(cmp.right, model, alias, cfg, ctx)
1579+
if right_sql is None:
1580+
return None
1581+
if cmp.op == "EQ":
1582+
return SQL("%s IS NULL", right_sql)
1583+
elif cmp.op == "NE":
1584+
return SQL("%s IS NOT NULL", right_sql)
1585+
return None
1586+
1587+
# --- general comparison: left <op> right ---
1588+
sql_op = opmap.get(cmp.op)
1589+
if sql_op is None:
1590+
return None
1591+
1592+
left_sql = self._ast_to_sql_expr(cmp.left, model, alias, cfg, ctx)
1593+
right_sql = self._ast_to_sql_expr(cmp.right, model, alias, cfg, ctx)
1594+
if left_sql is None or right_sql is None:
1595+
return None
1596+
1597+
return builder.comparison(left_sql, sql_op, right_sql)
1598+
1599+
def _age_years_compare_to_sql(
1600+
self,
1601+
cmp: P.Compare,
1602+
model: str,
1603+
alias: str,
1604+
cfg: dict[str, Any],
1605+
ctx: dict[str, Any],
1606+
opmap: dict[str, str],
1607+
) -> SQL | None:
1608+
"""Compile age_years(r.field) <op> N to date arithmetic SQL.
1609+
1610+
age_years(r.birthdate) < 18 becomes:
1611+
r.birthdate > CURRENT_DATE - INTERVAL '18 years'
1612+
1613+
The operator inversion matches _cmp_to_leaf() logic.
1614+
"""
1615+
# Resolve the field being passed to age_years()
1616+
field_arg = cmp.left.args[0]
1617+
field_sql = self._ast_to_sql_expr(field_arg, model, alias, cfg, ctx)
1618+
if field_sql is None:
1619+
return None
1620+
1621+
# The right side must be a numeric literal
1622+
if not isinstance(cmp.right, P.Literal) or not isinstance(cmp.right.value, int | float):
1623+
return None
1624+
1625+
n = int(cmp.right.value)
1626+
interval_n = SQL("CURRENT_DATE - INTERVAL %s", f"{n} years")
1627+
1628+
op = cmp.op
1629+
if op == "LT":
1630+
# age < n => birthdate > cutoff (younger than n)
1631+
return SQL("%s > %s", field_sql, interval_n)
1632+
elif op == "LE":
1633+
# age <= n => birthdate >= cutoff
1634+
return SQL("%s >= %s", field_sql, interval_n)
1635+
elif op == "GT":
1636+
# age > n => birthdate < cutoff (older than n)
1637+
return SQL("%s < %s", field_sql, interval_n)
1638+
elif op == "GE":
1639+
# age >= n => birthdate <= cutoff
1640+
return SQL("%s <= %s", field_sql, interval_n)
1641+
elif op == "EQ":
1642+
# age == n => birthdate in (cutoff_{n+1}, cutoff_n]
1643+
interval_n1 = SQL("CURRENT_DATE - INTERVAL %s", f"{n + 1} years")
1644+
return SQL("(%s > %s AND %s <= %s)", field_sql, interval_n1, field_sql, interval_n)
1645+
elif op == "NE":
1646+
# age != n => birthdate <= cutoff_{n+1} OR birthdate > cutoff_n
1647+
interval_n1 = SQL("CURRENT_DATE - INTERVAL %s", f"{n + 1} years")
1648+
return SQL("(%s <= %s OR %s > %s)", field_sql, interval_n1, field_sql, interval_n)
1649+
1650+
return None
1651+
1652+
def _attr_to_sql_column(
1653+
self,
1654+
node: P.Attr,
1655+
model: str,
1656+
alias: str,
1657+
cfg: dict[str, Any],
1658+
ctx: dict[str, Any],
1659+
) -> SQL | None:
1660+
"""Compile an Attr node (e.g., r.birthdate) to a SQL column reference."""
1661+
if isinstance(node.obj, P.Ident) and node.obj.name == "r":
1662+
field_name = self._normalize_field_name(model, node.name)
1663+
return SQL("%s.%s", SQL.identifier(alias), SQL.identifier(field_name))
1664+
1665+
# Nested attr (e.g., r.gender_id.code) - not supported in value context
1666+
return None
1667+
13981668
def _negate_leaf(self, plan):
13991669
if not isinstance(plan, LeafDomain):
14001670
return None

spp_cel_domain/readme/HISTORY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
### 19.0.2.1.0
2+
3+
- feat(sql): compile CEL ternary expressions to SQL CASE via `to_sql_case`, with `case_when`/`comparison` builders and a right-associative ternary parsing fix
4+
- fix(translator): smart operator label lookup is read-only — never creates vocabulary records or uses `sudo()` during expression compilation
5+
- test(translator): add coverage for the CEL translation cache helpers
6+
17
### 19.0.2.0.0
28

39
- Initial migration to OpenSPP2

0 commit comments

Comments
 (0)