This document describes the implementation of the CEL executor extension for event data
queries in models/cel_event_executor.py.
The implementation extends spp.cel.executor with support for three event query plan
types:
- EventValueCompare: Compare field values from registrant events
- EventExists: Check for event existence
- EventsAggregate: Aggregate values across multiple events
Each query plan type has two execution paths:
- SQL Fast Path: Optimized SQL for performance at scale
- Python Fallback: Full-featured evaluation when SQL not feasible
SELECT DISTINCT e.partner_id
FROM (
SELECT DISTINCT ON (e.partner_id) e.*
FROM spp_event_data e
WHERE e.event_type_code = 'survey'
AND e.state IN ('active', 'superseded', 'expired')
AND e.collection_date >= CURRENT_DATE - INTERVAL '365 days'
ORDER BY e.partner_id, e.collection_date DESC, e.id DESC
) latest_event
WHERE (latest_event.data_json->>'income')::numeric > 500Key optimizations:
- DISTINCT ON for selecting latest/first events
- Indexes on (partner_id, event_type_code, state, collection_date)
- Type casting for JSON field comparisons
- Single query execution for all candidates
SELECT DISTINCT e.partner_id
FROM spp_event_data e
WHERE e.event_type_code = 'assessment'
AND e.state = 'active'
AND e.collection_date >= CURRENT_DATE - INTERVAL '365 days'Key optimizations:
- Simple SELECT DISTINCT for existence check
- Minimal query complexity
- No subqueries needed
SELECT e.partner_id
FROM spp_event_data e
WHERE e.event_type_code = 'attendance'
AND e.state = 'active'
AND e.collection_date BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY e.partner_id
HAVING COUNT(*) >= 150Key optimizations:
- GROUP BY with HAVING for aggregation
- Direct numeric field extraction from JSON
- Support for count, sum, avg, min, max
The implementation supports multiple temporal filter types:
| Filter | SQL Implementation | Notes |
|---|---|---|
after |
collection_date >= %s |
Direct date comparison |
before |
collection_date <= %s |
Direct date comparison |
within_days |
collection_date >= CURRENT_DATE - INTERVAL '%s days' |
Relative to current date |
within_months |
collection_date >= CURRENT_DATE - INTERVAL '%s months' |
Relative to current date |
period |
collection_date BETWEEN start AND end |
Named periods (YYYY, YYYY-QN, YYYY-MM) |
| Mode | SQL Implementation | Default States |
|---|---|---|
active |
Simple filter, no ordering | ['active'] |
latest |
DISTINCT ON ... ORDER BY collection_date DESC, id DESC |
['active', 'superseded', 'expired'] |
latest_active |
DISTINCT ON ... ORDER BY collection_date DESC, id DESC |
['active'] |
first |
DISTINCT ON ... ORDER BY collection_date ASC, id ASC |
['active', 'superseded', 'expired'] |
any |
Simple filter, no ordering | ['active'] |
auto |
Resolves based on event type is_one_active_per_registrant |
Varies |
JSON field extraction handles multiple data types:
-- Boolean
(e.data_json->>'field')::boolean = true
-- Numeric (int/float)
(e.data_json->>'field')::numeric > 500
-- String
(e.data_json->>'field') = 'value'
-- NULL
(e.data_json->>'field') IS NULL- SQL errors: Caught and logged, falls back to Python path
- Type conversion errors: Handled gracefully with defaults
- Missing fields: Returns NULL/None
- Invalid periods: Logged warning, returns empty range
The Python path is used when:
- Default value specified (requires post-processing)
- Complex where predicates in aggregations (NOTE:
where_predicateis not yet implemented — registrants with this parameter are silently skipped with a warning log) - SQL execution fails
- Non-standard comparison operators
Implementation details:
- Iterates through candidate registrants
- Builds Odoo domain for event search
- Applies selection mode in Python
- Evaluates comparisons with type coercion
| Operation | Method | Complexity | Expected Performance |
|---|---|---|---|
| EventValueCompare (SQL) | Single query with subquery | O(n log n) | <500ms for 1M registrants |
| EventExists (SQL) | Simple SELECT DISTINCT | O(n) | <200ms for 1M registrants |
| EventsAggregate (SQL) | GROUP BY with HAVING | O(n) | <1s for 1M registrants |
| EventValueCompare (Python) | Loop with search per registrant | O(n*m) | Only for small cohorts |
All execution paths log performance metrics:
_logger.info(
"[CEL EVENT] EventValueCompare SQL: event_type=%s field=%s matches=%d",
plan.event_type,
plan.field_name,
len(partner_ids),
)
_logger.debug(
"[CEL EVENT] EventValueCompare SQL details: op=%s rhs=%r",
plan.op,
plan.rhs,
)Log tags:
[CEL EVENT]- Event executor operations- Includes: event_type, field, match count (op/rhs at DEBUG level only)
- Separate log entries for SQL vs Python paths
class CelEventExecutor(models.AbstractModel):
_inherit = "spp.cel.executor"
def _execute_plan(self, model: str, plan: Any, metrics_info: list[dict[str, Any]] | None = None) -> list[int]:
if isinstance(plan, EventValueCompare):
return self._exec_event_value(model, plan)
# ...
return super()._execute_plan(model, plan, metrics_info)Depends on spp.event.data model with:
- Fields:
partner_id,event_type_code,state,collection_date,data_json - Methods:
get_data_value(field_name, default)
All period formats are supported via cel_event_functions.parse_period():
YYYY: Full year (e.g., '2024')YYYY-QN: Quarter (e.g., '2024-Q1')YYYY-MM: Month (e.g., '2024-03')YYYY-HN: Half year (e.g., '2024-H1')YYYY-WNN: ISO week (e.g., '2024-W01')
-
SQL Path Coverage
- Test each selection mode (active, latest, latest_active, first)
- Test each temporal filter type
- Test each field type (bool, numeric, string, null)
- Test each aggregation function (count, sum, avg, min, max)
-
Python Path Coverage
- Test with default values
- Test error handling
- Test type coercion
-
Performance Testing
- Benchmark with 100K, 500K, 1M registrants
- Test with varying event counts per registrant
- Measure query execution times
-
Edge Cases
- No matching events
- Missing JSON fields
- Invalid event type codes
- Conflicting temporal filters
- Invalid period formats
-
SQL where_predicate Support
- Parse simple CEL predicates to SQL WHERE clauses
- Enable SQL fast path for filtered aggregations
-
Event Type Registry Cache
- Cache
is_one_active_per_registrantflag - Avoid repeated lookups in
_resolve_select_mode
- Cache
-
Query Result Caching
- Cache results for identical queries within request
- Invalidate on event data changes
-
Batch Optimization
- When multiple event conditions in same expression
- Combine into single query with JOINs
odoo.models: Base model frameworkodoo.tools.sql.SQL: SQL query builderspp_cel_domain: Base CEL executorspp_event_data: Event data model