Workflow Query Enhancements — Developer Guide

Table of Contents

  1. Overview
  2. Architecture
  3. Quick Start
  4. Object ID Filtering
  5. Aggregate Annotations
  6. JSON Format Reference
  7. View Layer Integration
  8. Workflow Engine Auto-Extraction
  9. Aggregate Metadata API
  10. Error Handling & Validation
  11. Complete Examples
  12. Reference

Overview

The Workflow Query Enhancements introduce two features to the SavedQuery query execution system:

  1. Main Object ID Filtering — Automatically restrict query results to a single record by passing object_id to get_queryset(). The workflow engine auto-extracts this from the WorkflowInstance or document it already holds, so callers never manually provide it.

  2. Aggregate Annotations — Compute SUM, COUNT, AVG, MIN, or MAX on child/related model fields and use those computed values in filter rules. Annotations are defined in a separate top-level annotations array in the filters_json and are applied via Django's annotate() before filtering.

Problem solved: - Previously, condition step queries ran against the entire model table, producing false positives when duplicate data existed across documents. With object ID filtering, queries are scoped to the exact document being evaluated. - Previously, there was no way to filter based on aggregated values from related records (e.g., "documents where total line amount > 1000"). The annotation system enables these aggregate-aware conditions.

User outcome: 1. Workflow condition queries now automatically filter to the specific document being evaluated — no configuration needed. 2. Developers can define aggregate annotations in SavedQuery JSON to compute SUM, COUNT, AVG, MIN, MAX on related model fields. 3. Annotation aliases can be referenced in filter rules alongside regular fields. 4. The query builder UI receives aggregate metadata for presenting aggregation options to users.


Architecture

text
┌──────────────────────────────────────────────────────────────┐
│ SavedQuery.get_queryset(object_id=None)                       │
│   ┌──────────────────────────────────────────────────────┐   │
│   │ 1. Resolve model_class from content_type              │   │
│   │ 2. Parse annotations from filters_json                │   │
│   │ 3. model_class.objects.annotate(**annotations)        │   │
│   │ 4. Build Q object from rules (existing parsers)       │   │
│   │ 5. qs.filter(q)                                       │   │
│   │ 6. If object_id: qs.filter(pk=object_id)              │   │
│   └──────────────────────────────────────────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│ parse_annotations(model_class, annotations_list)              │
│   - Maps function names → Django aggregates                  │
│   - Validates related_model is a valid relationship          │
│   - Builds lookup strings: {related_model}__{field}          │
│   - Returns {alias: Aggregate(...)} dict                     │
├──────────────────────────────────────────────────────────────┤
│ Auto-Extraction (Workflow Engine)                             │
│   evaluate_condition_for_document(document, query)            │
│     → object_id = str(document.pk)                           │
│     → query.get_queryset(object_id=object_id)                │
│                                                               │
│   ConditionStepHandler._evaluate_and_route(..., wf_instance)  │
│     → query.get_queryset(object_id=wf_instance.object_id)    │
│                                                               │
│   evaluate_condition_step() (Celery task)                     │
│     → delegates to evaluate_condition_for_document()          │
│     → auto-extracts via the service function above            │
├──────────────────────────────────────────────────────────────┤
│ UI Layer (QueryBuilderMixin)                                  │
│   get_aggregate_options(model_config)                         │
│     → For each relationship, discovers numeric fields         │
│     → Returns {function, field, related_model, label, alias}  │
│     → Injected into filters_by_model as 'aggregates' key      │
└──────────────────────────────────────────────────────────────┘

Data flow for object ID:

text
WorkflowInstance (has object_id + content_type)
    │
    ├─→ evaluate_condition_for_document(document, query)
    │       document.pk → str(object_id)
    │       → query.get_queryset(object_id=object_id)
    │
    ├─→ ConditionStepHandler._evaluate_and_route(..., wf_instance)
    │       wf_instance.object_id
    │       → query.get_queryset(object_id=wf_instance.object_id)
    │
    └─→ evaluate_condition_step() (Celery task)
            → evaluate_condition_for_document(wf_instance.document, ...)
            → auto-extracts via service function

Non-workflow callers (reports, admin query builder, tests):
    → query.get_queryset()  — object_id defaults to None, no filtering applied

Data flow for aggregates:

text
UI sends or workflow loads SavedQuery with filters_json:
{
  "condition": "AND",
  "annotations": [
    {"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
  ],
  "rules": [
    {"id": "total_amount", "field": "total_amount", "type": "double", "operator": "greater", "value": "1000"}
  ]
}

→ SavedQuery.get_queryset()
  → parse_annotations(model_class, annotations)
    → validates 'lines' is a valid relationship on the model
    → returns {'total_amount': Sum('lines__amount')}
  → qs = model_class.objects.annotate(total_amount=Sum('lines__amount'))
  → qs.filter(total_amount__gt=1000)
  → Django pushes aggregate filter to HAVING clause automatically

Quick Start

Defining an Aggregate-Enabled Query

json
{
  "condition": "AND",
  "annotations": [
    {"function": "sum", "field": "line_total", "related_model": "lines", "alias": "total"},
    {"function": "count", "field": "id", "related_model": "lines", "alias": "item_count"}
  ],
  "rules": [
    {"id": "total", "field": "total", "type": "double", "operator": "greater", "value": "500"},
    {"id": "item_count", "field": "item_count", "type": "integer", "operator": "greater", "value": "3"}
  ]
}

This query filters documents where the total of all line amounts exceeds 500 AND there are more than 3 line items.

Saving Through the API

POST to SaveQueryView:

json
{
  "name": "Large Orders",
  "model_slug": "frameworktestdocument",
  "workflow_name": "InvoiceWorkflow",
  "query": {
    "condition": "AND",
    "annotations": [
      {"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
    ],
    "rules": [
      {"id": "total_amount", "field": "total_amount", "type": "double", "operator": "greater", "value": "1000"}
    ]
  }
}

Or send annotations as a separate top-level key — the view merges it into filters_json:

json
{
  "name": "Large Orders",
  "model_slug": "frameworktestdocument",
  "workflow_name": "InvoiceWorkflow",
  "query": {
    "condition": "AND",
    "rules": [...]
  },
  "annotations": [
    {"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
  ]
}

Executing with Object ID

text
GET /workflow/savedquery/5/?execute=1&object_id=550e8400-e29b-41d4-a716-446655440000

The view passes object_id to SavedQuery.execute(object_id=...), which filters results to only that document.

Using in the Workflow Engine

No configuration needed. When a condition step evaluates:

python
# Web path: step_handlers.py — has workflow_instance
condition_query.get_queryset(object_id=workflow_instance.object_id)

# Celery path: workflow_tasks.py — delegates to service function
evaluate_condition_for_document(workflow_instance.document, condition_query)
# → extracts document.pk → condition_query.get_queryset(object_id=document.pk)

Object ID Filtering

SavedQuery.get_queryset(object_id=None)

The get_queryset() method now accepts an optional object_id parameter. When provided, the resulting queryset is filtered to only the record with that primary key.

python
def get_queryset(self, object_id=None):
    model_class = self.content_type.model_class()
    if not model_class:
        raise ValueError("Model not found")

    # ... annotation logic ...

    result_qs = qs.filter(q)

    if object_id is not None:
        result_qs = result_qs.filter(pk=object_id)

    return result_qs

object_id Type

object_id is a string (UUID for EnezaModel subclasses). Django's ORM handles UUID strings natively via pk=..., so no manual type coercion is needed.

Which Methods Accept object_id

Method Signature Notes
SavedQuery.get_queryset() get_queryset(object_id=None) Primary method
SavedQuery.execute() execute(object_id=None) Alias — forwards to get_queryset()
SavedQuery.execute_new_format() execute_new_format(object_id=None) New format parser — also supports annotations

Non-Workflow Callers

Callers outside the workflow engine (reports, admin query builder, tests) can omit object_id — it defaults to None and no filtering is applied.

python
# No object filtering — returns all matching records
results = saved_query.get_queryset()

# With object filtering — returns only matching record with that PK
results = saved_query.get_queryset(object_id="550e8400-e29b-41d4-a716-446655440000")

Aggregate Annotations

parse_annotations(model_class, annotations_list)

Location: enezaworkflowengine/utils.py

Converts an annotations JSON list into a dict of Django aggregate expressions, 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)

Supported Aggregate Functions

Function Django Aggregate Example Lookup
sum Sum('related__field') Sum('lines__amount')
count Count('related__field') Count('lines__id')
avg Avg('related__field') Avg('items__price')
min Min('related__field') Min('events__date')
max Max('related__field') Max('items__value')

Relationship Validation

Before building the aggregate expression, parse_annotations() validates that related_model is a valid relationship on the model class. It checks in order:

  1. Forward FK/M2M fields — checks model_class._meta.get_field(related_model).is_relation
  2. Reverse FK relations — checks model_class._meta.related_objects for matching accessor name
  3. Reverse M2M relations — checks model_class._meta.many_to_many for matching accessor name

If the relationship is not found, a ValueError is raised with a descriptive message.

Using Annotations in Filter Rules

Once defined, annotation aliases can be referenced in filter rules just like regular model fields. Rules referencing annotation aliases must use the same alias value as 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"
    }
  ]
}

Execution order is critical: Django applies annotate() before filter(), so aggregate aliases are available in WHERE/HAVING clauses. Django automatically pushes filterable annotations to HAVING rather than WHERE.

Backward Compatibility

  • Queries without an annotations key work exactly as before — no aggregation is applied.
  • Old-format JSON (with only rules and condition) continues to work unchanged.
  • The execute_new_format() method also supports annotations.

JSON Format Reference

Full Format with Annotations (Rules-style)

json
{
  "condition": "AND",
  "annotations": [
    {
      "function": "sum | count | avg | min | max",
      "field": "field_name_on_related_model",
      "related_model": "relation_accessor_name",
      "alias": "output_name_for_filter_rules"
    }
  ],
  "rules": [
    {
      "id": "field_or_alias",
      "field": "field_or_alias",
      "type": "string | integer | double | boolean | date | datetime",
      "operator": "equal | not_equal | contains | greater | ...",
      "value": "value_to_compare"
    }
  ]
}

Full Format with Annotations (Groups-style)

json
{
  "condition": "AND",
  "primary_model": "frameworktestdocument",
  "related_models": ["lines"],
  "annotations": [
    {
      "function": "sum",
      "field": "amount",
      "related_model": "lines",
      "alias": "total_amount"
    }
  ],
  "groups": [
    {
      "condition": "AND",
      "rules": [
        {
          "id": "total_amount",
          "field": "total_amount",
          "type": "double",
          "operator": "greater",
          "value": "1000"
        }
      ]
    }
  ]
}

Annotation Entry Schema

Key Type Required Description
function string Yes Aggregate function: sum, count, avg, min, or max
field string Yes Field name on the related model to aggregate
related_model string Yes FK/M2M field name or reverse relation accessor on the primary model
alias string Yes Output name referenced in filter rules

View Layer Integration

SaveQueryView (POST)

Accepts annotations either inside the query object or as a separate top-level annotations key:

python
# Inside the query (preferred):
{
    "query": {
        "condition": "AND",
        "annotations": [...],
        "rules": [...]
    }
}

# As a separate key (also supported):
{
    "query": {"condition": "AND", "rules": [...]},
    "annotations": [...]
}

The view merges separate annotations into filters_json before saving:

python
annotations = data.get('annotations')
if annotations and isinstance(filters_json, dict):
    filters_json['annotations'] = annotations

SaveQueryDetailView (GET with execute=1)

Accepts optional object_id query parameter:

text
GET /workflow/savedquery/5/?execute=1&object_id=<uuid>

Implementation:

python
object_id = self.request.GET.get('object_id')
queryset = self.object.execute(object_id=object_id)

BuildQueryView (Template Context)

The filters_by_model JSON now includes an aggregates key for each model:

python
filters_by_model = {
    m['slug']: self.get_model_filters(m['app_label'], m['model_name'], model_config=m)
    for m in allowed_models
}
for m in allowed_models:
    filters_by_model[m['slug']]['aggregates'] = self.get_aggregate_options(m)

The template receives this via filters_by_model_json:

json
{
  "frameworktestdocument": {
    "direct": [/* field filters */],
    "related": [/* related model filters */],
    "aggregates": [
      {
        "function": "sum",
        "field": "amount",
        "related_model": "lines",
        "label": "Sum of Lines Amount",
        "alias": "lines_amount_sum"
      }
    ]
  }
}

Workflow Engine Auto-Extraction

evaluate_condition_for_document()

File: enezaworkflowengine/services/condition_step.py

Receives a resolved document model instance. Extracts its PK and passes it to get_queryset():

python
def evaluate_condition_for_document(document, condition_query):
    if not document or not condition_query:
        return False

    try:
        object_id = str(document.pk)
        queryset = condition_query.get_queryset(object_id=object_id)
        result_count = queryset.count()
        return result_count > 0
    except Exception as e:
        raise ConditionStepError(f"Failed to evaluate condition: {str(e)}")

ConditionStepHandler._evaluate_and_route()

File: enezaworkflowengine/services/step_handlers.py:242

Has direct access to workflow_instance.object_id. Passes it directly — no DB query needed:

python
queryset = condition_query.get_queryset(object_id=workflow_instance.object_id)
result = queryset.count() > 0

evaluate_condition_step() (Celery Task)

File: enezaworkflowengine/tasks/workflow_tasks.py:254

Delegates to evaluate_condition_for_document(workflow_instance.document, condition_query). The service function auto-extracts the PK, so no change was needed in the Celery task.

Auto-Extraction Flow Diagram

text
WorkflowInstance (has object_id + content_type)
    │
    ├─→ evaluate_condition_for_document(document, query)
    │       document.pk → str(object_id)
    │       → query.get_queryset(object_id=object_id)
    │
    ├─→ ConditionStepHandler._evaluate_and_route(..., wf_instance)
    │       wf_instance.object_id
    │       → query.get_queryset(object_id=wf_instance.object_id)
    │
    └─→ evaluate_condition_step() (Celery task)
            → evaluate_condition_for_document(wf_instance.document, ...)
            → auto-extracts via service function above

Non-workflow callers (reports, admin query builder, tests):
    → query.get_queryset()  — object_id defaults to None, no filtering applied

Aggregate Metadata API

QueryBuilderMixin.get_aggregate_options(model_config)

Location: enezaworkflowengine/views/mixin.py

Generates aggregate annotation options for the query builder UI. For each relationship in model_config['relationships'], it discovers numeric fields on the related model and returns aggregate function options.

python
def get_aggregate_options(self, model_config):
    options = []
    for rel_def in model_config.get('relationships', []):
        rel_model = apps.get_model(rel_def['related_app'], rel_def['related_model'])
        field_name = rel_def['field_name']

        for field in rel_model._meta.get_fields():
            if isinstance(field, (IntegerField, DecimalField, FloatField, ...)):
                for func in ['sum', 'avg', 'min', 'max']:
                    options.append({
                        'function': func,
                        'field': field.name,
                        'related_model': field_name,
                        'label': f"{func.title()} of {rel_label} {field_verbose}",
                        'alias': f"{field_name}_{field.name}_{func}",
                    })
        options.append({
            'function': 'count',
            'field': 'id',
            'related_model': field_name,
            'label': f"Count of {rel_label}",
            'alias': f"{field_name}_count",
        })
    return options

Return Value Schema

Each returned option is a dict:

python
{
    'function': 'sum',                        # Aggregate function name
    'field': 'amount',                        # Field name on related model
    'related_model': 'lines',                 # Relationship accessor on primary model
    'label': 'Sum of Test Document Line Amount', # Human-readable UI label
    'alias': 'lines_amount_sum',              # Suggested alias for filter rule use
}

Numeric Field Types Discovered

The method detects these Django field types as numeric (aggregatable): - IntegerField, BigIntegerField, SmallIntegerField - PositiveIntegerField, PositiveSmallIntegerField - DecimalField, FloatField

Count Option

A count option is always added for each relationship regardless of numeric fields, using field: 'id'.


Error Handling & Validation

parse_annotations() Validation Errors

Condition Error Message
Missing required keys "Annotation {...} is missing required keys: 'function', 'field', 'related_model', 'alias'"
Unsupported function "Unsupported aggregate function 'percentile'. Supported: sum, count, avg, min, max"
Invalid relationship "'bad_relation' is not a valid relationship on Invoice. Check the related_model name against FK fields, M2M fields, or reverse relation accessors."

Django-Level Errors

Condition Behavior
object_id doesn't match model PK type Django raises ValueError — caller handles
Annotations with no filter rules Still valid — returns annotated queryset
Old format JSON (no annotations key) Backward compatible — no aggregation applied
Multiple annotations with same alias Last one wins (dict overwrite) — user responsibility

Workflow Engine Errors

Condition evaluation failures in the workflow engine are caught and handled by existing ConditionStepError / ConditionEvaluationError exceptions with appropriate fallback behavior (FAIL_OPEN / FAIL_CLOSED / FAIL_ESCALATE).


Complete Examples

Example 1: Simple Aggregate Condition (Old Rules Format)

json
{
  "condition": "AND",
  "annotations": [
    {"function": "sum", "field": "total_amount", "related_model": "lines", "alias": "grand_total"}
  ],
  "rules": [
    {"id": "grand_total", "field": "grand_total", "type": "double", "operator": "greater", "value": "5000"}
  ]
}

SQL equivalent:

sql
SELECT *, SUM(lines.total_amount) AS grand_total
FROM invoice
LEFT JOIN invoice_line AS lines ON lines.invoice_id = invoice.id
GROUP BY invoice.id
HAVING SUM(lines.total_amount) > 5000

Example 2: Multiple Aggregates with Combined Rules

json
{
  "condition": "AND",
  "annotations": [
    {"function": "count", "field": "id", "related_model": "lines", "alias": "item_count"},
    {"function": "avg",  "field": "unit_price", "related_model": "lines", "alias": "avg_price"}
  ],
  "rules": [
    {"id": "item_count", "field": "item_count", "type": "integer", "operator": "greater_or_equal", "value": "5"},
    {"id": "avg_price",  "field": "avg_price",  "type": "double",  "operator": "less",            "value": "100"}
  ]
}

Finds documents with at least 5 line items where the average unit price is below 100.

Example 3: Aggregate + Regular Field Mixed Conditions

json
{
  "condition": "AND",
  "annotations": [
    {"function": "sum", "field": "amount", "related_model": "lines", "alias": "total"}
  ],
  "rules": [
    {"id": "status", "field": "status", "type": "string", "operator": "equal", "value": "open"},
    {"id": "total",  "field": "total",  "type": "double", "operator": "greater", "value": "1000"}
  ]
}

Filters open documents where the sum of line amounts exceeds 1000. Both regular model fields and annotation aliases work together.

Example 4: Groups Format with Annotations

json
{
  "condition": "AND",
  "primary_model": "frameworktestdocument",
  "related_models": ["lines"],
  "annotations": [
    {"function": "max", "field": "amount", "related_model": "lines", "alias": "max_line_amount"},
    {"function": "min", "field": "amount", "related_model": "lines", "alias": "min_line_amount"}
  ],
  "groups": [
    {
      "condition": "AND",
      "rules": [
        {"id": "max_line_amount", "field": "max_line_amount", "type": "double", "operator": "greater", "value": "500"},
        {"id": "min_line_amount", "field": "min_line_amount", "type": "double", "operator": "less", "value": "50"}
      ]
    }
  ]
}

Finds documents where the highest line amount exceeds 500 AND the lowest line amount is under 50.

Example 5: Programmatic Usage

python
from enezaworkflowengine.models import SavedQuery
from enezaworkflowengine.utils import parse_annotations

# Create a query programmatically
query = SavedQuery.objects.create(
    name="High Value Orders",
    content_type=invoice_ct,
    filters_json={
        "condition": "AND",
        "annotations": [
            {"function": "sum", "field": "line_total", "related_model": "lines", "alias": "total"},
            {"function": "count", "field": "id", "related_model": "lines", "alias": "line_count"},
        ],
        "rules": [
            {"id": "total", "field": "total", "type": "double", "operator": "greater", "value": "10000"},
            {"id": "line_count", "field": "line_count", "type": "integer", "operator": "greater", "value": "10"},
        ],
    },
    user=user,
    workflow="InvoiceWorkflow",
)

# Execute with object ID filtering
results = query.get_queryset(object_id="550e8400-e29b-41d4-a716-446655440000")

# Execute without object ID (all records)
all_results = query.get_queryset()

Example 6: Using Directly with parse_annotations

python
from django.db.models import Sum, Count
from enezaworkflowengine.utils import parse_annotations
from myapp.models import Invoice

annotations = parse_annotations(Invoice, [
    {"function": "sum", "field": "line_total", "related_model": "lines", "alias": "total"},
    {"function": "count", "field": "id", "related_model": "lines", "alias": "line_count"},
])

queryset = Invoice.objects.annotate(**annotations)
queryset = queryset.filter(total__gt=1000, line_count__gte=5)

Reference

Files Modified

File Changes Purpose
enezaworkflowengine/utils.py Added AGGREGATE_FUNCTION_MAP, parse_annotations(), _validate_relationship() Converts annotation JSON to Django aggregates with validation
enezaworkflowengine/models/querybuilder.py Updated get_queryset(object_id=None), execute(object_id=None), execute_new_format(object_id=None) Object ID filtering + annotation integration
enezaworkflowengine/views/mixin.py Added get_aggregate_options() Generates aggregate metadata for query builder UI
enezaworkflowengine/views/querybuilder.py Updated BuildQueryView, SaveQueryView.post(), SaveQueryDetailView Accepts annotations in views + object_id param
enezaworkflowengine/services/condition_step.py Updated evaluate_condition_for_document() Auto-extracts document.pk → passes to get_queryset()
enezaworkflowengine/services/step_handlers.py Updated ConditionStepHandler._evaluate_and_route() Auto-extracts workflow_instance.object_id → passes to get_queryset()

SavedQuery API

Method Signature Notes
get_queryset() get_queryset(object_id=None) Core method — applies annotations + filters + object_id
execute() execute(object_id=None) Alias for get_queryset()
execute_new_format() execute_new_format(object_id=None) New format parser — also supports annotations

parse_annotations() API

python
parse_annotations(model_class, annotations_list) → Dict[str, Aggregate]
Parameter Type Description
model_class Model Django model class — used to validate relationships
annotations_list List[Dict] List of annotation dicts with function, field, related_model, alias
Returns Dict[str, Aggregate] Dict suitable for queryset.annotate(**result)

Annotation Entry Schema

Key Required Values Description
function Yes sum, count, avg, min, max Aggregate function
field Yes Any field name on the related model Field to aggregate
related_model Yes FK/M2M field name or reverse relation accessor Relationship on primary model
alias Yes Any string (snake_case recommended) Name used in filter rules

get_aggregate_options() API

python
QueryBuilderMixin.get_aggregate_options(model_config) → List[Dict]
Parameter Type Description
model_config Dict Model config dict from get_allowed_models() (must have relationships key)
Returns List[Dict] List of aggregate option dicts with function, field, related_model, label, alias

View Query Parameters

View Parameter Description
SaveQueryDetailView ?execute=1 Trigger query execution
SaveQueryDetailView ?object_id=<uuid> Filter results to a single document
BuildQueryView ?workflow_name=X Load allowed models for a workflow

Edge Cases

Scenario Behavior
object_id is None No filtering applied (backward compatible)
annotations key missing from filters_json No aggregation applied (backward compatible)
annotations is empty list [] No aggregation applied
Annotation references non-existent related_model ValueError raised with clear message
Annotation references unsupported function ValueError raised listing supported functions
Multiple annotations with same alias Last one wins (dict overwrite)
Annotation alias referenced in rules but not defined Django raises FieldError
object_id type mismatch with model PK Django raises ValueError

Out of Scope

  • Aggregate values in query result output
  • Nested aggregates (aggregate over aggregate)
  • Custom/expression-based annotations beyond the 5 standard functions
  • UI/template changes for the query builder (handled separately as a frontend task)