Workflow Aggregate Annotations
Developer guide for the Enezaframework workflow engine. Part of the Workflow series.
Table of Contents
- Overview
parse_annotations()- Supported Aggregate Functions
- Relationship Validation
- Using Annotations in Filter Rules
- Execution Order
- Backward Compatibility
- Related Guides
Overview
Aggregate annotations let a query filter on computed values from related records — "documents whose total line amount exceeds 1000", "orders with more than 3 lines". An annotations array in filters_json is converted into Django aggregate expressions and applied with annotate() before the rules are filtered.
parse_annotations()
Location: enezaworkflowengine/utils.py:189
Converts an annotations list into a dict of Django aggregates suitable for queryset.annotate(**result).
from enezaworkflowengine.utils import parse_annotations
annotations = [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"},
{"function": "count", "field": "id", "related_model": "lines", "alias": "line_count"},
{"function": "avg", "field": "price", "related_model": "items", "alias": "avg_price"},
{"function": "min", "field": "date", "related_model": "events", "alias": "first_event"},
{"function": "max", "field": "value", "related_model": "items", "alias": "max_value"},
]
result = parse_annotations(Invoice, annotations)
# {
# 'total_amount': Sum('lines__amount'),
# 'line_count': Count('lines__id'),
# 'avg_price': Avg('items__price'),
# 'first_event': Min('events__date'),
# 'max_value': Max('items__value'),
# }
queryset = Invoice.objects.annotate(**result)
Required keys per annotation: function, related_model, alias, and field — except count, where field is optional. Missing keys raise ValueError (utils.py:219-225).
flowchart TD
A["annotation dict"] --> B["read function, field, related_model, alias"]
B --> C{"required keys present?"}
C -->|No| ERR["ValueError: missing keys"]
C -->|Yes| D{"function supported?"}
D -->|No| ERR2["ValueError: unsupported function"]
D -->|Yes| E["_validate_relationship(model_class, related_model)"]
E --> F{"count and no field?"}
F -->|Yes| G["lookup = related_model"]
F -->|No| H["lookup = related_model__field"]
G --> I["result[alias] = Aggregate(lookup)"]
H --> ISupported Aggregate Functions
AGGREGATE_FUNCTION_MAP (utils.py:180-186):
| Function | Django Aggregate | Example lookup |
|---|---|---|
sum |
Sum |
Sum('lines__amount') |
count |
Count |
Count('lines__id') or Count('lines') |
avg |
Avg |
Avg('items__price') |
min |
Min |
Min('events__date') |
max |
Max |
Max('items__value') |
For
count, iffieldis omitted the lookup is justrelated_model(Count('lines')); iffieldis given it becomesrelated_model__field. Use the bare form for a row count.
Relationship Validation
Before building the expression, _validate_relationship() (utils.py:245-274) checks that related_model exists on the primary model, in this order:
- Forward FK/M2M —
model_class._meta.get_field(related_model)andfield.is_relation. - Reverse relations —
model_class._meta.related_objectsmatchingget_accessor_name(). - Reverse M2M —
model_class._meta.many_to_manymatchingget_accessor_name().
If none match, it raises:
'bad_relation' is not a valid relationship on Invoice. Check the related_model name against FK fields, M2M fields, or reverse relation accessors.
The validator is what allows both forward fields (vendor) and reverse accessors (lines for a related_name).
Using Annotations in Filter Rules
Reference an annotation by its alias, using the alias for both id and field:
{
"annotations": [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
],
"rules": [
{
"id": "total_amount",
"field": "total_amount",
"type": "double",
"operator": "greater",
"value": "1000"
}
]
}
Among the supported operators are equal, not_equal, contains, greater, greater_or_equal, less, and less_or_equal (the full set is handled by the rule parser in enezaworkflowengine/utils.py).
Execution Order
get_queryset() runs annotate() before filter() (models/querybuilder.py:36-54). Django therefore has the aggregate aliases available when it evaluates the rules, and automatically pushes aggregate comparisons into a HAVING clause rather than WHERE.
flowchart LR
A["model.objects"] --> B["annotate(total_amount=Sum('lines__amount'))"]
B --> C["filter(total_amount__gt=1000, status='open')"]
C --> D["SQL: GROUP BY ... HAVING SUM(...) > 1000"]Backward Compatibility
- Queries without an
annotationskey run exactly as before — no aggregation. - Old-format JSON (
condition+rules) continues to work. execute_new_format()supports annotations as well.