Talking to MongoDB: CRUD, filters, projections and nested documents

After this lesson you will write MongoDB's create, read, update and delete commands the way the exam expects them, build filters from the five operator families, mask and sort what find returns, and query sub-documents and arrays without falling into the type trap or the any-element trap.

Mark Jonhson — the exercise session spells him this way on every slide — has two mobile phones: +39 XXX 3456 778 with Wind, and +39 YYY 9874 123 with TIM. You ask MongoDB for the people with a Wind phone whose number is +39 YYY 9874 123. No such phone exists.

MongoDB returns Mark, and the session shows it on purpose (p. 1437). The exam of 6 September 2024 asks to "count the number of chefs who cooked at least one dish named “Octopus with Potatoes”, which cost 25.00€" — the same shape, two conditions on one element of an array, for 2 points.

Why does Mark come back, and what do you write when you mean "the same phone"? The answer needs filters, dotted paths, types and arrays, and it arrives in section 5.

Four verbs in a JavaScript shell

Every command of this lesson is typed into the shell that the lecture lists among MongoDB's processes: "Mongo – an interactive shell ( a client)", a "Fully functional JavaScript environment for use with a MongoDB" (p. 321). The other two processes of that slide, mongod and mongos, belong to lesson 16; today's shell is mongosh, typed the same way.

So a command is a JavaScript method call — db, the collection, the method — and its arguments are JavaScript objects. A key may go without quotes ({amenities: 1}, p. 1492) unless it contains a dot, and a missing brace is a syntax error before MongoDB sees anything.

CRUD is "Create – Read – Update – Delete" (p. 313). use smbud selects a database, which is created with its first document; a collection, too, appears with its first insert, and db.createCollection('people_collection') creates it empty. The session inserts with insertOne, or with insertMany and "a list of comma-separated documents" (p. 1421):

// adds two documents at once; MongoDB gives each an _id
db.people_collection.insertMany([
   {"name": {"first": "Mike", "last": "Ruby"}, "age": 63},
   {"name": {"first": "Anna", "last": "Verdi"}, "age": 29}
])

find(filter) returns every match, findOne(filter) "the first document that satisfies one or more conditions", countDocuments(filter) their number (p. 1424; lesson 13 introduced them). The lecture's find().pretty() (p. 314) only formats the output.

updateOne "collects the documents that satisfy one or more conditions defined in a filter and updates the first one found"; updateMany updates them all (p. 1425):

// sets Mike's age to 15; his name stays as it is
db.people_collection.updateOne(
   {"age": 63},
   {"$set": {"age": 15}}
)

The second argument must hold an update operator. $set rewrites the fields it names, adds missing ones and leaves the rest alone. Without an operator today's updateOne refuses to run ("Update document requires atomic operators"); to swap the whole document you call replaceOne(filter, newDocument), as the lecture's save did (p. 319).

before (p. 313) { _id: ObjectId("…"), "name": "nguyen", "age": 24, "gender": "male" } "$set": {"age": 20} { _id: ObjectId("…"), "name": "nguyen", "age": 20, "gender": "male" }

An update operator: one field changes

before (p. 313) { _id: ObjectId("…"), "name": "nguyen", "age": 24, "gender": "male" } replaceOne { _id: ObjectId("…"), "name": "nguyen", "age": 20 "gender": "male" lost }

A replacement: the whole document is swapped

Fig. 1. The lecture's student (p. 313), "age 20" written two ways. $set — in the lecture's update (p. 319) and the session's updateOne (p. 1425) — rewrites the field it names and keeps the others. A replacement document becomes the whole document, so a field you did not repeat is gone: the lecture's save and today's replaceOne do this on purpose.

deleteOne "deletes the first one found", deleteMany "all the collected documents" (p. 1426). The lecture adds db.people_collection.drop(), which removes a collection with its indexes, db.dropDatabase() and show dbs (p. 320).

// deletes Mike, whose age is now 15
db.people_collection.deleteOne(
   {"age": 15}
)

The lecture prints the shell's older method names, the session the current ones — write those at the exam:

The same four verbs in the lecture and in the exercise session.
VerbLecture (pp. 313–320)Session (pp. 1421–1426) — write these
Createinsert({…})insertOne({…}), insertMany([{…}, {…}])
Readfind().pretty()find, findOne, countDocuments
Updateupdate(<select_criteria>,<updated_data>)updateOne, updateMany with $set
Replacesave({_id: …, …})replaceOne(filter, document) (not on the slides)
Deleteremove({…})deleteOne, deleteMany
Dropdrop(), dropDatabase()the same

The new names say what the old ones hid: the old update changed the first match, the old remove every match.

Every read, update and delete starts from a filter; "One" acts on the first match, "Many" on all, and an update needs an operator such as $set.

A filter is a document

The lecture's first query with a condition (p. 314):

// the female students aged 20 or less
db.students.find({ "gender": "female", "age": {$lte:20} }).pretty()

Read it aloud. A field with a value is an equality; a field with a document applies the operator inside it, here $lte, "less than or equal"; all the fields must hold — an implicit AND, SQL's WHERE gender = 'female' AND age <= 20. "It’s pattern matching again!", the slide adds: you describe the documents you want, as Cypher's MATCH does (lesson 11).

The next slide maps SQL to MongoDB (p. 315). Two of its lines are wrong; the right-hand column gives the form to write.

"Read – mapping to SQL" (p. 315), as printed, with the form the session writes.
SQL statementMongoDB, as printedWrite it like this
SELECT * FROM tabledb.collection.find()the same
SELECT * FROM table WHERE artist = 'Nirvana'db.collection.find({Artist:"Nirvana"})find({"artist": "Nirvana"}) — field names are case-sensitive
SELECT* FROM table ORDER BY Titledb.collection.find().sort(Title:1)find().sort({"Title": 1})
DISTINCT.distinct()distinct("artist") — it takes the field name
GROUP BY.group()the $group stage of aggregate(), lesson 15
>=, <$gte, $ltthe same

The session sorts the operators into five families (p. 1430), with an example of each on the slides that follow:

"MongoDB – Query Operators" (p. 1430), with one example each from pp. 1431–1435.
FamilyOperatorsExample on the slides
Logical$and, $or, $not, $nor{"$or": [{"name.first": "Mark", "name.last": "Jonhson"}, {"age": 37}]}
Comparison$eq, $ne, $gt, $gte, $lt, $lte, $in, $nin{"age": {"$nin": [40, 41, 42, 44]}}
Element$exists, $type{"birth_date": {"$type": "date"}}
Evaluation$regex, $expr, $mod, $text, $where{"name.first": {"$regex": "[A-Z][a-z]+"}}
Array$all, $elemMatch, $size, $in{"mobile_phone_numbers": {"$size": 2}}

A comparison sits inside a field's value, {"age": {"$gt": 19}}; a logical operator sits at the top and takes an array of complete filters, each in its own braces. The nested example of p. 1432, braces balanced:

// aged 20 to 31, or not 40, 41, 42, 44: returns Mark (37)
db.people_collection.find({
   "$or": [
      {"$and": [
         {"age": {"$gt": 19}},
         {"age": {"$lte": 31}}
      ]},
      {"age": {"$nin": [40, 41, 42, 44]}}
   ]
})

Two conditions on one field may also share a document, {"age": {"$gt": 19, "$lte": 31}}, and $not goes inside a field: {"age": {"$not": {"$gt": 30}}}. One definition on p. 1431 is wrong:

$exists asks whether a field is there at all — the check the exam of 21 January 2025 demands for an optional relationship: "be sure to check that at least one book inspired the movie (-0.5 PT otherwise)". $type asks for the type, {"birth_date": {"$type": "date"}} (p. 1433). $regex finds a pattern anywhere inside a string, case-sensitive: "[A-Z][a-z]+" matches "Mark" (p. 1434), and {"$regex": "Pier"} answers "includes the word “Pier”" (exam of 24 June 2024). $text needs a text index, $where runs JavaScript on each document (p. 1434).

A comparison goes inside a field; a logical operator takes an array of whole filters.

Masks, order and the top N

The second argument of find is a projection: "lists of key-value pairs made by the field name and a boolean value representing whether the field will be returned (1) or not (0). They can be seen as masks" (p. 1427). An inclusion mask keeps the listed fields — plus _id, unless you write "_id": 0; an exclusion mask drops them. The two kinds do not mix ("Cannot do exclusion on field address in inclusion projection").

// only the age of every person, without the _id
db.people_collection.find({}, {"age": 1, "_id": 0})

A dotted path keeps the nested shape: {"host.host_response_rate": 1}, the mask of Ex. 5 (p. 1496), returns {"_id": "10006546", "host": {"host_response_rate": 100}} on the listing of section 4. The slide also computes a "New (Temporary) Field", year — with one character missing:

The next slide fixes the order (p. 1428): find "can only perform filters and projections in that exact order". So find({"year": 1986}, {"year": {"$year": "$birth_date"}}) returns nothing — year does not exist when the filter runs. Filtering on a computed value needs the aggregation pipeline.

Sort and limit (p. 1429): sort "accepts a list of fields and their ordering, i.e. descending (-1) and ascending (1). The earlier a field is referenced, the more relevant it is for the ordering" — sort({"name.first": 1, "name.last": 1}) is ORDER BY first, last. limit(n) keeps the first n, so a top N is a descending sort and a limit — Ex. 4 (p. 1495):

// the 10 housings with the most bedrooms; shows bedrooms (and _id)
db.air_bnb_collection.find({},
  { "bedrooms": 1 }
).sort(
  { "bedrooms": -1 }
).limit(10)

On a find the two calls are settings of one query: .limit(2).sort(…) still sorts first. In the pipeline of lesson 15 the stages run in the order written, and $limit before $sort keeps arbitrary documents. Ties come back in no fixed order; add a second sort key if it matters.

find filters first and applies the mask after; sort decides the order, limit cuts.

Types are data: the AirBnB listing

The second half of the session queries the AirBnB sample dataset that MongoDB offers with its free Atlas clusters, imported as air_bnb_collection; pp. 1472–1474 show its first document, the "Ribeira Charming Duplex" in Porto. One listing is one tree: plain values, sub-documents (host, address, availability, review_scores, images), an array of values (amenities) and an array of sub-documents (reviews) — what the session calls a sub-collection. Ex. 1 (p. 1492) inspects them one by one:

// the first housing, showing only its amenities (and _id)
db.air_bnb_collection.findOne({ }, {amenities: 1})

On the slide, minimum_nights: "2" is printed in quotes and green, number_of_reviews: 51 without quotes and blue (p. 1472): a string and a number, as MongoDB's documentation of the dataset confirms. Hence the quotes of Ex. 2 (p. 1493), whose footnote returns the field "just to check that the answer is correct":

// housings whose minimum nights is the string "2"
db.air_bnb_collection.find(
  { "minimum_nights": { "$eq": "2" } },
  { "minimum_nights": 1 }
)

MongoDB compares a filter's value only with values of the same kind — strings with strings, numbers with numbers, dates with dates. "2" and 2 are different values, and {"$gt": "10"} against stored numbers returns nothing, without an error; within numbers the storage type does not matter (80 finds 80.00). At the exam the types are those you declared in your documental representation — the papers ask for "all attributes and their types" — and the queries must use them: counts and amounts as numbers, names and codes as strings, dates as ISODate("2020-03-17").

The type trap, field by field

The Ribeira listing of pp. 1472–1474, trimmed to the fields the exercises use. Click a field (or pick it in the menu): the readouts give its dotted path, its type and a filter written with the right type, and the five listings light up when that filter returns them. The switch writes the filter's value with or without quotes. Ribeira's values are printed on the slides, except its review by Bridget; its numeric types (int, decimal) are those of MongoDB's documentation of the sample dataset. The other four listings are invented so that every case occurs — Tiny Studio, for one, stores its minimum nights as a number.

  1. minimum_nights is selected, with the session's filter of Ex. 2 (p. 1493). Predict which listings it returns. Then switch the quotes off, so that the filter holds the number 2.
    What you should see

    With "2": Ribeira, River Loft and Attic Room. With 2: only Tiny Studio, the one listing that stored a number. The two answers do not even overlap. The insight of the widget: a value in a filter has a type, and a string is never equal to a number.

  2. Click number_of_reviews (Ex. 3, p. 1494). Before switching the quotes on, predict what {"$gt": "10"} returns.
    What you should see

    Nothing, and no error: "10" is a string, the stored counts are integers, and $gt compares only values of the same kind. Ribeira (51), Big Villa (20) and River Loft (12) come back only with the number 10.

  3. Click price, printed as 80.00 (a Decimal128 in the sample dataset). Does the integer 80 find it?
    What you should see

    Yes: Ribeira, Big Villa and Attic Room. Numbers are compared by value, whatever their numeric type; only the quoted "80" fails. The rule is "the same kind" — numbers with numbers, strings with strings, dates with dates — not "the same storage type".

  4. Click first_review and compare ISODate("2016-01-01") with the quoted "2016-01-01".
    What you should see

    With the date: Ribeira (2016), Tiny Studio (2018) and River Loft (2017); Big Villa's first review is from 2015, and Attic Room has no first_review at all. With the string: nothing. When a paper says "after 17/03/2020", the value in your query is ISODate("2020-03-17"), not text.

Ex. 3 asks for a number, and the session's solution returns something else:

Ex. 5 (p. 1496) reaches into a sub-document:

// housings whose host response rate is over 90; shows the rate
db.air_bnb_collection.find(
  { "host.host_response_rate": { "$gt": 90 } },
  { "host.host_response_rate": 1 }
)

The dotted key needs quotes, since host.host_response_rate is not a valid JavaScript key. Otherwise a sub-document behaves like plain fields — such conditions "are not subject to any particular behaviour change" (p. 1466) — and $gt is strict, so a host at exactly 90 stays out. A whole sub-document in a filter, {"address": {"city": "Milan"}}, must match exactly, every field in the same order: it finds nothing.

Arrays of values: contains, all of, any of

Ex. 6 and Ex. 7 (pp. 1497–1498) differ by one operator, $and against $or:

// housings that include a TV and an Iron; shows name and amenities
db.air_bnb_collection.find(
  {
    "$and": [
      { "amenities": "TV" },
      { "amenities": "Iron" }
    ],
  },
  { "name": 1, "amenities": 1 }
)

On an array, {"amenities": "TV"} means "contains "TV"". Two such conditions need $and, because a JavaScript object cannot hold the key amenities twice (the trailing comma, by the way, is legal in the shell). The array family has shorter forms; on the widget's listings they return:

Conditions on the array of values amenities; results on the widget's five listings, checked on a real server.
FilterMeaningReturned
{"amenities": "TV"}contains TVRibeira, Big Villa, Attic Room
$and of TV and Iron (Ex. 6)contains bothBig Villa, Attic Room
{"amenities": {"$all": ["TV", "Iron"]}}contains all of them, in any orderBig Villa, Attic Room
$or of TV and Iron (Ex. 7)contains at least oneRibeira, Tiny Studio, Big Villa, Attic Room
{"amenities": {"$in": ["TV", "Iron"]}}the same as Ex. 7Ribeira, Tiny Studio, Big Villa, Attic Room
{"amenities": ["TV", "Iron"]}is exactly this array, in this orderAttic Room
{"amenities": {"$size": 2}}has exactly two elementsTiny Studio, Attic Room

The highlighted row is the trap: brackets ask for the whole array, not its contents. And $size takes a number only — {"$size": {"$gt": 1}} is refused.

"The mask {"name": 1} returns the names of the hosts." Ex. 6 and Ex. 7 ask for "the name of the hosts", but name is the housing's, "Ribeira Charming Duplex"; the host's is host.host_name, "Ana&Gonçalo" (p. 1473). At the exam the mask answers the question — {"host.host_name": 1, "_id": 0}; compare "Return only its Product_ID" (exam of 6 September 2024).

A value in a filter has a type — "2" is not 2 — and one value on an array of values means "contains".

Arrays of sub-documents: the same element or any element

Back to Mark (p. 1419): mobile_phone_numbers is a sub-collection. One dotted condition tests every element — "queries evaluating a single condition on the fields of the documents of an array (sub-collection) will return the main document if at least one of the documents in the array satisfies the condition" (p. 1436):

// people with at least one Wind phone: returns Mark
db.people_collection.find(
   {"mobile_phone_numbers.service_provider": "Wind"}
)

Several conditions are "assessed individually on the array’s documents": "It doesn’t matter if one item matches all the conditions or if different items match different conditions" (p. 1437). That is the warm-up:

// returns Mark: Wind is found in one element, YYY in the other
db.people_collection.find(
   {"mobile_phone_numbers.service_provider": "Wind",
    "mobile_phone_numbers.phone_number": "+39 YYY 9874 123"}
)

To tie the conditions to one element, $elemMatch "matches documents containing an array field with at least one document that satisfies all the specified query criteria" (p. 1438):

// returns Mark: TIM and YYY sit in the same element
db.people_collection.find({
   "mobile_phone_numbers": {
      "$elemMatch": {
         "service_provider": "TIM",
         "phone_number": "+39 YYY 9874 123"
      }
   }
})

With Wind in place of TIM the same filter returns nothing.

mobile_phone_numbers element 0 number "+39 XXX 3456 778" provider "Wind" A B element 1 number "+39 YYY 9874 123" provider "TIM" A B A, provider Wind: found in element 0 B, number YYY: found in element 1 every condition found: Mark is returned

Dotted paths: each condition on its own (p. 1437)

mobile_phone_numbers element 0 number "+39 XXX 3456 778" provider "Wind" A B element 1 number "+39 YYY 9874 123" provider "TIM" A B element 0: A only element 1: B only no element has both: not returned

$elemMatch: all conditions on one element

Fig. 2. Mark's two phones (p. 1419) and the two conditions of the warm-up, A = provider "Wind", B = number "+39 YYY 9874 123". The marks are the same in both panels; only the reading changes. Dotted paths read down the columns — is A somewhere? is B somewhere? — and return Mark. $elemMatch reads along the rows — does one element have both? — and does not.

Step by step, on Mark:

  1. Condition A, provider "Wind": the path runs into an array, so MongoDB tries every element. Element 0 says Wind — A holds.

  2. Condition B, number "+39 YYY 9874 123": element 0 no, element 1 yes — B holds, on another element.

  3. Both conditions hold, so Mark is returned: nothing asked them to look at the same element (p. 1437).

  4. $elemMatch swaps the loops: one element at a time, all the conditions on it. Element 0: Wind yes, YYY no; element 1: Wind no, YYY yes. No element passes both — Mark is not returned.

  5. With TIM and YYY (p. 1438) element 1 passes both, and both forms return Mark.

In logic the two filters differ only in the order of two quantifiers:

\[ \forall i \;\; \exists e \in A : \; c_i(e) \] \[ \exists e \in A \;\; \forall i : \; c_i(e) \]
\(A\)
the array, e.g. mobile_phone_numbers
\(c_i\)
the conditions of the filter
first line
dotted paths: every condition finds some element, not necessarily the same one
second line
$elemMatch: one element passes every condition

The second line implies the first, but not the reverse: $elemMatch never returns more documents, and with one condition the two coincide. The widget replays the session's pair on reviews: Ex. 14 asks for "at least one review with listing id 10006546 and reviewer id 11263097" (p. 1505, solved with $elemMatch), Ex. 15 for "at least one review with listing id 10006546 and one with reviewer name Bridget" (p. 1506, dotted paths).

Same review or any review?

Three listings and their reviews — the sub-collection of Ex. 14 and Ex. 15. Pick two conditions and the form of the filter. Each review card shows the conditions it satisfies (A round, B square); a listing is returned when its badge lights up, and in the $elemMatch form the review that satisfies both is highlighted. Ribeira's review by Théo is the one printed on p. 1474; every other review and the two other listings are invented — Big Villa's first review even carries Ribeira's listing id, so that the session's two conditions can fall on two different reviews.

  1. The start is Ex. 15's pair, A = listing_id "10006546" and B = reviewer_name "Bridget", in the dotted form of the session. Predict which listings are returned; then switch on $elemMatch.
    What you should see

    Dotted: Ribeira and Big Villa. Big Villa passes because Anna's review carries the listing id and Bridget's review carries the name: two different reviews. With $elemMatch only Ribeira is left, where Bridget's review has both. That is the insight of the widget, and of pp. 1437–1438: dotted conditions may be satisfied by different elements, $elemMatch needs one element that satisfies them all.

  2. Set B to reviewer_id "11263097", Ex. 14's pair. Do the two forms still differ?
    What you should see

    No: both return Ribeira only. Tiny Studio has a review by 11263097, but on listing 2; Big Villa has no review by 11263097. The forms differ only when a document's conditions are met by different elements — on this data a test run would not reveal the mistake, so the wording of the question has to choose the operator.

  3. Pick A = reviewer_name "Bridget" and B = reviewer_name "Théo". Predict both forms.
    What you should see

    Dotted: Ribeira, which has one review by each. $elemMatch: nothing — a review has one author. A filter document cannot hold the same key twice, so the widget writes both forms with $and.

  4. Try to find two conditions for which $elemMatch returns a listing that the dotted form does not.
    What you should see

    There are none. If one review satisfies A and B, then A is satisfied somewhere and B is satisfied somewhere: every $elemMatch result is also a dotted result. $elemMatch can only remove listings.

So read the question first. "At least one X with A and B" — one element, both properties — is $elemMatch; "one X with A and one with B" is two dotted paths; a single condition is the same either way. The warm-up's chef question is the first kind, and it is the first exam question below.

Several dotted conditions on an array may be satisfied by different elements; $elemMatch demands one element that satisfies them all.

Indexes: fast reads, paid for by writes

Without an index, find reads every document and tests the filter on each. An index is a second structure kept sorted — "B+ tree indexes" (p. 322); for the session, "data structures that store a small portion of the collection's data set in an easy to traverse form, ordered by the value of the field" (p. 1422). A B+ tree reaches a value in a few steps and then reads its neighbours in order, so one index serves equality, ranges and sorting.

Page 322 lists the rest. "An index is automatically created on the _id field (the primary key)", which is what keeps every _id unique; "Users can create other indexes to improve query performance or to enforce Unique values for a particular field"; they are single-field or compound, and "Like SQL order of the fields in a compound index matters"; an indexed array gets "separate index entries for every element of the array" — a multikey index, in the current manual. The session's compound index (p. 1422):

// a compound index: last name descending, then first name, then age
db.people_collection.createIndex(
   {"name.last": -1, "name.first": 1, "age": 1}
)

1 and -1 mean ascending and descending, as in sort. Why the order of the fields matters shows on the leaves of the tree:

{"name.last": "Rossi"} last -1 first 1 age 1 VerdiAnna29 RossiLuca41 RossiMark22 RossiPaola35 NeriLia50 JonhsonMark37 BianchiLuca28 BianchiMark33 one run of neighbours

A prefix of the index: one run (index scan)

{"name.first": "Mark"} last -1 first 1 age 1 VerdiAnna29 RossiLuca41 RossiMark22 RossiPaola35 NeriLia50 JonhsonMark37 BianchiLuca28 BianchiMark33 scattered: read it all

Not a prefix: scattered (collection scan)

Fig. 3. The leaves of the index of p. 1422 for eight people, sorted by name.last descending, then name.first, then age. A filter on name.last walks down the tree once and reads one run of neighbours; the three Marks, instead, are scattered over the whole order, so a filter on name.first alone cannot use the index. On a real server the first query runs as an index scan, the second as a collection scan.

This is the prefix rule: the index serves name.last, name.last with name.first, or all three — a prefix of its fields — but not name.first or age alone. A sort on name.last can use it either way round, since the tree reads backwards too. A composite index in SQL behaves the same: hence "Like SQL".

Page 323 adds: "Sparse property of an index ensures that the index only contain entries for documents that have the indexed field", and "If an index is both unique and sparse – then the system will reject records that have a duplicate key value but allow records that do not have the indexed field defined". Why: a unique index that is not sparse files a missing field as null, so a second document without it is a duplicate null and is refused. The typical case is an optional e-mail, unique when present:

// e-mail optional, but unique when present
db.users.createIndex({"email": 1}, {"unique": true, "sparse": true})

The price is printed under the session's index, in green and red: "Faster Queries and Improved Read Performance but Slower Writes and Increased Storage" (p. 1422). Every insert and delete must also update every index, an update every index on a field it changes, and every index takes disk space — an indexed array of twenty values adds twenty entries per document. Index what your queries filter and sort on, not everything.

An index trades write speed and storage for read speed, and a compound index serves only the prefixes of its fields.

Exam-style questions

Five exercises in the form of the practical part, then two theory questions. Each exercise starts from the ER scenario of its paper and states the documental representation it assumes — the query depends on it, which is why every paper asks for the design first. No solutions of past papers are published: these model answers are ours, run on a real MongoDB server against small test collections. In the theory part of the real exam, wrong answers may carry a penalty, announced just before the exam begins.

Exam of 6 Sep 2024, exercise 3 — a restaurant. ER scenario: Person (Personal_ID, Name, Surname, Birth Date, Phone Number) with the children Owner, Customer, Waiter and Chef (Hiring Date); Head Chef (Head Chef Since) is a child of Chef. Dish (Dish_ID, Name, Price, Recipe, Description); Ingredient (Ingredient_ID, Name, Allergies (Yes/No)). Dish 1:N — Cook — 1:N Chef; Dish 1:N — Use — 1:N Ingredient; Chef 1:1 — Manage — 1:N Head Chef. N.B. "Dishes are cooked by more Chefs overall, but only one at a time."

Assumed documental representation (ours): one collection chef; a head chef is a chef document that also has head_chef_since; the dishes a chef cooked are embedded as a sub-collection, each with its ingredients: {"_id": "P1", "name": "Antonio", "hiring_date": ISODate("2019-04-01"), "dishes": [{"dish_id": "D1", "name": "Octopus with Potatoes", "price": 25.00, "recipe": "…", "description": "…", "ingredients": […]}]}.

Write a query to count the number of chefs who cooked at least one dish named “Octopus with Potatoes”, which cost 25.00€. Perform the query starting from the Chef Collection or Sub-Collection. (2 PT)

Show model answer
db.chef.countDocuments({
  "dishes": {
    "$elemMatch": {"name": "Octopus with Potatoes", "price": 25.00}
  }
})

Both conditions describe the same dish, so they go into one $elemMatch on the sub-collection dishes; "count the number of chefs" asks for a number, so the method is countDocuments. The cost is the Price attribute, stored as a number.

This is the warm-up in a chef's apron. Written with two dotted paths, the filter would also count a chef who cooked an octopus at 30€ and some other dish at 25€: on our three test chefs the dotted form answers 2, $elemMatch answers 1. The price must be a number — 25.00 and 25 find the same value, the string "25.00" finds nothing.

Exam of 13 Jun 2025 (its header prints Jan. 21, 2025), exercise 3 — concert attendances and performances. ER scenario: Person (Personal ID, Name, Surname, Birth Date) with the children Maestro (Biography, Stage Name) and Musician (Biography), among others, (T, E). Orchestra (Name, Foundation Date, Biography); Instrument (Name, Type, Description). Maestro (1,1) — Direct — (1,1) Orchestra, with First Concert (Yes/No); Orchestra (10,N) — Compose — (1,2) Musician; Musician (1,N) — Play — (1,N) Instrument, with Since.

Assumed documental representation: the one of lesson 13 — one collection orchestra; the maestro is an embedded sub-document, the musicians a sub-collection, and each musician holds his instruments as a sub-collection: {"_id": "Orchestra Aurora", "maestro": {"Name": "Claudio", …}, "musicians": [{"Personal_ID": "P101", "Name": "Lorenzo", …, "instruments": [{"Name": "piano", …, "Since": …}]}]}.

Write a query to count the number of orchestras directed by directors named “Claudio” and including at least one member named “Lorenzo” who can play the piano (2 PT)

Show model answer
db.orchestra.countDocuments({
  "maestro.Name": "Claudio",
  "musicians": {
    "$elemMatch": {"Name": "Lorenzo", "instruments.Name": "piano"}
  }
})

The maestro is a sub-document, so a dotted path reaches his name. "At least one member named Lorenzo who can play the piano" is one musician with two properties: $elemMatch on musicians. Inside it, "instruments.Name": "piano" is a dotted path into that musician's own instruments, true if any of them is the piano.

The "directors" of the question are the maestros of Direct. Two dotted paths for the member would also count an orchestra where Lorenzo plays the cello and Fabiana the piano: on our three test orchestras the dotted form answers 2, the model answer 1.

Exam of 6 Jun 2024, exercise 3 — tracking natural disasters. ER scenario: Natural Disaster (ID, Start Time, Start Date, End Time, End Date, Disaster Description) with the children Earthquake (Magnitude, Mercalli Scale), Flooding (Water Height), Landslide (Landslide Class) and Tornado (EF Scale, 0 to 5), (T, E); Location (Postal Code, City, Country, Description). Location 0:N — Occurr — 1:N Natural Disaster; Cause, recursive on Natural Disaster, with Causal Description.

Assumed documental representation (ours): one collection natural_disaster; as the hierarchy is total and exclusive, each document is one child, with a type field and its own attributes; the locations of Occurr are embedded as a sub-collection, postal codes stored as strings: {"_id": "ND1", "type": "tornado", "ef_scale": 4, "start_date": …, "locations": [{"postal_code": "23563", "city": "…", "country": "Italy", "description": "…"}]}.

Write a query to return the list of Tornadoes with an EF Scale greater than 3 that affected at least one location whose Country is Italy and whose postal code is 23563 or that affected Milan. (2 PT)

Show model answer
db.natural_disaster.find({
  "type": "tornado",
  "ef_scale": {"$gt": 3},
  "$or": [
    {"locations": {
      "$elemMatch": {"country": "Italy", "postal_code": "23563"}
    }},
    {"locations.city": "Milan"}
  ]
})

Three conditions joined by the implicit AND: the type, the EF scale and an $or of two location clauses. "At least one location whose Country is Italy and whose postal code is 23563" is one location with two properties, hence $elemMatch; "affected Milan" is a single condition, hence a dotted path.

Read the "or" carefully: it separates the two location clauses, not the EF scale. With two dotted paths the first clause would also accept a tornado that hit location 23563 in another country and some other place in Italy. The postal code is a string because the design says so; written as the number 23563, the first clause would find nothing.

Exam of 24 Jun 2024, exercise 3 — tracking Disney's digital products. ER scenario: Company Section (ID, Name, Description, Number of Employees); Digital Content (Title, Release Date, Duration, Production Cost, Minimum Age) with the children Series (Number of Episodes, …) and Movie (Sequence Number, Nominated for Award (Yes/No), …), (T, E), and Animated Movie a child of Movie, (P, O); Script (ID, Length, Duration, Title). Company Section 1:N — Produce — 1:N Digital Content, with Starting Date and End Date; Digital Content 1:N — Describe — 1:1 Script.

Assumed documental representation (ours): one collection digital_content with a type field ("series" or "movie"); the scripts are embedded as a sub-collection, Script being the N side of Describe; the producing sections are embedded as a sub-collection with the dates of Produce: {"_id": "The Pier Saga", "type": "series", "number_of_episodes": 10, "company_sections": [{"id": "908686", "name": "Digital Artist & Co", "starting_date": …, "end_date": …}], "scripts": [{"id": "S1", "length": …, "duration": …, "title": "Pier 39"}]}.

Write a query to count the number of series produced by a company section named “Digital Artist & Co” with ID “908686” and for which at least one script includes the word “Pier” in its title. (2 PT)

Show model answer
db.digital_content.countDocuments({
  "type": "series",
  "company_sections": {
    "$elemMatch": {"name": "Digital Artist & Co", "id": "908686"}
  },
  "scripts.title": {"$regex": "Pier"}
})

The name and the ID describe one section, so they share one $elemMatch; the script condition is a single condition, so a dotted path is enough; "includes the word" is a $regex, which matches anywhere inside the title. The ID is written in quotes in the question and stored as a string.

Three families in one filter: comparison (the implicit $eq of "type": "series"), array ($elemMatch) and evaluation ($regex). $regex is case-sensitive and does not stop at word boundaries: "Pier" also matches "Pierce". If you want the word only, "\\bPier\\b" marks its boundaries — a refinement, worth one line of comment.

The session's AirBnB collection (the exercise is ours). ER scenario: Housing (ID, Name, Minimum Nights, Number of Reviews, Amenities (multi-valued)) and Host (Host_ID, Host Name, Response Rate); Housing (1,1) — Offered By — (1,N) Host; each housing has an Address with a Country. Documental representation: the session's air_bnb_collection — host and address are embedded sub-documents, amenities an array of strings, and minimum nights is stored as a string, as on p. 1472.

Write a query to return the host name and the number of reviews of the 5 housings with the highest number of reviews, among those located in Portugal, whose minimum nights is equal to 2 and that include Wifi.

Show model answer
db.air_bnb_collection.find(
  {"address.country": "Portugal", "minimum_nights": "2",
   "amenities": "Wifi"},
  {"host.host_name": 1, "number_of_reviews": 1, "_id": 0}
).sort({"number_of_reviews": -1}).limit(5)

Three conditions in one filter, an implicit AND: a dotted path into the address, a string "2" because minimum nights is stored as a string, and "Wifi" on the array of amenities, meaning "contains". The mask keeps only what the question asks for and removes the _id; the descending sort followed by the limit gives the top 5.

On the widget's five listings this returns Ribeira (51 reviews) and River Loft (12), both hosted by Ana&Gonçalo. With the number 2 it would return Tiny Studio instead — the type trap. Forgetting "_id": 0 returns one field the question did not ask for.

True or false: in MongoDB, $nor returns the documents that do not match at least one of the query expressions in its array.

  • True This is the sentence printed on p. 1431, but it describes NOT (A AND B): a document that fails only one of the expressions would be returned, and MongoDB does not return it.
  • False $nor returns the documents that fail all the expressions of its array: NOT (A OR B).
Show model answer

False. $nor selects the documents that fail every query expression in its array: {"$nor": [A, B]} means NOT (A OR B). A document that matches A but not B is not returned.

The slide's wording is the trap, so this is the kind of statement a true-or-false question can quote. A one-line check: Mark, aged 37, is not returned by {"$nor": [{"age": 37}, {"name.first": "Bob"}]}, although he fails one of the two.

Explain how MongoDB uses indexes: which index exists in every collection, why the order of the fields of a compound index matters, what happens when an array field is indexed, and what a unique and sparse index allows. What is the cost of an index?

Show model answer

Indexes are B+ tree structures that keep a small part of the data sorted by the indexed fields, so equality and range filters and sorts can be answered without scanning the whole collection. An index on _id, the primary key, is created automatically in every collection and keeps the _id values unique. A compound index is sorted by its first field, then by the second within equal values of the first, and so on: as in SQL, it serves queries on a prefix of its fields — for an index on (a, b, c): a; a and b; a, b and c — but not on b alone. Indexing a field that holds an array creates one index entry for every element of the array. A sparse index contains entries only for the documents that have the field; if it is also unique, duplicate values are rejected but any number of documents without the field are accepted, whereas a unique index that is not sparse would reject the second document without the field as a duplicate null. The cost: every insert and delete, and every update of an indexed field, must also update the indexes, so writes are slower, and the indexes take extra storage.

A complete answer covers five points: the automatic and unique _id index; the prefix rule with an example; one entry per array element; why unique needs sparse when the field is optional (the duplicate null); and the read/write trade-off that p. 1422 prints in green and red.

Check yourself

Mark's phones are [{number XXX, provider Wind}, {number YYY, provider TIM}], stored with the number first. Which filters return Mark? Select all that apply.

  • {"mobile_phone_numbers.service_provider": "Wind", "mobile_phone_numbers.phone_number": "+39 YYY 9874 123"} Each dotted condition finds its own element: Wind in the first, YYY in the second (p. 1437).
  • {"mobile_phone_numbers": {"$elemMatch": {"service_provider": "Wind", "phone_number": "+39 YYY 9874 123"}}} No single element has Wind and YYY together.
  • {"mobile_phone_numbers": {"$elemMatch": {"service_provider": "TIM"}}} With one condition $elemMatch and a dotted path say the same thing: some element is a TIM phone.
  • {"mobile_phone_numbers": {"service_provider": "TIM", "phone_number": "+39 YYY 9874 123"}} This compares an element with a whole sub-document, which must match exactly — fields in the same order. The stored element has the number first, so nothing matches.

Dotted paths: every condition somewhere. $elemMatch: all the conditions on one element. A whole sub-document: an exact copy.

In the session's collection, minimum_nights is stored as a string in every listing — "2" for Ribeira. What does {"minimum_nights": 2} return?

  • Every housing with a minimum of 2 nights Only if the value were stored as a number. A string is never equal to a number.
  • Nothing, without any error The filter holds a number, the data holds strings: MongoDB compares values of the same kind only, and an empty result is a valid result.
  • An error message about the type MongoDB does not check types against the data: the query runs and finds nothing.
  • The housings with 2 nights or more An equality never becomes a range.

Which fields of Mark does db.people_collection.find({}, {"name": 1}) return?

  • Only name The _id comes along unless the mask says "_id": 0.
  • _id and name An inclusion mask keeps the listed fields plus the _id.
  • Every field except name That would be the exclusion mask {"name": 0}.
  • An error: the filter is empty An empty filter {} matches every document.

You run db.students.updateOne({"name": "nguyen"}, {"age": 20}) in today's shell. What happens?

  • The age becomes 20 and the other fields stay That needs an operator: {"$set": {"age": 20}}.
  • The document is replaced by {"age": 20} That is what the old update did with a plain document, and what replaceOne does on purpose (Fig. 1).
  • The command is refused: the update document needs an operator Without an operator such as $set, updateOne stops with "Update document requires atomic operators".
  • Every student named nguyen is updated Even with an operator, updateOne touches only the first match; updateMany touches all.

With the index {"name.last": -1, "name.first": 1, "age": 1}, which query cannot use it, so that MongoDB falls back to a collection scan?

  • find({"name.last": "Rossi"}) name.last is the first field of the index: one run of neighbours.
  • find({"name.last": "Rossi", "name.first": "Mark"}) The first two fields form a prefix of the index.
  • find({"name.first": "Mark"}) name.first is not a prefix: the Marks are scattered over the order of the index (Fig. 3).
  • find().sort({"name.last": 1}) The index is sorted by name.last; read backwards, it gives the ascending order.

Big Villa's amenities are ["TV", "Iron", "Wifi"]. Which filters return it? Select all that apply.

  • {"amenities": "Iron"} One value on an array means "contains".
  • {"amenities": ["TV", "Iron"]} Brackets ask for exactly this array, in this order — Big Villa's has three elements.
  • {"amenities": {"$all": ["Iron", "TV"]}} Contains all the listed values, in any order; extra elements are allowed.
  • {"amenities": {"$size": 2}} $size asks for exactly two elements; Big Villa has three.
  • {"amenities": {"$in": ["Sauna", "Wifi"]}} Contains at least one of the values: Wifi.

A collection has a unique and sparse index on email. You insert two documents that have no email field. What happens?

  • The second is rejected as a duplicate That happens with a unique index that is not sparse: both missing values are filed as null.
  • Both are accepted A sparse index leaves documents without the field out, so there is nothing to compare (p. 323).
  • Both are rejected: the field is required An index never makes a field required.
  • Both are accepted, and both get email: null An index does not change the documents.

Key take-aways

What to watch and read