|
6 | 6 |
|
7 | 7 | from odoo import api, fields, models |
8 | 8 | from odoo.fields import Domain |
| 9 | +from odoo.tools.sql import SQL |
9 | 10 |
|
10 | 11 | from ..exceptions import CELMetricsUnavailableError, CELValidationError |
11 | 12 | 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) -> |
1186 | 1187 | .search([("value", "=", right.capitalize())], limit=None) |
1187 | 1188 | ) |
1188 | 1189 | 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)] |
1201 | 1193 | if direct: |
1202 | 1194 | resolved_ids = direct.ids |
1203 | 1195 | if op in ("=", "=="): |
@@ -1395,6 +1387,284 @@ def invalidate_cache(self): |
1395 | 1387 | """Invalidate the translation cache. Call when profiles or system settings change.""" |
1396 | 1388 | invalidate_translation_cache() |
1397 | 1389 |
|
| 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 | + |
1398 | 1668 | def _negate_leaf(self, plan): |
1399 | 1669 | if not isinstance(plan, LeafDomain): |
1400 | 1670 | return None |
|
0 commit comments