Chapter 05 · Queries: Filters, Ordering, Limits, Cursors, Collection Groups, and Query Constraints
Equality, Inequality, in / not-in, array-contains / array-contains-any, and Compound Query Semantics
Predict Firestore Core filter semantics across equality, inequality, membership, arrays, compound queries, missing fields, Rules, and Standard-edition constraints.
Learning outcomes
AtlasMart's catalog screen has filters for category, price, publication state, and tags. A relational habit says “write whatever predicate the UI asks for.” Firestore Core queries are different: the operator combination, indexed fields, missing/null values, Rules, and edition all form one executable contract. The goal is to be able to predict the candidate result set before running the SDK call.
Explain equality, inequality,
in/not-in,
array-contains/array-contains-any,
and compound filter semantics without treating them as
interchangeable syntax.
Predict how missing fields, null, arrays,
duplicate matches, and Standard-edition disjunction limits
alter the result set.
Separate query validity from index availability and authorization; a syntactically valid query can still require an index or be denied by Rules.
Build a deterministic query matrix over AtlasMart fixtures and assert exact document IDs rather than eyeballing console output.
Recognize when an unsupported access pattern should be remodeled or precomputed instead of forced through client-side filtering.
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. A query is a result-set contract, not an arbitrary predicate
For AtlasMart,
where("category", "==", "camera") asks Firestore
for documents whose indexed category value equals
the string camera. The operator is type-sensitive:
an array containing camera is not equal to the
scalar string camera. Conversely,
array-contains only matches array fields. Query
design therefore begins with the stored representation from
Chapter 02 and the access-pattern decisions from Chapter 03.
| Operator | Mental model | Important boundary |
|---|---|---|
== |
Field equals one value | Missing field cannot match; type/value must satisfy Firestore comparison semantics. |
<, <=, >, >=, != |
Range or exclusion predicate |
Inequality affects ordering/index planning;
!= excludes missing fields.
|
in |
OR of equality comparisons on one field | Standard: up to 30 comparison values/disjunction budget still applies. |
not-in |
AND of not-equal comparisons on one field | Standard: up to 10 values; missing and null behavior is intentionally restrictive. |
array-contains |
Array contains one member | At most one per disjunction in Standard. |
array-contains-any |
OR across array membership values | Standard: up to 30 values; one matching document appears once even if several values match. |
A field set to null exists. A field that is
absent does not. not-in excludes documents where
the target field does not exist, and it does not behave like
“everything except these values” over a schemaless universe.
Treat field presence as part of the query schema.
2. Compound filters consume both semantic and structural budgets
Multiple where() clauses usually mean logical AND.
Firestore also supports logical OR through explicit composite
filters and shorthand operators such as in and
array-contains-any. In Standard edition, Firestore
converts OR-like expressions to disjunctive normal form (DNF)
and caps the result at 30 disjunctions. The multiplication can
be surprising: two independent in lists can create
more disjunctions than either list alone suggests.
Standard also forbids several combinations, including mixing
not-in with in,
array-contains-any, or or, and allows
only one not-in or != per query. Do
not memorize these as timeless folklore: they are
edition-specific product constraints and must be rechecked when
the course is refreshed.
import { collection, query, where, getDocs } from "firebase/firestore";const items = collection(db, "catalogItems");const contracts = { cameras: query(items, where("category", "==", "camera")), priceAtLeast99: query(items, where("price", ">=", 99)), selectedCategories: query(items, where("category", "in", ["camera", "gateway"])), notSellerB: query(items, where("sellerId", "not-in", ["seller-b"])), outdoor: query(items, where("tags", "array-contains", "outdoor")), cameraOrIot: query(items, where("tags", "array-contains-any", ["camera", "iot"]))};for (const [name, q] of Object.entries(contracts)) { const snap = await getDocs(q); console.log(name, snap.docs.map(d => d.id));}
With the deterministic fixture, the important evidence is the exact set of IDs. Ordering is a separate contract covered in Lesson 2; do not accidentally turn whatever iteration order you happened to observe into an application guarantee.
3. Arrays: membership is not array equality
array-contains-any asks whether an array field
contains at least one candidate member. An in query
can also use arrays as comparison values, but then it compares
the whole array value—including length, order, and values. These
are different access patterns. A document whose
tags are ["outdoor","camera"] can
match array-contains("outdoor"); it does not equal
the scalar "outdoor".
If one document matches more than one value in an
array-contains-any query, that document appears
once in the result set. Do not multiply-match it in an
application-side billing or ranking estimate.
4. Deliberately wrong approach: overbroad read, then client-side filter
Suppose the UI wants “published camera products owned by
seller-a.” A tempting implementation reads all
catalogItems, filters in JavaScript, and assumes
Security Rules will prevent unauthorized rows from leaking.
Rules are not filters. A client query must be authorized for its
potential result set; Firestore does not fetch an overbroad
result then remove documents that fail Rules.
The repair is to encode the access pattern in the query and data model: query only fields the Rules and indexes can prove, or create a precomputed collection/materialized view if the desired predicate is unsupported or too expensive. If several bounded authorized queries are required, merge their already-authorized results deliberately and account for the extra reads.
5. Hands-on lab: assert the filter contract
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
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");
import assert from "node:assert/strict";import { initializeApp } from "firebase/app";import { connectFirestoreEmulator, collection, getDocs, getFirestore, query, 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");async function ids(q){ return (await getDocs(q)).docs.map(d=>d.id).sort(); }assert.deepEqual(await ids(query(items, where("category","==","camera"))), ["p-1001","p-1002","p-1005"]);assert.deepEqual(await ids(query(items, where("tags","array-contains","outdoor"))), ["p-1001","p-1003","p-1005"]);assert.deepEqual(await ids(query(items, where("category","in",["gateway","sensor"]))), ["p-1003","p-1004","p-1006"]);console.log("filter contracts passed");
Extend the file with one intentionally invalid Standard-edition
operator combination and assert that the SDK/backend rejects it.
Record the error code and SDK version rather than depending on
human-readable wording. Then add a document whose target field
is missing and another whose field is null; prove
exactly which operators include or exclude each.
The emulator can validate many query/operator contracts, but do not use it to prove production composite-index enforcement or billing. Save production index creation/error verification for the explicit optional check in Lesson 4.
Production judgment
A query shape is acceptable only if its semantics, index support, authorization proof, result ordering, expected read scope, and future schema behavior are all explicit. If AtlasMart needs arbitrary full-text, analytical predicates, joins, or unconstrained ad-hoc exploration, that is a signal to add a search/analytics system or evaluate Enterprise Pipeline operations—not to hide an overbroad Firestore read behind client code.
Knowledge check
-
Why is
not-innot equivalent to “all documents except these values”? -
How does
array-contains-anytreat a document that matches two requested array members? -
Why can two small
inlists still exceed a Standard query limit? - Why is client-side filtering not an authorization strategy?
- What evidence should a filter contract test assert?
Review the answers
1. Because missing fields do not match, null has special behavior, and Standard imposes operator-combination/value-count constraints. The stored field-presence contract matters.
2. It returns the document once; matches are de-duplicated at the document-result level.
3. OR-like expressions are converted to DNF; independent choices multiply the number of disjunctions.
4. Security Rules are not post-query row filters. The query itself must be provably authorized for its possible result set.
5. Exact document IDs plus expected rejection cases and missing/null boundary behavior, with SDK/edition/emulator state recorded.
Summary and next step
Filters define the candidate set, but production screens also need deterministic order and pagination. Lesson 2 turns the same fixtures into a stable ordering/cursor contract, including tie values and missing fields.
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.