Data Modeling in Firestore
Food Tracker (React + Firebase)
Chapter 2 · Data Modeling in Firestore
Chapter 1 named Firestore as this course's own database, in contrast to the SQL every sibling course uses. This chapter designs the Pantry Item around it properly — and the honest answer up front is that Firestore's document model doesn't just store the same data differently, it changes what "the schema" even means.
The Relational Shape (What the Other Three Courses Use)
Food Tracker (FastAPI), Food Tracker (Django), and Food Tracker (React + Express) all model a Pantry Item as one row in a single SQL table — id, name, barcode, category, a nullable expiry_date, a status column, added_at, and a nullable used_at. The database enforces column types and nullability up front, at write time — a row that violates the schema is rejected before it's ever stored.
Firestore's Document Model
Firestore stores data as collections of documents — each document a JSON-like object whose fields can be strings, numbers, booleans, timestamps, arrays, or nested maps. Critically, Firestore itself enforces no schema at all: nothing stops one document in a collection from having fields another document in the same collection lacks entirely. This is schema-on-read rather than SQL's schema-on-write — the database doesn't validate shape at all; whatever validates it is the application code, or nothing.
For this app: one top-level items collection, one document per Pantry Item, with fields name, barcode, category, expiryDate, status, addedAt, and usedAt.
A Real Firestore Document
Notice the used item doesn't set expiryDate to null — it omits the field entirely. This is a deliberate modeling choice, not an oversight: a Firestore range query (where("expiryDate", "<", someDate)) only ever matches documents where that field actually exists and is a comparable type. A document missing the field is automatically excluded from the results — quietly and correctly — which is exactly the behavior the expiry-alerts query in Chapter 7 depends on. SQL's NULL behaves differently in comparisons (a `NULL` value in a `WHERE expiry_date < X` clause is neither true nor false, and is excluded for a different underlying reason) — the end result looks similar here, but it's worth knowing the two databases arrive at it through genuinely different mechanics.
Document IDs: Not the Barcode
A tempting shortcut is using the barcode itself as the document ID, since it's already a natural unique identifier for the product. Resist it: the same product (the same barcode) can be bought — and tracked — more than once, each purchase with its own expiry date and its own lifecycle. The document ID needs to identify one purchased instance, not one product. Let Firestore auto-generate the document ID, and store the barcode as an ordinary field instead, exactly as shown above.
Use Firestore's Timestamp Type, Not Date Strings
Firestore has a native Timestamp type specifically for dates — use it for expiryDate, addedAt, and usedAt, rather than storing an ISO date string. Range queries, sorting, and Chapter 7's own "expiring within N days" logic all rely on genuine timestamp comparison, not string comparison that happens to work for well-formatted ISO strings but isn't a real date comparison at all.
Firestore vs. SQL, Side by Side
| SQL (the other three courses) | Firestore (this course) | What's genuinely different |
|---|---|---|
| Table + column schema | Collection of documents, no enforced schema | Schema-on-write vs. schema-on-read |
| Primary key (often auto-increment) | Auto-generated document ID | Similar in spirit; Firestore IDs are opaque strings, not sequential integers |
Nullable column (NULL) | Field simply absent from the document | "No value" is modeled as absence, not a special null marker |
WHERE expiry_date < X | where("expiryDate", "<", X) | Documents missing the field are excluded automatically, same practical outcome via a different mechanism |
Querying Firestore: A First Look
expiryDate in one part of the app and expiry_date in another doesn't raise an error — Firestore happily stores both as two separate fields on the same document, and neither Chapter 7's alerts query nor anything else will notice until items mysteriously stop showing up as expiring. This class of bug has no SQL equivalent, since a misspelled column name there fails immediately and loudly. The shared-interface habit from the tip box above is the real defense against it.
Where This Course Is Headed
Barcode lookup via a Cloud Function, the camera-scanning React component, direct client writes to Firestore, Security Rules as the real gatekeeper, expiry alerts built on exactly the query shown above, item history and its search limitations, marking items used, recipe lookup, Firebase Authentication, deployment, and a capstone tying every chapter together.
Hands-On Exercises
Explain the difference between a field being "absent" in Firestore and a column being NULL in SQL. Why does a used item's document simply omit expiryDate rather than setting it to null, and how does that choice affect Chapter 7's own expiry-alerts query?
📄 View solutionExplain why this chapter argues against using the barcode itself as a Firestore document ID, and what should be used instead.
📄 View solutionThis chapter says Firestore's schema-on-read flexibility is a genuine trade-off, not a free win. What cost does it push onto the application that SQL's schema-on-write avoids, and how does the tip box's shared-interface suggestion partially address it?
📄 View solutionChapter 2 Quick Reference
- Collection/document model — one
itemscollection, one document per Pantry Item, no enforced schema - Field absence, not NULL — a used item simply omits
expiryDate; range queries exclude documents missing the field automatically - Document ID — auto-generated, never the barcode (which identifies a product, not a purchased instance)
- Firestore
Timestamp— always for dates, never a plain string - This chapter's own throughline: schema-on-read trades database-enforced consistency for flexibility — a cost the app's own code has to pick up instead
- Next chapter: Barcode Lookup via a Cloud Function