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.

Intermediate120–140 minutesExecutable query contract suiteFirebase JS 12.19.0 · CLI 15.30.0Last reviewed: September 2026

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.

01

Express every important query as named input constraints plus expected ordered IDs or expected failure.

02

Cover empty results, boundary values, tied values, missing/null fields, first/last pages, and duplicate-free page traversal.

03

Use Security Rules unit tests for client authorization while keeping Admin/server fixture setup outside the Rules proof.

04

Separate local semantic tests from production-only index/Query Explain/billing checks.

05

Attach query contracts to schema migrations so a new field/default/index/rule cannot silently change existing screens.

Execution and safety note

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.

Chapter 05 reproducibility baseline · reviewed 15 September 2026

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.

What was and was not executed while authoring

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.

seed-query-fixtures.mjs · deterministic AtlasMart query data
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.

query-contract.test.mjs · compact semantic suite
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");
rules-contract.test.mjs · owner query must be provable
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

query-contracts.json · reviewable query surface
{  "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 rank from 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 category to 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

shell · pinned local dependencies
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
package.json scripts · deterministic local gate
{  "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

  1. Why use Admin SDK for fixture setup but client contexts for Rules tests?
  2. What should a query contract assert besides “no exception”?
  3. Why keep a production-only verification layer?
  4. What makes a good regression injection?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.