Use Case 15: Inside PopulatingRepository β the Object β ObjectId Lifecycle
Scenario: You want to see what the repository actually does, in which order, before you trust it with your data β how a Profile becomes an ObjectId for storage and comes back as a Profile on read, and where every piece is wired.
π¦ Two Representations, One Field
A populate-ruled field is a shape shifter β the same name holds different things depending on where you look:
| Place | Value held in the field |
|---|---|
| In the MongoDB doc | ObjectId (or list[ObjectId]) |
| In the cache/JSON | hex string (see use case 16) |
| In the app model | the referenced model (or None) |
PopulatingRepository.create / update depopulate (model β ObjectId) before writing; get_by_id / get_many populate (ObjectId β model) after reading:
App model MongoDB document App model
User(profile=Profile) ββdepopulateβββΆ { profile: <ObjectId> } ββpopulateβββΆ User(profile=Profile)
(write path) (storage shape) (read path)
π Write Path β create() Step by Step
Given user = User(username="alice", profile=saved_profile) where saved_profile.id exists:
class UserRepository(PopulatingRepository[User]):
def __init__(self):
super().__init__(
collection_name="users",
model=User,
population_engine=engine,
populate_rules=[profile_rule],
)
- Guard check (
_depopulate,repository.py): for each rule field, if the value is already anObjectId(orlist[ObjectId]) an error is raised β see _depopulate guards below. Saved models pass. engine.depopulate(user, rules)collapses the graph in place:saved_profile(aBaseDocumentwith.id) β becomessaved_profile.idβObjectId- a
Profilewithout.id(unsaved) β is left as a model object, whichmodel_dumpthen embeds as a dict β see intricacy #3 - when
nested_rulesare present, the nested model is depopulated recursively first, then collapsed model_dump(exclude={"id"}, exclude_none=True)produces the raw insert dict; timestamps are added.insert_one(doc)writes{..., "profile": ObjectId("..."), ...}to MongoDB.- On the way out,
data_to_modelruns_populate(the read path below) socreatereturns a fully populated model, not the raw one.
The _depopulate guards (loud failures > silent corruption)
| Stored/held value under a rule field | Behaviour |
|---|---|
ObjectId / list[ObjectId] |
ValueError("...contains ObjectId β was populate skipped?") β the doc was loaded raw (e.g. via BaseRepository or an unpopulated read) and handed back to create/update |
a saved BaseDocument (has .id) |
collapse to .id (ObjectId) |
an unsaved BaseDocument (no .id) |
kept as a model β embedded dict below |
a dict |
not BaseDocument β left as-is β embedded |
non-BaseDocument, non-dict value |
AttributeError raised by engine.depopulate |
| no engine / no rules | passthrough β _depopulate just does model_dump, whatever shape you gave is stored |
π Read Path β get_by_id() Step by Step
get_by_id inherits CRUD and only changes data_to_model (repository.py:247):
doc = await self.collection.find_one({"_id": id}) # {"profile": ObjectId, ...}
return await self.data_to_model(doc) # data_to_model β self._populate(doc)
_populate(data) walks each rule field in the raw dict:
ref = data.get("profile")βObjectId. Ifref is Noneβ field untouched (stays absent).- Look up the repo:
repo = engine._repos.get("profiles"). If it's not registered,doc = None. doc = await repo.get_by_id(ref)β a single read on the referenced collection.- If
docisNoneβdata["profile"] = None(missing refs resolve toNone, never raise). - If the rule has
nested_rulesβengine.populate(doc, nested_rules, depth=1)deepens the result. data["profile"] = docβself.model(**data)builds theUserwith a realProfile.
Lists behave the same per item; a ref that is a dict (an embedded document) raises ValueError("...contains embedded dict(s) β run repair script").
Engine is optional. Set
population_engine=Noneandpopulate_rules=[], andPopulatingRepositoryis just aBaseRepositoryβ FK fields come back as rawObjectId, and your model field type must agree (that is the whole point of the guard in the WRITE path: aProfile | None-typed field populated with a rawObjectIdis a broken round-trip waiting to happen).
π How It's Wired
from mongo_ops import BaseDocument, PopulatingRepository
from mongo_ops.populate import PopulateRule, PopulationEngine
class Profile(BaseDocument):
avatar_url: str = ""
class User(BaseDocument):
username: str = ""
profile: Profile | None = None # ObjectId in DB, Profile in memory
engine = PopulationEngine({}) # repositories live here, keyed by collection name
profile_rule = PopulateRule(field_name="profile", collection_name="profiles")
class UserRepository(PopulatingRepository[User]):
def __init__(self):
super().__init__("users", User, population_engine=engine, populate_rules=[profile_rule])
def wire() -> None:
"""Call AFTER connect() β repositories need a live database."""
engine.register_repo("profiles", PopulatingRepository[Profile]("profiles", Profile))
engine.register_repo("users", UserRepository())
Wiring rules:
engine._reposis keyed bycollection_nameas written in the rule β typo β silentNonerefs.register_reponeeds an already-constructed repository β call it inside the lifespan (afterconnect()/MongoConnectionManager.lifespan), not at module import.- The referenced repository only needs a
get_by_idthat returns aBaseDocumentβ it can be a plainBaseRepository, anotherPopulatingRepository, or even a cached repo (see use case 10). Population requires no extra DB index on the referenced collection's_id. - Swap at runtime:
repo.set_population_engine(new_engine)andrepo.set_populate_rules(new_rules)β the tests exercise both.
β οΈ Intricacy β nested_rules Do NOT Round-Trip Through depopulate
nested_rules are designed for read-side deep population. If the same rules run through the write path (create / update), engine.depopulate behaves differently from plain refs β verified against the engine:
| Rule shape | What depopulate does to it |
Effect on the stored doc |
|---|---|---|
scalar FK, no nested_rules |
collapse to .id |
stored as ObjectId β |
list[ObjectId] FK, no nested_rules |
collapse each item to .id |
stored as list[ObjectId] β |
list FK with nested_rules |
items are kept as models | stored as embedded dicts β β reading back raises "contains embedded dict(s) β run repair script" |
scalar FK with nested_rules |
the recursion runs, then the field is assigned None |
reference lost β β written as absent/null |
In other words: a document carrying deep nested_rules (like UC 09's Author β books β publisher) cannot be created / updated as-is. If the graph must be written back, materialize references separately (save each Book to its collection first, then store list[ObjectId] without nested_rules), and keep nested_rules only on read rules you never hand back to depopulate.
π‘ Tips
- Never pass a raw
ObjectId-holding model tocreate/update: the "was populate skipped?"ValueErroris the guard. Read through the repo so the read path can populate first. - Unsaved references are a write-once trap: they embed as dicts, and re-reading raises the "run repair script"
ValueError. Save referenced docs to their collection first, then reference theirid. filter/projectiononPopulateRuleare declared but not applied by the engine β don't rely on them.- Reads cost 1 query per reference (no
$lookupyet); batch-heavy endpoints should add caching (next use case).