Chapter 13 · Reps

Persistence III — MongoDB and the Document Model — Reps

← Back to Chapter 13

Chapter 13 — Reps

Conditioning, not grading. pymongo against a local MongoDB this week.

Ground rules:

  1. Type every line yourself. Including the filter documents. The $-operator vocabulary only sticks if your fingers learn it.
  2. Run everything. Every insert, every find, every update — run it, then open a mongosh shell (or re-query in Python) and confirm the database actually changed the way you expected. Trust nothing you didn’t observe.
  3. AI / agents are ON (Phase 2) — but these reps are still hand-built. Use an agent to look up an operator or explain an error if you’re stuck, the way you’d ask a senior. Do not have it write the reps for you. The modeling reps (8, 9, 11) especially: the decision is yours, or the rep is worthless.
  4. One client, reused. Create a single MongoClient at the top of each script and reuse it. Build the production habit now.

Setup for mongod + pip install pymongo lives in Appendix B. Everything below assumes mongod is running on localhost:27017. Each rep uses a throwaway database named reps13 so you can drop it and start clean: client.drop_database("reps13").


Reps 1–3: Connect and CRUD

Rep 1 — Hello, Document

Write rep01.py:

from pymongo import MongoClient

client = MongoClient("mongodb://localhost:27017/")
notes = client["reps13"]["notes"]

notes.delete_many({})   # start clean

notes.insert_one({
    "title": "Counting the Cost",
    "passage": "Luke 14:28",
    "tags": ["discipleship", "cost"],
})
notes.insert_one({
    "title": "On Vocation",
    "passage": "1 Cor 7:17",
    "tags": ["vocation"],
    "speaker": "Pastor Lindemann",   # a field the first doc doesn't have
})

for doc in notes.find():
    print(doc)

Run it. Confirm two documents print, each with an _id MongoDB generated. Note that the second has a speaker field and the first does not — and the collection accepted both. That is the document model. Write one sentence in a comment: what would Postgres have required before accepting these two rows?


Rep 2 — Filter Documents

Against the notes collection from Rep 1, write and run each of these queries. Predict the result before you run each one, then check:

  1. notes.find_one({"title": "On Vocation"}) — exact match.
  2. notes.find({"tags": "cost"}) — array membership (matches the first doc).
  3. notes.find({"speaker": {"$exists": True}}) — only docs that have a speaker field.
  4. notes.find({"speaker": {"$exists": False}}) — only docs that don’t.
  5. notes.find({"tags": {"$in": ["vocation", "grace"]}}) — tags contains any of these.

For #2, write a one-line comment explaining why matching the scalar "cost" against the array tags works. (This is the §13.4 idea.)


Rep 3 — Update Without Wiping

Add to the same script:

  1. update_one to set year: 2026 on the “On Vocation” note — using $set. Confirm the other fields survive.
  2. Now, on purpose, run update_one({"title": "On Vocation"}, {"speaker": "X"}) without $set. Print the document. Watch the entire document get replaced by {"_id": ..., "speaker": "X"}. Every other field is gone.
  3. Restore the document. Write a comment: this is the §13.7 bug. Never forget the operator.
  4. Use $push to append "calling" to the “On Vocation” note’s tags array.
  5. Use $inc to create-and-increment a view_count on it (run it twice; confirm it reaches 2).

The deliberate destruction in #2 is the rep. You will only respect $set after you’ve watched its absence eat a document.


Reps 4–6: The Query SQL Hates, and Nested Fields

Rep 4 — Natural Here, Awkward There

Insert three study entries with an arbitrary nested metadata object:

entries = client["reps13"]["entries"]
entries.delete_many({})
entries.insert_many([
    {"word": "charis", "tags": ["grace"],
     "metadata": {"language": "Greek", "strongs": "G5485"}},
    {"word": "chesed", "tags": ["grace", "covenant"],
     "metadata": {"language": "Hebrew", "strongs": "H2617"}},
    {"word": "agape", "tags": ["love"],
     "metadata": {"language": "Greek"}},
])

Now write the query: every entry tagged grace whose metadata.language is Greek.

for e in entries.find({"tags": "grace", "metadata.language": "Greek"}):
    print(e["word"])

It should print charis only. Then, in a comment block, write out the SQL you’d need to get the same answer if metadata were arbitrary key/value rows in an EAV table plus a separate tags table. Count the joins. That contrast is the whole rep.


Rep 5 — Projection

Re-run the Rep 4 grace-and-Greek query, but return only the word and metadata.strongs, suppressing _id:

entries.find({"tags": "grace", "metadata.language": "Greek"},
             {"word": 1, "metadata.strongs": 1, "_id": 0})

Confirm the returned dicts contain only those fields. This is SELECT word, strongs in document form.


Rep 6 — Delete and Count

  1. delete_one the agape entry. Confirm entries.count_documents({}) drops by one.
  2. Run entries.delete_many({"tags": "grace"}). Confirm both grace entries are gone.
  3. Don’t run this, just write it as a comment and explain the danger: entries.delete_many({}). What does an empty filter match? (§13.7.)

Reps 7–9: Modeling — Embed, Reference, and Both Ways

Rep 7 — Embed vs Reference

Model a sermon-with-speaker two ways in a modeling database.

Embedded — speaker info nested inside the sermon:

sermons.insert_one({
    "title": "The Cost of Discipleship",
    "speaker": {"name": "Pastor Lindemann", "ordained": 2009},
})

Referenced — a separate speakers collection, sermon stores an _id:

sid = speakers.insert_one({"name": "Pastor Lindemann", "ordained": 2009}).inserted_id
sermons.insert_one({"title": "On Vocation", "speaker_id": sid})

Then: write the code to fetch “the sermon and its speaker’s name” for each model. Notice the embedded one is one query; the referenced one is two (fetch the sermon, then fetch the speaker by _id). Write a two-line comment: which would you choose if the speaker’s name changes often, and why? (§13.5: embed = duplication + one read; reference = single source of truth + two reads.)


Rep 8 — The Same Data, Both Models

Take this one study entry:

{
  "word": "charis",
  "passage": "Eph 2:8",
  "tags": ["grace", "salvation"],
  "metadata": {"language": "Greek", "strongs": "G5485"},
  "cross_refs": ["Rom 3:24", "Titus 2:11"]
}
  1. Store it as a single MongoDB document (you basically already can — insert it).
  2. On paper (or in a comment), design the relational schema that holds the same information: how many tables? (entries, tags, cross_refs, and an EAV-ish metadata — count them.) Write the CREATE TABLE statements.
  3. Make a two-column list: what the document model makes easy / hard vs what the relational model makes easy / hard, for this data. At minimum address: adding an entry with brand-new metadata fields; querying “all Greek grace words”; ensuring every entry has a valid passage.

This rep is the Medium tier of the project in miniature. Do it honestly — both columns have real wins.


Rep 9 — Schema Flexibility Is Also Danger

Into one collection, deliberately insert these four documents, then write the query find({"tags": "grace"}):

c.insert_one({"title": "A", "tags": ["grace"]})
c.insert_one({"titel": "B", "tags": ["grace"]})    # typo key
c.insert_one({"title": "C", "tags": "grace"})      # tags is a string, not a list
c.insert_one({"title": "D", "tags": ["grace"], "title2": 7})

Which of these does find({"tags": "grace"}) match? Predict, then run. (Surprise: the string-tags doc also matches, because {"tags": "grace"} matches both “field equals the string” and “array contains the string.”) Then try to print each match’s ["title"] and watch doc B (the typo) be missing a title.

In a comment, answer: which of these four would Postgres have rejected at insert time, and which would have silently entered the database there too? This is §13.5 — you are the guardrail now.


Reps 10–11: The Architect’s Decision

Rep 10 — Upsert and Bulk

  1. Write an update_one(filter, {"$set": {...}}, upsert=True) that updates a sermon if it exists and inserts it if it doesn’t. Run it twice with the same filter; confirm the second run updates rather than duplicating.
  2. insert_many ten small documents in one call. Confirm count_documents({}) jumps by ten. Note that one round trip inserted all ten — the batching lesson.

Rep 11 — Walk the Decision Table Cold

No code. For each of these three applications, walk the five-constraint table from §13.6 (query patterns, consistency needs, schema stability, relationships, scale shape) and write a one-paragraph verdict: relational, document, or mixed — and why. Name at least one constraint that drove the call.

  1. A church membership system — people, households, small groups, giving records, attendance. Queried every which way; reports across all of it; money must reconcile exactly.
  2. A sermon archive — each sermon has a title, passage, audio link, and free-form notes whose structure differs by preacher and series; readers fetch one whole sermon at a time; thousands of them, read-heavy.
  3. A small-group event log — append-only stream of activity events of many different shapes (joined, posted, prayed-for, RSVP’d), rarely updated, occasionally queried by group and date range.

For at least one of them, your honest answer should be mixed — and you should say which piece goes where. If all three came out “use Mongo,” you skipped §13.5; go back.


Done? One Last Thing.

From scratch, no looking — in a single script:

  1. Connect to local Mongo with one MongoClient.
  2. Create a journal collection and insert_one a document-shaped study entry with at least one array field and one nested object.
  3. find it back by an array-membership filter.
  4. update_one a nested field using $set and dot notation (e.g. {"$set": {"metadata.reviewed": True}}).
  5. Write the SQL-hating query (a filter on a nested field + array membership) and, in a comment, the multi-join SQL it would have replaced.
  6. delete_one it. Confirm the collection is empty.

If you can do that cold — connect, model a document, CRUD it, and articulate the one query that justifies the document model — you have the week’s mechanical skill. The judgment (Rep 11) is the part the project will test hardest.


Up next: Project 13 — Project 13: Model It in Mongo.