Chapter 9: Aggregation as a Document Pipeline¶
A query answers which documents match. An aggregation pipeline answers a
broader question: how should a stream of documents be filtered, reshaped,
combined, ordered, and bounded? MiniMongoDB implements five stages—
$match, $project, $group, $sort, and $limit—as small composable
operators. The key lesson is not the number of stages. It is where a stage can
stream and where semantics force it to materialize all input.
Learning objectives¶
By the end of this chapter, you will be able to:
- trace
Collection.aggregatethroughexecute_pipelineand its stage dispatch; - classify
$match,$project, and$limitas streaming stages and$group/$sortas blocking stages; - evaluate field references and nested expression shapes;
- explain group identity, accumulator initialization, and missing-value behavior; and
- identify which MongoDB aggregation capabilities and optimizations are intentionally absent.
9.1 A pipeline is staged iterable composition¶
Collection.aggregate in src/minimongodb/collection.py passes the
collection's document list to execute_pipeline in
src/minimongodb/aggregate/__init__.py. execute_pipeline first checks that
the pipeline is a list, then creates a generator that clones every source
document. Aggregation therefore does not leak internal mutable documents to
callers or mutate collection state.
Each stage must be a dictionary with exactly one operator. The dispatcher
validates stage-specific top-level shapes and replaces stream with the next
iterable:
for stage in pipeline:
operator, specification = next(iter(stage.items()))
if operator == "$match":
stream = _match(stream, specification)
elif operator == "$project":
stream = _project(stream, specification)
...
return list(stream)
Although the public result is ultimately an eager list, the intermediate value
is usually an iterable. That distinction allows an early $match to discard
documents before later work and allows $limit to stop pulling. Stage order is
observable: project away a field before matching it and the later match sees a
different document shape.
Composition also localizes responsibility. The dispatcher owns stage syntax,
each helper owns one transformation, and the terminal list(stream) owns
public materialization. A helper need not know whether its input came from the
collection, a matcher, or another projection. This is the same interface
advantage that makes iterator-based operator engines teachable.
Invalid structure fails explicitly with InvalidPipelineError: non-list
pipelines, stages containing two operators, unknown stage names, and invalid
stage specifications are not silently ignored.
9.2 Streaming stages¶
_match is a generator. It pulls one document, calls the existing
query.matches, and yields only accepted documents. This reuse is important:
array fan-out, exact embedded-document matching, dotted paths, and the supported
query operators do not acquire a second aggregation-specific interpretation.
However, _match receives the current pipeline iterable directly; it does not
call Collection._run_query, so there is no index planning or pushdown.
_project is also a generator. It first classifies fields whose expressions
are 0/False as exclusions and the remainder as inclusions or computed
fields. Excluding fields other than _id cannot be mixed with inclusion or
computed expressions. In inclusion mode, _id is preserved by default, direct
1/True fields are read with get_path, and other expressions are evaluated
by _evaluate. In exclusion mode, it clones the whole document and calls
unset_path for each excluded field.
_limit validates a nonnegative integer and yields until its enumeration
position reaches the limit. It explicitly rejects Boolean values even though
bool is a Python subclass of int. At position limit, it breaks without
pulling the rest of the upstream iterable. Placing $limit before a blocking
stage can therefore bound that stage's input, though doing so changes the
pipeline's meaning.
These stages use Python generators, so they hold one current document plus their small local state. “Streaming” does not mean network streaming or an async cursor; it describes incremental consumption inside one synchronous call.
9.3 Expression evaluation¶
Function _evaluate supports a compact expression language:
- a string beginning with
$is a dotted field reference; - the special string
$refers to the whole document; - dictionaries and lists are recursively evaluated as nested shapes; and
- every other value is a deep-copied constant.
A missing field reference returns the MISSING sentinel from bson.path.
Projection omits a missing direct field. Within a nested list, missing becomes
None; within a nested document, that child key is omitted. These conversions
ensure the internal sentinel never leaks into a returned BSON-shaped value.
This language is sufficient for projections such as
{"name": "$item.name"} and group keys such as "$region", but it does not
implement MongoDB's arithmetic, conditional, date, string, array, or variable
expression families.
9.4 Why grouping blocks¶
_group cannot yield a final group when it sees the first member: a later
document might change the sum, average, minimum, maximum, or pushed list. It
therefore consumes the complete input into a groups dictionary keyed by
canonical_key(group_value). Canonical keys let structured group identities
be hashable while preserving MiniMongoDB's BSON equality.
A missing group key becomes None. New groups initialize $push to an empty
list, $min/$max to a private _UNSET sentinel, and numeric accumulators to
None. The sentinel matters because BSON null is a real comparable value and
must not mean “no observation yet.”
Accumulator behavior is explicit:
$sumadds numeric values and treats nonnumeric or missing values as zero;$avgtracks a separate(total, count)pair and ignores nonnumeric values;$minand$maxcompare present values withbson_compare;$pushappends evaluated values, converting missing toNone.
Function _numeric rejects Boolean despite Python's numeric inheritance. After
consumption, _group finalizes averages, empty sums, and never-seen min/max,
then returns groups.values().
_sort is the other blocking stage. It calls sorted on the entire iterable
using a comparator that reads dotted paths and delegates mixed-type ordering to
bson_compare. Missing sort fields become None. Multiple sort fields are
checked in specification order, each with direction 1 or -1.
Thus the memory boundary is visible in source: _match, _project, and
_limit contain yield; _group owns dictionaries covering all groups, and
_sort calls sorted.
9.5 Compared with real MongoDB¶
MongoDB's aggregation framework has a large expression language and stages
such as $unwind, $lookup, $facet, $setWindowFields, $merge, and
$out. Its optimizer can reorder or combine stages, push eligible $match
work toward indexes, split pipelines across shards, and spill some blocking
work to disk subject to configuration and limits.
MiniMongoDB implements only $match/$project/$group/$sort/$limit. Its
$group supports _id plus $sum/$avg/$min/$max/$push. It runs in one
process against a cloned stable input sequence, with no optimizer, index
pushdown, spilling, parallelism, distributed merge, lookup, unwind, window
function, or output stage. $group and $sort may grow memory with all input.
See
Differences: Aggregation differences
and the aggregate.execute_pipeline row in
MiniMongoDB ↔ MongoDB mapping. The transferable model is
ordered operator composition, explicit expression evaluation, and
streaming-versus-blocking ownership—not production scalability.
9.6 Hands-on experiment: filter, group, and reshape¶
Run this exact command from the repository root:
UV_CACHE_DIR=/tmp/minimongodb-uv-cache uv run python - <<'PY'
import json
from minimongodb import Collection
sales = Collection("sales")
sales.insert_many([
{"_id": 1, "region": "west", "amount": 3},
{"_id": 2, "region": "east", "amount": 9},
{"_id": 3, "region": "west", "amount": 7},
])
result = sales.aggregate([
{"$match": {"region": "west"}},
{"$group": {"_id": "$region", "total": {"$sum": "$amount"}, "avg": {"$avg": "$amount"}}},
{"$project": {"_id": 0, "region": "$_id", "total": 1, "avg": 1}},
])
print(json.dumps(result, sort_keys=True))
PY
Measured output:
[{"avg": 5.0, "region": "west", "total": 10}]
$match streams two west documents into the blocking $group; $project
then reshapes the one completed group. sort_keys=True normalizes only the
printed JSON key order so the measured output is reproducible; key order has
no role in the calculated totals.
Run the focused contract:
UV_CACHE_DIR=/tmp/minimongodb-uv-cache uv run pytest -q tests/test_aggregate.py
Measured output:
........ [100%]
8 passed in 0.03s
Both commands use only the direct API; no socket is involved.
9.7 Exercises¶
Understanding 1: moving a limit¶
Are [$sort, $limit: 10] and [$limit: 10, $sort] equivalent?
Reference answer
No. Sort-then-limit returns the first ten documents in global sorted order. Limit-then-sort consumes the first ten upstream documents and sorts only them. The second form bounds sort memory but changes semantics.
Understanding 2: null versus no value¶
Why does $min initialize with _UNSET rather than None?
Reference answer
None is the BSON null value and participates in MiniMongoDB comparison
order. Using it as “not initialized” would make a real null observation
indistinguishable from no observation. _UNSET keeps those states separate.
Hands-on 3: add $count¶
On a throwaway branch, add a blocking $count stage whose string
specification names the output field. It should emit one document such as
{"matched": 3}. Acceptance: test normal, empty-input, and invalid-name cases,
then run uv run pytest -q tests/test_aggregate.py.
Reference answer
A compact design adds dispatcher validation and a helper:
+elif operator == "$count":
+ stream = _count(stream, specification)
+def _count(documents, field):
+ if not isinstance(field, str) or not field or field.startswith("$"):
+ raise InvalidPipelineError("$count requires an output field name")
+ count = sum(1 for _ in documents)
+ return [] if count == 0 else [{field: count}]
Confirm the desired empty-input behavior against MongoDB before treating
the exercise as compatibility work. The tutorial does not modify src/.
Summary¶
MiniMongoDB composes aggregation as iterables: matching, projection, and limit can consume incrementally, while grouping and sorting must own all relevant input before returning final answers. Canonical BSON equality and comparison remain shared across querying, indexing, and aggregation. Chapter 10 closes the book by comparing these document mechanisms with relational schemas, joins, plans, indexes, and transaction boundaries.