17. Populate & Depopulate — Worked Examples
Scenario: You want to see what populate/depopulate actually do to your documents — field by field, at every layer (Python model → depopulated → stored in Mongo → raw read back → populated).
These examples all ran against the real mongo_ops engine (mongo_ops/populate/engine.py). Timestamps and ObjectIds are abbreviated with … for readability.
Models & rules used below
from bson import ObjectId
from mongo_ops import BaseDocument, PopulatingRepository
from mongo_ops.populate import PopulateRule, PopulationEngine
class Profile(BaseDocument):
avatar_url: str = ""
bio: str = ""
class Publisher(BaseDocument):
name: str = ""
class Book(BaseDocument):
title: str = ""
publisher: Publisher | None = None # nested ref, reached via nested_rules
class User(BaseDocument):
name: str = ""
profile: Profile | None = None # scalar FK — DB stores an ObjectId
books: list[Book] | None = None # list FK — DB stores list[ObjectId]
engine = PopulationEngine({}) # repos keyed by collection name, see UC 15
# A rule WITHOUT nested_rules — the write path is safe.
profile_rule = PopulateRule(field_name="profile", collection_name="profiles")
# A rule WITH nested_rules — designed for READ-side deep population.
books_rule = PopulateRule(
field_name="books",
collection_name="books",
nested_rules=[PopulateRule(field_name="publisher", collection_name="publishers")],
)
1. Write path — depopulate() turns models into references
engine.depopulate(document, rules) walks each rule field and mutates the model in place before you insert_one / find_one_and_update it. What you get on the other side:
1a. Scalar FK, no nested_rules → stored as ObjectId
async def demo():
profile = Profile(id=ObjectId("656…04"), avatar_url="a.png", bio="hi")
user = User(id=ObjectId("656…05"), name="Ada", profile=profile)
await engine.depopulate(user, [profile_rule])
return user.profile # ObjectId('656…04') — was a Profile model
What Mongo receives (via model_dump, minus _id handling):
1b. Scalar FK with nested_rules → reference lost (do not do this)
profile_rule_deep = PopulateRule( # same field, but with nested_rules
field_name="profile",
collection_name="profiles",
nested_rules=[PopulateRule(field_name="bio", collection_name="profiles")], # any child field
)
async def demo():
user = User(id=ObjectId("656…05"), name="Ada",
profile=Profile(id=ObjectId("656…04"), avatar_url="a.png"))
await engine.depopulate(user, [profile_rule_deep])
return user.profile # None — reference silently dropped!
Because depopulate recurses into the child but then assigns the parent field None (see engine.py — depopulated_value is never set on that branch). On save you lose the reference entirely. There is no warning — the field is just gone.
1c. List FK, no nested_rules → stored as list[ObjectId] (the safe pattern)
async def demo():
b1 = Book(id=ObjectId("656…06"), title="MongoDB in Action")
b2 = Book(id=ObjectId("656…07"), title="MongoDB: The Definitive Guide")
user = User(id=ObjectId("656…08"), name="Ada", books=[b1, b2])
await engine.depopulate(user, [PopulateRule(field_name="books", collection_name="books")])
return user.books # [ObjectId('656…06'), ObjectId('656…07')] — clean refs
1d. List FK with nested_rules → stored as embedded documents (do not do this)
async def demo():
publisher = Publisher(id=ObjectId("656…09"), name="O'Reilly")
b1p = Book(id=ObjectId("656…06"), title="MongoDB in Action", publisher=publisher)
user = User(id=ObjectId("656…08"), name="Ada", books=[b1p])
await engine.depopulate(user, [books_rule]) # books_rule carries nested_rules
return user.books[0]
# <Book id=ObjectId('656…06') publisher=ObjectId('656…09')>
books stays a list of Book models — the nested publisher collapsed to ObjectId, but the Book itself was left embedded. Serialized to Mongo you get a nested sub-document:
{ "name": "Ada", "books": [ { "title": "MongoDB in Action", "publisher": { "$oid": "656…09" } } ] }
…and reading that back through population raises ValueError(…contains embedded dict(s) — run repair script…), because the engine expects books to hold references, not embedded docs.
Why UC 09's
Author → books → publishergraph can't be written as-is: a rule withnested_rulesbreaks the write path (1b logs silently, 1d stores embedded). The safe round-trip: save referenced documents first, storelist[ObjectId]/ObjectIdwith a non-nested rule for reads, and keepnested_rulesonly on rules you never hand back todepopulate.
2. Read path — populate() turns references back into models
PopulatingRepository.data_to_model(raw_dict) resolves references before building the model. It first runs _populate(dict) — replacing the raw ObjectId references with full models via the registered repos — and only then constructs self.model(**data), so every FK field is a real model by construction. The two data_to_model examples below (2a/2b) show the underlying resolution _populate performs per rule. (repo below is a PopulatingRepository wired per UC 15 — books and publishers repos registered in its PopulationEngine.)
2a. Scalar FK — ObjectId → Profile model
# what came back from Mongo (raw dict, profile is an ObjectId reference):
raw = {"_id": ObjectId("656…05"), "name": "Ada", "profile": ObjectId("656…04")}
async def demo():
user = await repo.data_to_model(raw) # _populate() → model(**data)
return user.profile # Profile(id=ObjectId('656…04'), avatar_url='a.png')
2b. List FK + nested_rules — two levels deep
raw = {"_id": ObjectId("656…08"), "name": "Ada",
"books": [ObjectId("656…06"), ObjectId("656…07")]}
async def demo():
user = await repo.data_to_model(raw) # _populate() resolves both levels
return (type(user.books[0]).__name__, # 'Book'
type(user.books[0].publisher).__name__, # 'Publisher' — nested_rules reached it
user.books[0].publisher.name) # "O'Reilly"
Walking through one book's resolution:
books_rule → field "books", collection "books"
nested_rules[0].publisher rule → field "publisher", collection "publishers"
1. for ObjectId('656…06') → repo("books").get_by_id(...) → Book("MongoDB in Action")
2. book has nested rule → repo("publishers").get_by_id(...) → Publisher("O'Reilly")
3. author.books[0] → Book(publisher=Publisher("O'Reilly"))
Per-level cost: 1 query per reference (no $lookup). A hub with 20 hooks and a publisher nested rule is 40 queries — that is why the next section exists.
3. Cache round trip — what actually goes into the cache
CachedBaseRepository stores model_dump(by_alias=True) JSON-encoded with default=str (the encode_value helper in mongo_ops/cache/in_memory.py). Crucially it snapshots the raw, just-out-of-Mongo shape — FK fields still hold ObjectIds — so the cache holds references, exactly like the DB row:
| Layer | profile field looks like |
|---|---|
| Mongo doc (raw) | “profile”: ObjectId("656…04") |
model_dump(by_alias=True) |
"profile": ObjectId("656…04") |
encode_value(...) |
"...\"profile\": \"656…04\"..." — hex string |
decode_value(...) |
"profile": "656…04" — still a hex string |
Verbatim (timestamps abbreviated):
from mongo_ops.cache.in_memory import decode_value, encode_value
snapshot = {"id": "656…05", "profile": ObjectId("656…04")} # what a cached raw doc looks like
cached = encode_value(snapshot) # json.dumps(snapshot, default=str).encode()
decoded = decode_value(cached)
decoded["profile"] # '656…04' — hex STRING, not ObjectId, not Profile
Typed-FK caveat: the round trip above only works when the FK field's model type accepts the raw value (
str/ObjectIdtyped). A field typedProfile | Nonecannot even be materialized from the raw Mongo doc — both hit and miss raiseValidationError(UC 16's intricacy). That is exactly why UC 10's composed repo overridesget_by_id/create/updateto carry raw data through the cache and resolve refs with_populate.
Two consequences (the "intricacies", detailed in UC 16):
- On a cache hit,
decode_valuegives you hex-string references. A field typedProfile | Nonereceiving a hex string raises PydanticValidationError— so a plainCachedBaseRepositorycannot serve model-typed FK fields, hit or miss. The composed pattern in UC 10 re-resolves them in itsget_by_id:data = decode_value(await cache.get(key)), thenpopulated = await self._populate(data), thenself.model(**populated). - On a miss,
BaseRepository.get_by_idreads the raw Mongo doc whoseprofileis anObjectIdand immediately raises the sameValidationError— which is why UC 10 overridesget_by_idto fetch raw, cache raw, then populate. (Real ObjectIds are covered by UC 16's table.)
4. When to use which
| You need… | Use |
|---|---|
Scalar ObjectId store, model-struct reads |
PopulatingRepository (UC 08) |
| Deep nested reads (2+ levels) | PopulatingRepository + nested_rules (UC 09) — read side only |
| Same shape, but cache-first reads | UC 10 composed repo (engine + cache + _populate) |
Scalar docs, no refs, hot get_by_id |
CachedBaseRepository (UC 07, UC 16) |
| Writing a reference graph back | depopulate without nested_rules — save children first, then point at their ids |