Found by dogfooding on nuwayze: `pipe queries lib` from the repo root
failed with
no resolved package graph at .dart_tool/package_config.json
remedy: run `flutter pub get` or `dart pub get` in .../nuwayze-w3/lib
It failed even with the package resolved, because the path was taken
as the project root: pipe looked for lib/.dart_tool/package_config.json,
which never exists, and the remedy sent the user to run pub get in a
directory that has no pubspec.
enclosingPackage() (project.dart) splits a path into the package that
contains it (the path itself when it holds a pubspec.yaml, else the
nearest ancestor that does) and the directory it names under that
root. `pipe queries` loads the package root and scans only that
directory, so `pipe queries lib/services` is `--dir lib/services` on
the package; an explicit --dir with such a path is relative to it.
Without a resolved graph the error now names the package root.
loadProject's own remedy uses the same lookup, so `pipe check lib`
(and the other commands, which still take the path as the root) now
says the path is inside the package at <root> and suggests passing
that with `--dir lib`, instead of pub get in lib.
Tests: enclosingPackage on a root, a nested directory, a nested
package and a missing path; loadProject's remedy for a directory
inside a package; and `pipe queries` on the fixture's lib/data
(root reported as the package, one file discovered), with
`lib --dir data`, and on an unresolved package where the remedy names
the root, not lib.
On nuwayze-w3: `pipe queries lib` exits 0 with 313 queries over 847
files, identical to `pipe queries .`; `pipe queries lib/services` scans
161 files and every one of its 212 queries sits under lib/services.
233 tests pass.
221 lines
12 KiB
Markdown
221 lines
12 KiB
Markdown
# Firestore query inventory
|
|
|
|
`pipe queries` lists every Firestore query the scanned code can issue, as
|
|
typed, machine-readable records. It is an **inventory only**: no rules, no
|
|
findings, no baseline, and deliberately no index analysis. Deciding whether a
|
|
query needs a composite index, and probing a live project, belong to the
|
|
consumer of this output, which can compare it against `firestore.indexes.json`.
|
|
|
|
The path may be a package root or a directory inside one: `pipe queries lib/data`
|
|
resolves the package that contains `lib/data` (the nearest `pubspec.yaml`) and
|
|
scans only that directory, as `--dir lib/data` would. `--dir` given with such a
|
|
path is relative to it.
|
|
|
|
What it recognises:
|
|
|
|
- **cloud_firestore chains.** Receivers are identified by **static type and
|
|
library**: an `InterfaceType` named `FirebaseFirestore`, `CollectionReference`,
|
|
`Query`, `DocumentReference`, `AggregateQuery`, `Filter` or `FieldPath` whose
|
|
library URI starts with `package:cloud_firestore/` or
|
|
`package:cloud_firestore_platform_interface/`. Nothing is matched by name, so
|
|
`Iterable.where`/`take`, or a project class called `Query`, never is.
|
|
- **One record per terminal call**: `get()` (`realtime: false`), `snapshots()`
|
|
(`realtime: true`), `count()` / `aggregate(...)` (`aggregate: count|sum|average`)
|
|
on a `Query` or `CollectionReference`. `DocumentReference.get/snapshots` are
|
|
point reads that need no index: they are counted in `totals.documentReads`
|
|
and never listed.
|
|
- `collection(path)`, `collectionGroup(id)` and `doc(...)` segments become
|
|
`scope`, `collection` (the last collection id) and a `path` pattern such as
|
|
`stores/*/products`. Document ids are expected to be dynamic and cost
|
|
nothing; a dynamic **collection** id makes the record `partial`, and a fully
|
|
dynamic one also sets `collection: null`.
|
|
- `where(...)` with every named operator (`isEqualTo` `==`, `isNotEqualTo` `!=`,
|
|
`isLessThan` `<`, `isLessThanOrEqualTo` `<=`, `isGreaterThan` `>`,
|
|
`isGreaterThanOrEqualTo` `>=`, `arrayContains` `array-contains`,
|
|
`arrayContainsAny` `array-contains-any`, `whereIn` `in`, `whereNotIn`
|
|
`not-in`, `isNull: true` `==` null, `isNull: false` `!=` null); a field may be
|
|
a string, `FieldPath([...])` (dotted) or `FieldPath.documentId` (`__name__`).
|
|
`where(Filter.or(...))` / `Filter.and(...)` become a `composite` node.
|
|
- `orderBy(field, descending:)`, `limit`, `limitToLast` (`limit.last: true`),
|
|
and any cursor (`startAt`, `startAfter`, `endAt`, `endBefore` and their
|
|
`...Document` forms) as `cursor: true`. `withConverter` passes through.
|
|
- Strings resolve through literals, adjacent and interpolated strings (`*` for
|
|
each unknown part), `+`, `const` and `final` variables with a literal
|
|
initializer, and one-return path helpers (`storePath(id) => 'stores/$id'`)
|
|
with their arguments bound.
|
|
- **Built queries.** A local `Query` variable is the union of its definitions in
|
|
its function body; a clause added under an `if`, ternary, `switch`, loop,
|
|
`catch` or closure is `conditional: true`. Two cuts keep that union honest:
|
|
an assignment textually after the terminal is ignored unless both sit in one
|
|
loop, and an `if` that encloses both the assignment and the terminal does not
|
|
make the clause conditional. Fields are followed through their initializer,
|
|
constructor initializers and assignments in their class.
|
|
- **Helpers.** A getter, method or function in the project that returns a query
|
|
is summarised and inlined at its call sites with the call's arguments bound.
|
|
Methods are inlined **one level deep**; getters and fields take no arguments
|
|
and do not count against that. A query that arrives as a parameter, or from a
|
|
call pipe cannot follow, is `origin: unknown`, `confidence: unknown`, with
|
|
the clauses added locally still recorded.
|
|
- `inLoop: true` when the terminal is inside a `for`/`for-in`/`while`/`do` body
|
|
or a collection `for`, or inside a closure passed to `Iterable.map`/
|
|
`forEach`/`expand`, `Stream.asyncMap`, or within `Future.wait(...)`. A fixed
|
|
`Future.wait([a.get(), b.get()])` is not a loop.
|
|
|
|
## Schema
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"root": "relative path, or the directory name when outside the cwd",
|
|
"coverage": {"discovered": 827, "selected": 827, "resolved": 825, "failed": 0, "skipped": 2, "complete": false},
|
|
"totals": {
|
|
"queries": 372,
|
|
"byConfidence": {"exact": 336, "partial": 28, "unknown": 8},
|
|
"byScope": {"collection": 297, "collectionGroup": 66, "unknown": 9},
|
|
"byOrigin": {"chain": 189, "helper": 47, "unknown": 8, "variable": 128},
|
|
"realtime": 28, "inLoop": 37, "documentReads": 165,
|
|
"collections": {"bookings": 23, "...": 0}
|
|
},
|
|
"unresolvedFirestoreUris": [{"site": "lib/a.dart:3", "uri": "package:cloud_firestore/cloud_firestore.dart"}],
|
|
"notes": ["why the inventory may be incomplete; never empty when queries is"],
|
|
"queries": [{
|
|
"site": "lib/services/category_service.dart:23",
|
|
"owner": "CategoryService", "member": "getCategories",
|
|
"scope": "collection", "collection": "categories", "path": "categories",
|
|
"filters": [{"field": "isActive", "op": "==", "conditional": false, "valueType": "bool"}],
|
|
"orderBy": [{"field": "name", "dir": "asc", "conditional": false}],
|
|
"limit": {"kind": "none", "conditional": false},
|
|
"cursor": false, "realtime": false, "inLoop": false,
|
|
"confidence": "exact", "origin": "chain", "reasons": []
|
|
}]
|
|
}
|
|
```
|
|
|
|
- `filters[].valueType`: `string`, `int`, `double`, `num`, `bool`, `timestamp`
|
|
(`DateTime` or `Timestamp`), `null`, `list`, `map`, `reference`, or
|
|
`unknown`; `inCount` is the length of a literal list operand.
|
|
- `composite` (optional): `{op: or|and, filters: [filter | {composite}]}`.
|
|
- `limit`: `{kind: literal|variable|none, value?, conditional, last?}`.
|
|
- `orderBy[].dir`: `asc`, `desc`, or `unknown` when `descending:` is not a
|
|
constant.
|
|
- `confidence`: `exact`, `partial` (the source is known but something is not:
|
|
every cause is in `reasons`), or `unknown` (the query's source could not be
|
|
followed). `origin`: `chain`, `variable`, `helper`, `unknown`, `adapter`.
|
|
- `queries` is sorted by site. Status lines and notes go to stderr, so stdout
|
|
is exactly the document. `--format md` renders the same data grouped by
|
|
collection, with the coverage line first.
|
|
|
|
**Injection policy.** The analysed repository is untrusted (see
|
|
[agent briefs](usage.md#agent-briefs)). The inventory never quotes source: every
|
|
collection, path and field name must match `^[A-Za-z0-9_.*/\-]+$` or it is
|
|
replaced with `*` and a reason is added; owners and members are identifiers.
|
|
|
|
**Unresolved packages.** If `cloud_firestore` is not in the package graph, or
|
|
an import of it does not resolve, the output says so in `notes` (and
|
|
`unresolvedFirestoreUris`) rather than presenting an empty list as a clean
|
|
result; `--require-firestore` turns that into exit 2.
|
|
|
|
## Adapter config
|
|
|
|
Server-side code often wraps Firestore in a declarative API that takes a query
|
|
as data, so there is no chain to follow. `--adapter-config` (YAML, or JSON,
|
|
which is a subset of YAML) tells pipe what such an API's arguments mean. The
|
|
resulting records use the same schema with `origin: adapter`. For a gateway
|
|
like this:
|
|
|
|
```dart
|
|
abstract class FirestoreGateway {
|
|
Future<List<QueriedDoc>> queryCollection(String collectionPath,
|
|
{List<WhereEquals> filters = const [], WhereArrayContains? arrayContains,
|
|
List<WhereRange> ranges = const [], List<OrderBy> orderBy = const [],
|
|
String? startAfterId, int? limit});
|
|
Future<List<QueriedDoc>> queryCollectionGroup(String collectionId,
|
|
{List<WhereEquals> filters = const [], WhereIn? inFilter,
|
|
WhereArrayContainsAny? arrayContainsAny, List<WhereRange> ranges = const [],
|
|
List<OrderBy> orderBy = const [], int? limit});
|
|
}
|
|
abstract class FirestoreTransaction {
|
|
Future<List<QueriedDoc>> queryCollection(String collectionPath,
|
|
{List<WhereEquals> filters = const [], int? limit});
|
|
}
|
|
// WhereEquals(field, value), WhereIn(field, values), WhereArrayContains(field, value),
|
|
// WhereArrayContainsAny(field, values), WhereRange(field, {gte, gt, lte, lt}),
|
|
// OrderBy(field, {descending = false})
|
|
```
|
|
|
|
the config is:
|
|
|
|
```json
|
|
{
|
|
"adapters": [{
|
|
"interface": ["FirestoreGateway", "FirestoreTransaction"],
|
|
"library": "package:nuwayze_functions_dart/src/data/firestore_gateway.dart",
|
|
"methods": {
|
|
"queryCollection": {"scope": "collection", "path": 0},
|
|
"queryCollectionGroup": {"scope": "collectionGroup", "path": 0}
|
|
},
|
|
"args": {
|
|
"filters": "filters", "arrayContains": "filters", "arrayContainsAny": "filters",
|
|
"inFilter": "filters", "ranges": "filters",
|
|
"orderBy": "orderBy", "limit": "limit", "startAfterId": "cursor"
|
|
},
|
|
"types": {
|
|
"WhereEquals": {"field": 0, "op": "==", "value": 1},
|
|
"WhereIn": {"field": 0, "op": "in", "value": 1},
|
|
"WhereArrayContains": {"field": 0, "op": "array-contains", "value": 1},
|
|
"WhereArrayContainsAny": {"field": 0, "op": "array-contains-any", "value": 1},
|
|
"WhereRange": {"field": 0, "ops": {"gte": ">=", "gt": ">", "lte": "<=", "lt": "<"}},
|
|
"OrderBy": {"kind": "orderBy", "field": 0, "descending": "descending"}
|
|
}
|
|
}]
|
|
}
|
|
```
|
|
|
|
- `interface` (a name or a list) + `library`: a call matches when the invoked
|
|
method's enclosing class is one of these interfaces from that library, or a
|
|
class implementing one. Names alone never match.
|
|
- `methods.<name>`: `scope` (`collection` | `collectionGroup`), `path` (the
|
|
argument holding the path or group id), optional `realtime: true`.
|
|
- `args.<named argument>`: a role - `filters` and `orderBy` (a value object, a
|
|
list literal with `if`/`for`/spread elements, a ternary, or a local list built
|
|
with `add`/`addAll`; elements under an `if` are conditional), `limit`, or
|
|
`cursor` (any non-null value).
|
|
- `types.<class>`: how to read one value object, matched by class name and
|
|
library (the adapter's, or `library:`). `field` and `value` are a positional
|
|
index or a named argument. The operator is either fixed (`op`), one per
|
|
present named bound (`ops`), or read from an argument (`opArg`) and mapped
|
|
through `opMap`, keyed by enum constant name or string value
|
|
(`{"opArg": 1, "opMap": {"lessThan": "<", "greaterThan": ">"}}`).
|
|
`kind: orderBy` with `descending` reads an ordering instead of a filter.
|
|
- Every operator must be one of `==`, `!=`, `<`, `<=`, `>`, `>=`,
|
|
`array-contains`, `array-contains-any`, `in`, `not-in`; a config that names
|
|
anything else, an unknown role, or a bad scope is refused with exit 2.
|
|
|
|
## Limits
|
|
|
|
- Flow-insensitive within a function, apart from the two cuts above: a clause
|
|
on a path that returns early is still in the union (as conditional).
|
|
- Helper methods are inlined one level deep. A query-consuming wrapper - a
|
|
method that takes a `Query` parameter and calls `get()` itself - is reported
|
|
at its own terminal as `unknown`; the clauses its callers pass in are not
|
|
attributed to it.
|
|
- A collection name taken from a loop variable over a constant list, a map
|
|
lookup or a runtime value is `*`, with the record `partial`.
|
|
- No index analysis, by design: the output carries what an index check needs
|
|
(scope, collection group, equality vs. range vs. array operators, order
|
|
directions, `in` sizes) and leaves the decision to the consumer.
|
|
|
|
## Calibration
|
|
|
|
Calibrated on the NuWayze app (Flutter, `lib/`, 827 files) and its Dart backend
|
|
(`functions-dart`, 147 files, with the config above), 2026-09-23:
|
|
|
|
| package | extracted | independent count | exact / partial / unknown |
|
|
| --- | --- | --- | --- |
|
|
| app | 372 query terminals + 165 document reads | 372 `get`/`snapshots`/`count`/`aggregate` calls whose receiver resolves to `Query`/`CollectionReference` (336 + 36) | 336 / 28 / 8 |
|
|
| backend | 136 adapter calls | 136 `queryCollection(`/`queryCollectionGroup(` call sites by grep | 121 / 15 / 0 |
|
|
|
|
Every `partial` is a genuinely dynamic collection or field name; the 8
|
|
`unknown` are query-consuming wrappers (a `Query` parameter, or a wrapper that
|
|
applies caller-supplied builder closures). Zero false positives were found:
|
|
records exist only where the receiver type resolves into cloud_firestore.
|