Use Case 8: Document Population
Scenario: An API returns a User with its related Profile embedded in one JSON payload โ no second round-trip from the client, no joins.
๐ฆ What's New?
| Component | Description |
|---|---|
PopulateRule |
Declares which field to resolve. field_name holds the ObjectId (or list[ObjectId]) and is the same field the resolved document replaces in place. collection_name says where the referenced documents live. |
PopulationEngine |
Holds repositories by collection name and resolves rules recursively, detecting cycles (CircularReferenceError). |
PopulatingRepository[T] |
get_by_id/get_many populate on read; create/update depopulate on write; patch rejects FK fields. |
โ ๏ธ
PopulateRuledoes not have a separate "ref field" vs "target field" โ the ref field is the populated field.filter/projectiononPopulateRuleare declared but not yet applied by the engine.
๐ Example
Python
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from mongo_ops import BaseDocument, MongoConnectionManager, ModelRegistry, PopulatingRepository
from mongo_ops.populate import PopulateRule, PopulationEngine
# 1. Models โ `profile` holds an ObjectId in the DB and becomes a Profile on read.
class Profile(BaseDocument):
avatar_url: str = ""
bio: str = ""
class User(BaseDocument):
username: str = ""
email: str = ""
profile: Profile | None = None # ObjectId in MongoDB, Profile in memory
# 2. Engine + rule โ repositories are registered later (after connect()).
engine = PopulationEngine({})
profile_rule = PopulateRule(
field_name="profile",
collection_name="profiles",
)
# 3. The repository the app uses.
class UserRepository(PopulatingRepository[User]):
def __init__(self):
super().__init__(
collection_name="users",
model=User,
population_engine=engine,
populate_rules=[profile_rule],
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
async with MongoConnectionManager.lifespan(
uri="mongodb://localhost:27017", db_name="mydb"
):
# Tells the engine which repository resolves "profiles" refs.
engine.register_repo("profiles", PopulatingRepository[Profile]("profiles", Profile))
await ModelRegistry.initialize_all()
yield
app = FastAPI(lifespan=lifespan)
# 4. Writing โ pass the model; the repository depopulates to an ObjectId.
@app.post("/users/", response_model=User)
async def create_user():
user = User(
username="alice",
email="alice@example.com",
profile=Profile(avatar_url="alice.png", bio="hi"),
)
return await UserRepository().create(user)
# ^ stored as {"profile": <ObjectId>}, returned with profile populated
# 5. Reading โ get_by_id resolves the reference on the way back.
@app.get("/users/{user_id}", response_model=User)
async def get_user(user_id: str):
user = await UserRepository().get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user # user.profile is a Profile instance
๐ก Tips
Patching an FK field raisesValueErrorโ switch FK changes toupdate(user_id, model)instead. See use case 05 for the reasoning with soft deletes.- Missing references resolve to
None, not an error. - A field holding an embedded dict (instead of an
ObjectId) raises aValueError("run repair script") โ migrate embedded docs to a separate collection first. - If
create/updatereceives a field that is already anObjectIdunder a populate rule,_depopulateraisesValueError("...was populate skipped?")โ populate-then-depopulate pairs must be balanced.