Skip to content

Chapter 2: The Document Model

中文版

Every higher-level operation depends on a precise answer to three questions: which values are legal, when are two values equal, and how does a field path select a nested value? MiniMongoDB keeps those answers in src/minimongodb/bson/. This chapter develops the value model before queries and updates begin using it in different ways.

Learning objectives

By the end of this chapter, you will be able to:

  • list MiniMongoDB's supported BSON-shaped type tags and comparison order;
  • explain why True, 1, documents, and arrays need typed equality;
  • distinguish exact value equality from dotted-path selection;
  • trace get_path, set_path, and query-specific resolve_path; and
  • identify where MiniMongoDB intentionally differs from real BSON.

Values have explicit tags

src/minimongodb/bson/types.py::type_tag accepts seven categories: null, number, string, document, array, bool, and objectId. Python values provide the representation—dict for documents and list for arrays—but the database does not simply inherit all Python behavior. Sets, tuples, bytes, datetimes, non-string document keys, and arbitrary objects are rejected.

The order of checks in type_tag matters. Python makes bool a subclass of int, so testing for numbers first would label True as a number. MiniMongoDB tests bool first and gives it a separate tag. That distinction propagates to equality, identity indexes, persistence, and sorting.

src/minimongodb/bson/types.py::clone_document calls the recursive _validate_tree before deepcopy. Validation is therefore not limited to the root. A set nested three arrays deep is still rejected before storage. The supported object graph is value-like and acyclic under normal use, which makes a deep copy a clear teaching representation of ownership transfer.

MiniMongoDB's ObjectId is a frozen value containing a non-negative integer that fits in 96 bits. ObjectId.__str__ renders 24 hexadecimal digits, so the shape is familiar. CounterObjectIdGenerator.__call__ supplies monotonically increasing values and is injectable for deterministic tests. Shape is not provenance: a real MongoDB ObjectId contains timestamp, random/process, and counter material; this one deliberately does not.

Exact equality is BSON-shaped

src/minimongodb/bson/types.py::bson_equal begins by comparing type tags. Consequently True and 1 are different even though Python says True == 1. Numeric int and float values share the number tag and compare by numeric value, subject to explicit NaN handling.

Documents are compared in insertion order. The function checks the ordered key lists and then recursively compares corresponding values:

if isinstance(left, dict):
    return list(left.keys()) == list(right.keys()) and all(
        bson_equal(left[key], right[key]) for key in left
    )

Thus {"a": 1, "b": 2} is not exactly equal to {"b": 2, "a": 1}. Arrays compare element by element and remain order-sensitive. This exact equality becomes important when a query operand is itself a document or array. A literal document means “equal to this whole embedded value,” not “contains these fields.”

Hash tables need hashable keys, but BSON-shaped values can contain dictionaries and lists. src/minimongodb/bson/types.py::canonical_key recursively converts a value into a tuple beginning with its type tag. Numbers receive special normalization so ordinary equal int/float values share identity, NaNs converge, and large integers do not accidentally collapse through an imprecise float. Documents preserve field order in their canonical form; arrays preserve element order.

src/minimongodb/index/id_index.py::IdIndex uses this key for uniqueness and lookup. The typed prefix is why _id=True and _id=1 can coexist, while _id=1 and _id=1.0 usually conflict. Canonicalization is an indexing representation of bson_equal, not a new public equality rule.

A deliberately small comparison order

Range predicates and aggregation sorting need ordering, not only equality. src/minimongodb/bson/types.py::bson_compare first compares positions in _TYPE_ORDER:

null < number < string < document < array < bool < objectId

Values with the same tag are compared recursively. Documents compare their key/value sequence, arrays compare elements lexicographically, and numeric comparison treats NaN explicitly to remain deterministic.

This global order is a teaching choice, not MongoDB parity. Real BSON supports many more types and has a documented BSON comparison order. More importantly, MongoDB comparison query predicates usually apply type bracketing: a numeric range normally compares numeric fields rather than walking a global order across strings and documents. MiniMongoDB exposes its global order directly. Code that relies on cross-type range matches here is not portable MQL.

Dotted paths: one notation, two read semantics

CRUD updates and aggregation expressions use src/minimongodb/bson/path.py::get_path. It splits a non-empty dotted string, walks dictionary keys, and accepts a numeric segment when the current container is a list. Missing keys or indexes return the sentinel MISSING, which is different from a stored None.

That distinction lets an update tell “field absent” from “field explicitly null.” It also lets aggregation omit a missing projected field while retaining a null field. A private sentinel is safer than choosing an ordinary user value as “not found.”

set_path traverses existing containers and can create missing dictionary parents. It will not guess how to create or extend arrays. If the current container is a list, the segment must be numeric and the position must already exist. unset_path deletes a dictionary key, but when it targets an array position it writes None so later positions do not shift.

Query matching needs a different read operation: src/minimongodb/query/matcher.py::resolve_path. When a nonnumeric field is applied to an array, it recursively applies the same remaining path to every element. For:

{"items": [{"sku": "A"}, {"sku": "B"}]}

resolve_path(document, "items.sku") yields both "A" and "B". By contrast, the general-purpose get_path returns one value or MISSING; it does not perform query fan-out. Keeping these functions separate prevents aggregation and update code from accidentally inheriting matcher-only array semantics.

This is a recurring architectural lesson: shared syntax does not imply shared cardinality. Both functions understand dots and numeric array indexes, but only the matcher path can return several candidates.

Compared with real MongoDB

Real MongoDB stores binary BSON with types absent here, including date, binary, decimal, regex, timestamp, MinKey, and MaxKey. MiniMongoDB's tagged JSON persistence is inspectable but neither BSON binary nor wire-compatible. MongoDB's ObjectId generation is nondeterministic and time-bearing; the local counter is semantically opposite in provenance.

The useful correspondences are narrower. Documents and arrays remain distinct typed values. Embedded-document equality is whole-value and field-order sensitive. Dot notation selects nested fields, and numeric segments can address array positions. Those are real concepts represented by smaller code.

Read the exact boundary in BSON and identity differences and query differences. The bson.types, ObjectId, and bson.path rows in the mapping table classify the relationships.

One particularly important mismatch is null versus missing. In real MongoDB, {field: null} also matches documents where the field is missing. MiniMongoDB's literal-null equality requires a present None; missing is queried with $exists: false.

Hands-on experiment: inspect tags, order, and paths

Run:

UV_CACHE_DIR=/tmp/minimongodb-uv-cache uv run python - <<'PY'
from minimongodb import ObjectId
from minimongodb.bson import MISSING, bson_compare, bson_equal, get_path, type_tag

values = [None, 1, "a", {"a": 1}, [1], False, ObjectId(1)]
print("tags:", [type_tag(value) for value in values])
print("ascending:", all(bson_compare(a, b) < 0 for a, b in zip(values, values[1:])))
print("True equals 1:", bson_equal(True, 1))
document = {"profile": {"names": [{"first": "Ada"}]}, "nullish": None}
print("nested:", get_path(document, "profile.names.0.first"))
print("missing sentinel:", get_path(document, "profile.city") is MISSING)
print("null is missing:", get_path(document, "nullish") is MISSING)
PY

Measured output:

tags: ['null', 'number', 'string', 'document', 'array', 'bool', 'objectId']
ascending: True
True equals 1: False
nested: Ada
missing sentinel: True
null is missing: False

The output proves the project-specific tag order and the null/missing distinction. It does not prove real BSON comparison parity.

Exercises

1. Understanding: equality versus selection

Why can an exact comparison of {"city": "London"} fail against {"city": "London", "role": "engineer"} while a dotted query on profile.city succeeds?

Reference answer

Exact document equality compares the complete ordered field/value sequence. A dotted path first selects the nested city scalar, so the unrelated role field is outside the value being compared.

2. Understanding: typed identity

Predict whether _id=True conflicts with _id=1, and whether _id=1 conflicts with _id=1.0. Verify with Collection.insert_one.

Reference answer

True and 1 have different tags and may coexist. Ordinary 1 and 1.0 share numeric BSON equality and therefore conflict in the _id index. Acceptance is two successful inserts for the first pair and a DuplicateKeyError for the second pair.

3. Hands-on: add a teaching value type on paper

Draft, but do not apply, a diff that adds a date tag. Identify changes needed in type_tag, _TYPE_ORDER, equality/comparison, canonical_key, and the storage codec. Acceptance: the proposed diff names every layer and includes at least one round-trip and one ordering test.

Reference answer

A complete proposal adds a datetime check before unsupported-value rejection, assigns an explicit order position, defines deterministic same-tag comparison and canonical encoding, adds date branches to _to_node/_from_node, and tests clone validation, equality, comparison, canonical _id behavior, and checkpoint/journal round trips. Merely changing type_tag is incomplete because persistence would reject the new node.

4. Hands-on: path mutation without source edits

Use get_path, set_path, and unset_path on a local dictionary. Acceptance: create profile.city, change names.0.first, unset the array element at values.1, and print a None placeholder rather than a shortened array.

Reference answer

Start with {"profile": {"names": [{"first": "Ada"}]}, "values": [1, 2, 3]}. After the operations the relevant shape is {"profile": {"names": [{"first": "Grace"}], "city": "London"}, "values": [1, None, 3]}.

Summary

MiniMongoDB overlays explicit database semantics on Python containers. type_tag, bson_equal, bson_compare, and canonical_key keep type, equality, ordering, and hash identity aligned. get_path performs singular document traversal, while matcher resolve_path may fan out through arrays. The next chapter uses those primitives to explain the book's central query lesson: why arrays sometimes expand into candidate elements and sometimes remain exact whole values.