Workflow Aggregate Annotations

Developer guide for the Enezaframework workflow engine. Part of the Workflow series.

Table of Contents

  1. Overview
  2. parse_annotations()
  3. Supported Aggregate Functions
  4. Relationship Validation
  5. Using Annotations in Filter Rules
  6. Execution Order
  7. Backward Compatibility
  8. 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).

python
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 --> I

Supported 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, if field is omitted the lookup is just related_model (Count('lines')); if field is given it becomes related_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:

  1. Forward FK/M2M — model_class._meta.get_field(related_model) and field.is_relation.
  2. Reverse relations — model_class._meta.related_objects matching get_accessor_name().
  3. Reverse M2M — model_class._meta.many_to_many matching get_accessor_name().

If none match, it raises:

text
'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:

json
{
  "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 annotations key run exactly as before — no aggregation.
  • Old-format JSON (condition + rules) continues to work.
  • execute_new_format() supports annotations as well.