Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions spp_cel_domain/models/cel_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,11 @@ def _domain_to_id_sql(self, model: str, domain: list[Any]) -> SQL | None:
expr = osv_expression.expression(model=Model, domain=domain)
query = expr.query

# Get the WHERE clause
where_clause = query.where_clause
if not where_clause:
# No WHERE clause means all records
return SQL("(SELECT id FROM %s)", SQL.identifier(table))

# Build the full SELECT query
# Note: where_clause is already a SQL object in Odoo 19
return SQL(
"(SELECT %s.id FROM %s WHERE %s)",
SQL.identifier(table),
SQL.identifier(table),
where_clause,
)
# Use query.select() to get the full SQL including FROM clause
# with all JOINs (needed for related field domains like gender_id.uri)
select_sql = query.select(SQL.identifier(table, "id"))

return SQL("(%s)", select_sql)
except Exception as e:
self._logger.debug("[CEL SQL] Failed to convert domain to SQL: %s", e)
return None
Expand Down
47 changes: 9 additions & 38 deletions spp_cel_domain/models/cel_sql_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,57 +157,28 @@ def select_ids_from_domain(self, model: str, domain: list[Any]) -> SQL | None:
"""Generate SELECT id FROM model WHERE domain.

Uses expression.expression() to include record rules.
Uses Query.select() to include all necessary JOINs (e.g. for
related field lookups like gender_id.uri).
Returns None if domain cannot be converted.

This is the PRIMARY method for generating subqueries.
All other methods should use this for model references.
"""
if not domain:
# Empty domain selects all IDs (respecting record rules)
Model = self.env[model]
table = Model._table
# Still need to apply record rules even for empty domain
try:
from odoo.osv import expression as osv_expression

expr = osv_expression.expression(model=Model, domain=[])
query = expr.query
where_clause = query.where_clause
if where_clause:
return SQL(
"(SELECT %s.id FROM %s WHERE %s)",
SQL.identifier(table),
SQL.identifier(table),
where_clause,
)
return SQL("(SELECT id FROM %s)", SQL.identifier(table))
except Exception as e:
_logger.debug("[SQLBuilder] Failed for empty domain: %s", e)
return SQL("(SELECT id FROM %s)", SQL.identifier(table))

try:
from odoo.osv import expression as osv_expression

Model = self.env[model]
table = Model._table

# Build the expression and extract query
expr = osv_expression.expression(model=Model, domain=domain)
# Build the expression (applies record rules even for empty domain)
expr = osv_expression.expression(model=Model, domain=domain or [])
query = expr.query

# Get the WHERE clause
where_clause = query.where_clause
if not where_clause:
# No WHERE clause means all records
return SQL("(SELECT id FROM %s)", SQL.identifier(table))

# Build the full SELECT query
return SQL(
"(SELECT %s.id FROM %s WHERE %s)",
SQL.identifier(table),
SQL.identifier(table),
where_clause,
)
# Use query.select() to get the full SQL including FROM clause
# with all JOINs (needed for related field domains like gender_id.uri)
select_sql = query.select(SQL.identifier(table, "id"))

return SQL("(%s)", select_sql)
except Exception as e:
_logger.debug("[SQLBuilder] Failed to convert domain to SQL: %s", e)
return None
Expand Down
7 changes: 5 additions & 2 deletions spp_cel_domain/models/cel_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ def _flatten_attr(a):
rec = self.env["spp.program"].search([("name", "=", name)], limit=1)
pid = rec.id or None
return LeafDomain(model, [("id", "!=", 0)]), f"PROGRAM({name})={pid}"
# Boolean field used as predicate (e.g., m._link.is_ended)
# Field used as bare predicate (e.g., m._link.is_ended, program_membership_ids)
if isinstance(node, P.Attr | P.Ident):
fld, mdl = self._resolve_field(model, node, cfg, ctx)
target_model = mdl or model
Expand All @@ -637,7 +637,10 @@ def _flatten_attr(a):
ft = model_fields.get(fld)
if ft and getattr(ft, "type", None) == "boolean":
return LeafDomain(target_model, [(fld, "=", True)]), f"{fld} is True"
# Field exists but is not boolean — cannot use as bare predicate
# Relational fields as bare predicates: treat as "has records"
if ft and getattr(ft, "type", None) in ("one2many", "many2many"):
return LeafDomain(target_model, [(fld, "!=", False)]), f"{fld} is not empty"
# Field exists but is not boolean or relational — cannot use as bare predicate
if ft:
raise NotImplementedError(
f"Field '{fld}' is of type '{getattr(ft, 'type', '?')}', not boolean. "
Expand Down
21 changes: 15 additions & 6 deletions spp_cel_domain/models/cel_variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,16 +530,25 @@ def unlink(self):
return super().unlink()

def _invalidate_resolver_cache(self):
"""Invalidate the variable resolver cache.
"""Invalidate all CEL caches.

Called when variable definitions change to ensure deferred resolution
uses the updated definitions.
Called when variable definitions change to ensure:
- Variable resolver cache uses updated variable definitions
- Translation cache rebuilds with new variable expansions
- Profile cache reflects any configuration changes

This prevents stale cache issues where expressions continue to
use old variable definitions after modifications.
"""
try:
resolver = self.env["spp.cel.variable.resolver"]
resolver.invalidate_variable_cache()
# Invalidate all CEL caches via the service facade
# This ensures profile cache, translation cache, and resolver cache
# are all cleared in a coordinated manner
cel_service = self.env["spp.cel.service"]
cel_service.invalidate_caches()
_logger.debug("CEL caches invalidated after variable change")
except Exception as e:
_logger.debug("Could not invalidate resolver cache: %s", e)
_logger.warning("Could not invalidate CEL caches: %s", e)

# ═══════════════════════════════════════════════════════════════════════
# HELPER METHODS
Expand Down
1 change: 1 addition & 0 deletions spp_cel_domain/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@
from . import test_data_value
from . import test_data_provider
from . import test_multi_company
from . import test_cel_relational_predicate
79 changes: 79 additions & 0 deletions spp_cel_domain/tests/test_cel_relational_predicate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Tests for relational fields (one2many, many2many) used as bare predicates.

When a user writes a CEL expression like `program_membership_ids` (without
a comparison operator), the translator should treat it as a truthiness
check: "has records" → True, "no records" → False. This matches Python
and CEL semantics where non-empty collections are truthy.
"""

from odoo.tests import TransactionCase, tagged


@tagged("post_install", "-at_install")
class TestRelationalBarePredicates(TransactionCase):
"""Test that one2many and many2many fields work as bare predicates."""

def setUp(self):
super().setUp()
self.service = self.env["spp.cel.service"]

def test_one2many_field_as_bare_predicate_compiles(self):
"""A one2many field used as a bare predicate should compile.

Expression like `program_membership_ids` should be treated as
checking whether the field has records (not empty).
"""
# program_membership_ids is a One2many on res.partner
# (from spp_programs module)
if "program_membership_ids" not in self.env["res.partner"]._fields:
self.skipTest("spp_programs not installed (no program_membership_ids field)")

result = self.service.compile_expression(
"program_membership_ids",
"registry_groups",
)
self.assertTrue(result["valid"], f"Error: {result.get('error')}")
# Should produce a domain checking the field is not empty
self.assertIsInstance(result["domain"], list)

def test_many2many_field_as_bare_predicate_compiles(self):
"""A many2many field used as a bare predicate should compile."""
# Find any many2many field on res.partner for testing
partner_fields = self.env["res.partner"]._fields
m2m_field = None
for fname, fobj in partner_fields.items():
if getattr(fobj, "type", None) == "many2many":
m2m_field = fname
break

if not m2m_field:
self.skipTest("No many2many field found on res.partner")

result = self.service.compile_expression(
m2m_field,
"registry_groups",
)
self.assertTrue(result["valid"], f"Error: {result.get('error')}")
self.assertIsInstance(result["domain"], list)

def test_one2many_predicate_produces_correct_domain(self):
"""Bare one2many predicate should produce a '!= False' domain.

This is the standard Odoo pattern for checking if a relational
field has records.
"""
if "program_membership_ids" not in self.env["res.partner"]._fields:
self.skipTest("spp_programs not installed (no program_membership_ids field)")

result = self.service.compile_expression(
"program_membership_ids",
"registry_groups",
)
self.assertTrue(result["valid"], f"Error: {result.get('error')}")
# The domain should contain a check for "has records"
domain = result["domain"]
# Look for the exact expected leaf in the domain
expected_leaf = ("program_membership_ids", "!=", False)
found = any(leaf == expected_leaf for leaf in domain if isinstance(leaf, tuple))
self.assertTrue(found, f"Expected '{expected_leaf}' to be in domain, but got: {domain}")
Loading
Loading