Skip to content

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.

โš ๏ธ PopulateRule does not have a separate "ref field" vs "target field" โ€” the ref field is the populated field. filter/projection on PopulateRule are 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 raises ValueError โ€” switch FK changes to update(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 a ValueError ("run repair script") โ€” migrate embedded docs to a separate collection first.
  • If create/update receives a field that is already an ObjectId under a populate rule, _depopulate raises ValueError("...was populate skipped?") โ€” populate-then-depopulate pairs must be balanced.