Chapter 05 · Queries: Filters, Ordering, Limits, Cursors, Collection Groups, and Query Constraints
Create a Query Contract Test Suite Covering Boundaries, Pagination, Empty Results, and Security Constraints
Build a reproducible Firestore query contract test suite for filters, ordering, pagination, empty results, collection groups, Security Rules, and production-only index checks.
Learning outcomes
Individual query examples are easy to make pass. The durable engineering artifact is a query contract suite that proves boundaries: exact IDs, order, ties, missing fields, empty results, cursor transitions, Rules denials, and edition/emulator assumptions. This lesson packages Chapter 05 into a regression harness AtlasMart can run before Rules, schema, or SDK changes ship.
Express every important query as named input constraints plus expected ordered IDs or expected failure.
Cover empty results, boundary values, tied values, missing/null fields, first/last pages, and duplicate-free page traversal.
Use Security Rules unit tests for client authorization while keeping Admin/server fixture setup outside the Rules proof.
Separate local semantic tests from production-only index/Query Explain/billing checks.
Attach query contracts to schema migrations so a new field/default/index/rule cannot silently change existing screens.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart keeps the same local course environment used in
Chapters 01–04: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database for
Core-operation labs, Firestore emulator
127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 (which currently carries
@google-cloud/firestore 9.1.0),
@firebase/rules-unit-testing 5.0.2,
and Node.js 22+. No moving latest tag is required
by the lab.
The generation environment does not run the Firebase emulators, so code and commands are checked against current official Firebase/Google Cloud documentation but the output shown in this lesson is an expected invariant, not fabricated captured output. In particular, the Firestore emulator does not enforce production composite indexes in the same way as the managed service. Operator validation, deterministic result ordering, cursor contracts, and Security Rules tests belong in the mandatory local lab; a missing-composite-index error or Query Explain evidence must be treated as a separately labeled production verification.
1. Treat a query like an API endpoint
A good contract names the user journey and the invariants it
promises. For example, catalog.cameraByPrice.v1 may
promise: only published cameras, ascending price then document
ID, page size 2, missing price excluded, client
read allowed only for public catalog documents, and a known
collection-scope composite index in production. The test should
fail if any of those assumptions changes accidentally.
| Contract dimension | Example assertion |
|---|---|
| Membership | Exact IDs or exact empty set |
| Ordering | Exact ordered ID list, not sorted after retrieval |
| Boundary |
startAfter does not repeat the previous
anchor
|
| Tie handling | All equal-price docs appear exactly once across pages |
| Field presence | Missing ordered field is intentionally excluded |
| Security | Owner query allowed; broad/other-owner query denied |
| Index | Expected scope/definition recorded; production check separate |
| Version | SDK/CLI/rules-test versions logged with test run |
2. Build fixtures for failure, not only the happy path
The Chapter 05 seed intentionally contains three price ties, a
missing rank, a null rank, several tag
combinations, and orders under different owners/tenants. Keep
those awkward values. A fixture with every field populated and
every sort key unique can never prove the cases most likely to
break real pagination.
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";import { initializeApp, applicationDefault } from "firebase-admin/app";import { getFirestore, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();const catalog = { "p-1001": { category:"camera", price:99, rating:4.8, tags:["outdoor","camera"], published:true, rank:10, sellerId:"seller-a" }, "p-1002": { category:"camera", price:99, rating:4.5, tags:["studio","camera"], published:true, rank:20, sellerId:"seller-a" }, "p-1003": { category:"sensor", price:49, rating:4.8, tags:["outdoor","iot"], published:true, sellerId:"seller-b" }, "p-1004": { category:"sensor", price:149, rating:4.1, tags:["industrial","iot"], published:false, rank:null, sellerId:"seller-b" }, "p-1005": { category:"camera", price:199, rating:4.9, tags:["outdoor","camera"], published:true, rank:30, sellerId:"seller-c" }, "p-1006": { category:"gateway", price:99, rating:4.2, tags:["iot"], published:true, rank:40, sellerId:"seller-c" }};for (const [id, data] of Object.entries(catalog)) { await db.collection("catalogItems").doc(id).set({ ...data, updatedAt: Timestamp.fromMillis(1760000000000) });}const orders = [ ["u-alice","o-1001",{ ownerUid:"u-alice", tenantId:"seller-a", status:"paid", total:198, createdAt:Timestamp.fromMillis(1760000100000) }], ["u-alice","o-1002",{ ownerUid:"u-alice", tenantId:"seller-b", status:"shipped", total:49, createdAt:Timestamp.fromMillis(1760000200000) }], ["u-bob", "o-1003",{ ownerUid:"u-bob", tenantId:"seller-a", status:"paid", total:99, createdAt:Timestamp.fromMillis(1760000300000) }]];for (const [uid,id,data] of orders) await db.doc(`users/${uid}/orders/${id}`).set(data);console.log("seeded", Object.keys(catalog).length, "catalog items and", orders.length, "orders");
3. Query tests and Rules tests prove different things
Use the Admin SDK to reset deterministic state because it is a
trusted server path and bypasses Security Rules. Then use the
Web SDK or @firebase/rules-unit-testing client
contexts to prove authorization. Never interpret “Admin query
succeeded” as evidence that a browser client may run it.
import assert from "node:assert/strict";import { initializeApp } from "firebase/app";import { collection, connectFirestoreEmulator, documentId, getDocs, getFirestore, limit, orderBy, query, startAfter, where } from "firebase/firestore";const app=initializeApp({projectId:"demo-atlasmart-firestore",apiKey:"demo",appId:"demo"});const db=getFirestore(app); connectFirestoreEmulator(db,"127.0.0.1",8080);const items=collection(db,"catalogItems");const ids=async q=>(await getDocs(q)).docs.map(d=>d.id);assert.deepEqual(await ids(query(items,where("category","==","camera"),orderBy("price"))),["p-1001","p-1002","p-1005"]);assert.deepEqual(await ids(query(items,where("category","==","missing"))),[]);const first=await getDocs(query(items,orderBy("price"),orderBy(documentId()),limit(3)));const second=await getDocs(query(items,orderBy("price"),orderBy(documentId()),startAfter(first.docs.at(-1)),limit(3)));const traversed=[...first.docs,...second.docs].map(d=>d.id);assert.equal(new Set(traversed).size,traversed.length);assert.equal(traversed.length,6);console.log("semantic contracts passed");
import { initializeTestEnvironment, assertFails, assertSucceeds } from "@firebase/rules-unit-testing";import { collectionGroup, getDocs, query, where } from "firebase/firestore";const env=await initializeTestEnvironment({ projectId:"demo-atlasmart-firestore", firestore:{ rules: await (await import("node:fs/promises")).readFile("firestore.rules","utf8"), host:"127.0.0.1", port:8080 }});const alice=env.authenticatedContext("u-alice").firestore();await assertSucceeds(getDocs(query(collectionGroup(alice,"orders"),where("ownerUid","==","u-alice"))));await assertFails(getDocs(collectionGroup(alice,"orders")));await assertFails(getDocs(query(collectionGroup(alice,"orders"),where("ownerUid","==","u-bob"))));await env.cleanup();
4. Add a contract manifest beside the code
{ "catalog.cameraByPrice.v1": { "edition": "Standard", "mode": "Native/Core", "collection": "catalogItems", "filters": ["category == camera"], "order": ["price ASC", "__name__ ASC"], "pageSize": 2, "rules": "public catalog only", "productionIndex": "category ASC + price ASC (verify managed readiness)", "emulatorCovers": ["membership", "ordering", "cursor", "rules"], "productionOnly": ["composite-index enforcement", "Query Explain", "billing/latency"] }}
This file is not a replacement for executable tests. It is a review surface: product, backend, security, and data engineers can see what a schema/rules/index change is expected to preserve.
5. Deliberately break the contract
Regression tests become credible when you see them catch a real regression. Try each change independently and revert it after the expected failure:
-
remove
rankfrom a document and show the ordered-query expectation changes; -
replace the two-field pagination cursor with
startAfter(price)and make a tie disappear; - broaden the order collection-group query and confirm Rules deny it;
-
change one fixture from scalar
categoryto an array and show equality no longer matches; - change the model contract version without updating the migration/fixture and make the test fail loudly.
Record the failure as expected evidence, not as a screenshot-only anecdote. The CI signal should state which named query contract broke.
6. Local CI command and production verification boundary
mkdir atlasmart-firestore-query-labcd atlasmart-firestore-query-labnpm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2node --versionnpx firebase --versionnpx firebase emulators:start --only firestore,auth --project demo-atlasmart-firestore
{ "scripts": { "seed:queries": "node seed-query-fixtures.mjs", "test:queries": "node query-contract.test.mjs && node rules-contract.test.mjs", "test:queries:emulated": "firebase emulators:exec --only firestore,auth --project demo-atlasmart-firestore 'npm run seed:queries && npm run test:queries'" }}
The mandatory gate is local and no-cost. A separate optional managed-project checklist can verify required indexes have reached a ready state, run Query Explain for selected queries where supported, and record actual production latency/read/index-entry evidence. Never make that optional cloud check the only way a learner can complete the chapter.
Cleanup and handoff
Stop the emulators, remove only the disposable
atlasmart-firestore-query-lab directory if you no
longer need it, and keep the contract files if they will seed
Chapter 06 index work. Do not run recursive deletion against an
unrelated Firebase project. Chapter 06 will start from these
named queries and inspect exactly which
automatic/manual/collection-group/vector indexes support them
and what those indexes cost.
Production judgment
Query tests should fail before users discover a broken screen. The highest-value cases are not dozens of trivial equality examples; they are the boundaries where schema, Rules, ordering, indexes, and pagination interact. Keep the suite small enough to understand and strict enough to defend the user-visible contract.
Knowledge check
- Why use Admin SDK for fixture setup but client contexts for Rules tests?
- What should a query contract assert besides “no exception”?
- Why keep a production-only verification layer?
- What makes a good regression injection?
- How does Chapter 05 hand off to Chapter 06?
Review the answers
1. Admin is a trusted path that bypasses Rules; client contexts are needed to prove the browser/mobile authorization contract.
2. Exact membership/order or expected failure, cursor boundaries, missing/null cases, Rules behavior, and the environment/version assumptions.
3. The emulator cannot prove managed composite-index readiness, production Query Explain metrics, real latency, or billing.
4. A small reversible change that breaks one named invariant—such as a tie cursor, field presence, or Rules constraint—and is caught by CI.
5. The named query contracts identify exactly which index definitions/scopes need to be audited rather than creating indexes speculatively.
Summary and next step
Chapter 05 finishes with executable query contracts: filters, ordering, cursors, collection groups, Rules, and emulator boundaries are explicit. Chapter 06 will open the index layer behind these queries—automatic/manual indexes, collection-group scope, exemptions, vector indexes, fan-out, and cost.
Authoritative references
- Perform simple and compound queries in Cloud Firestore — Current Core filter/operator behavior and Standard-edition limitations.
- Order and limit data with Cloud Firestore — Ordering, limits, field-existence effect, and limit semantics.
- Paginate data with query cursors — Cursor boundaries and document-snapshot pagination.
- Index types in Cloud Firestore — Index scopes, implicit document-name ordering, and query/index execution model.
- Securely query data — Why Security Rules are not filters and collection-group rule requirements.
- Firebase release notes — Current SDK/tooling versions used for the pinned lab baseline.
- Test Security Rules — Rules unit testing with the local emulator.