feat(queries): land the query inventory that missed main #6

Merged
trevon merged 2 commits from trevon/land-query-inventory into main 2026-09-24 02:45:00 +00:00 AGit
20 changed files with 4470 additions and 4 deletions

2
.gitignore vendored
View file

@ -9,6 +9,8 @@ builddir*
# them. Re-include it after the blanket .dart_tool/ rule above.
!test/fixtures/stuck_loading/.dart_tool/
!test/fixtures/stuck_loading/.dart_tool/package_config.json
!test/fixtures/firestore_queries/.dart_tool/
!test/fixtures/firestore_queries/.dart_tool/package_config.json
# Build outputs
*.exe

230
README.md
View file

@ -76,6 +76,9 @@ pipe verify <path> --baseline .pipe-baseline.json --expect-resolved
# What to fix first, per file (ranked, with the scoring explained)
pipe suggest <path> --format agent --budget 3000
# Every Firestore query the code can issue, as typed records (inventory only)
pipe queries <path> --format json [--adapter-config adapters.json]
```
Common flags: `--format text|json` (`agent|md|json` for `guide`,
@ -86,7 +89,10 @@ default `lib`; a directory that does not exist is an error, not skipped),
`*.mocks.dart` are scanned unless you pass `--no-include-generated`),
`--limit N`, `--rule <name>` (repeatable), `--top N`.
Command-specific flags: `index --fail-on-error` exits 1 when the resolved project
Command-specific flags: `queries --format json|md` (default `json`),
`--adapter-config <file>` (see [Firestore query inventory](#firestore-query-inventory))
and `--require-firestore` (exit 2 when cloud_firestore types do not resolve);
`index --fail-on-error` exits 1 when the resolved project
has analyzer errors (the default reports the count and exits 0);
`suggest --budget N --max-actions N --top N` bound the agent plan and the text
report; `guide --budget N --max-findings N` bound the agent brief, and `--yams`
@ -416,6 +422,221 @@ limits are inherent and stated in the brief rather than hidden:
Deletion-class findings need neither, which is why they are the verified
actionable class.
## 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`.
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 guidance](#agent-guidance)). 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.
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.
## Architecture
```text
@ -431,6 +652,9 @@ lib/src/index.dart resolution health, timing, diagnostics
lib/src/model.dart declaration model
lib/src/state_flow.dart the state-flow IR (unions, emissions, render sites,
events, flags, Riverpod)
lib/src/firestore_facts.dart Firestore query inventory (typed records, no
rules)
lib/src/gateway_facts.dart adapters for declarative query APIs
lib/src/excerpt.dart bounded, literal-free source excerpts
lib/src/rules.dart breakage detectors, findings, baselines
lib/src/provider_rules.dart provider / ChangeNotifier rules
@ -491,7 +715,9 @@ is byte-identical across runs.
dart test
```
Tests cover resolution/IR extraction, rule contracts (positive *and*
Tests cover resolution/IR extraction, the Firestore query inventory (against
a fixture with a hand-written cloud_firestore skeleton and a gateway adapter),
rule contracts (positive *and*
negative controls per rule), Riverpod and provider modelling, agent-brief
rendering, fix-order scoring, baseline round-tripping (including legacy
fingerprints-only baselines), verification, and recall evaluation, against a

View file

@ -3,6 +3,20 @@ library;
export 'src/analysis.dart' show Analyzer, FileOutcome, ScanCoverage;
export 'src/errors.dart' show PipeException;
export 'src/firestore_facts.dart'
show
FirestoreModel,
QueryComposite,
QueryFilter,
QueryLimit,
QueryOrder,
QueryRecord,
UnresolvedFirestoreUri,
buildFirestoreModel,
queryOperators,
safeNamePattern;
export 'src/gateway_facts.dart'
show AdapterMethod, AdapterType, ArgRef, QueryAdapter, parseAdapterConfig;
export 'src/guide.dart'
show
Guidance,

View file

@ -5,7 +5,10 @@ import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'analysis.dart';
import 'errors.dart';
import 'firestore_facts.dart';
import 'gateway_facts.dart';
import 'guide.dart';
import 'index.dart';
import 'model.dart';
@ -20,6 +23,7 @@ import 'verify.dart';
const List<String> _formats = ['text', 'json'];
const List<String> _guideFormats = ['agent', 'md', 'json'];
const List<String> _planFormats = ['text', 'agent', 'json'];
const List<String> _queryFormats = ['json', 'md'];
void _addCommonOptions(
ArgParser parser, {
@ -232,6 +236,7 @@ class PipeRunner extends CommandRunner<int> {
addCommand(RecallCommand());
addCommand(VerifyCommand());
addCommand(SuggestCommand());
addCommand(QueriesCommand());
}
}
@ -846,3 +851,205 @@ class GuideCommand extends Command<int> {
return 0;
}
}
/// Inventory every Firestore query the project can issue.
class QueriesCommand extends Command<int> {
QueriesCommand() {
_addCommonOptions(argParser, formats: _queryFormats, defaultFormat: 'json');
argParser
..addOption(
'adapter-config',
help:
'YAML or JSON file describing declarative query APIs '
'(see README: "Adapter config").',
)
..addFlag(
'require-firestore',
defaultsTo: false,
help:
'Exit 2 when cloud_firestore types do not resolve, instead of '
'reporting an inventory that could not see them.',
);
}
@override
String get name => 'queries';
@override
String get description =>
'Inventory Firestore query surfaces (collections, filters, orderings, '
'limits) as typed, machine-readable records.';
@override
Future<int> run() async {
final results = argResults!;
final configPath = results['adapter-config'] as String?;
final adapters = configPath == null
? const <QueryAdapter>[]
: parseAdapterConfig(_readText(configPath, 'adapter config'));
final project = await loadProject(
_target(results),
directories: results['dir'] as List<String>,
);
final run = await resolveProject(
project,
sdkOverride: results['sdk'] as String?,
concurrency: _readConcurrency(results),
includeGenerated: results['include-generated'] as bool,
limit: _readLimit(results),
);
try {
final model = buildFirestoreModel(
project,
run.outcomes,
adapters: adapters,
);
final coverage = ScanCoverage.of(
discovered: project.dartFiles.length,
outcomes: run.outcomes,
);
final root = queriesRoot(project.root);
if (results['format'] as String == 'md') {
stdout.write(queriesMarkdown(root, coverage, model));
} else {
stdout.writeln(_encode(queriesJson(root, coverage, model)));
}
for (final note in model.notes) {
stderr.writeln('pipe: $note');
}
if ((results['require-firestore'] as bool) && !model.firestoreResolved) {
stderr.writeln(
'pipe: --require-firestore: cloud_firestore types did not resolve',
);
return 2;
}
} finally {
await run.dispose();
}
return 0;
}
}
/// The project root as reported: relative to the working directory when it
/// sits under it, else only its directory name, so the inventory never
/// carries one machine's absolute path.
String queriesRoot(String root, {String? cwd}) {
final relative = p.relative(root, from: cwd ?? Directory.current.path);
if (relative == '.') return '.';
if (p.isAbsolute(relative) || relative.split(p.separator).first == '..') {
return p.basename(root);
}
return relative;
}
/// Version of the `pipe queries` JSON schema.
const int queriesSchemaVersion = 1;
Map<String, Object?> queriesJson(
String root,
ScanCoverage coverage,
FirestoreModel model,
) => {
'version': queriesSchemaVersion,
'root': root,
'coverage': coverage.toJson(),
'totals': model.totals(),
'unresolvedFirestoreUris': [
for (final uri in model.unresolvedUris) uri.toJson(),
],
'notes': model.notes,
'queries': [for (final query in model.queries) query.toJson()],
};
/// The inventory as markdown, grouped by collection.
String queriesMarkdown(
String root,
ScanCoverage coverage,
FirestoreModel model,
) {
final totals = model.totals();
final byConfidence = totals['byConfidence'] as Map<String, int>;
final buffer = StringBuffer()
..writeln('# Firestore queries — $root')
..writeln()
..writeln(coverage.describe())
..writeln()
..writeln(
'${model.queries.length} quer${model.queries.length == 1 ? 'y' : 'ies'}: '
'${byConfidence['exact']} exact, ${byConfidence['partial']} partial, '
'${byConfidence['unknown']} unknown; ${totals['realtime']} realtime, '
'${totals['inLoop']} in a loop.',
);
final notes = model.notes;
if (notes.isNotEmpty) {
buffer.writeln();
for (final note in notes) {
buffer.writeln('> $note');
}
}
final groups = <String, List<QueryRecord>>{};
for (final query in model.queries) {
groups.putIfAbsent(query.collection ?? '(unresolved)', () => []).add(query);
}
final names = groups.keys.toList()
..sort((a, b) {
// Unresolved last; the rest alphabetically.
if (a == '(unresolved)') return 1;
if (b == '(unresolved)') return -1;
return a.compareTo(b);
});
for (final name in names) {
final queries = groups[name]!;
buffer
..writeln()
..writeln('## `$name` (${queries.length})')
..writeln();
for (final query in queries) {
buffer.writeln('- ${_describeQuery(query)}');
}
}
return buffer.toString();
}
String _describeQuery(QueryRecord query) {
String conditional(bool value) => value ? '?' : '';
final parts = <String>[
'`${query.site}`',
'${query.scope ?? 'unknown'} `${query.pathPattern ?? '*'}`',
];
final clauses = <String>[
for (final filter in query.filters)
'${filter.field} ${filter.op} ${filter.valueType}'
'${filter.inCount == null ? '' : '[${filter.inCount}]'}'
'${conditional(filter.conditional)}',
if (query.composite != null) _describeComposite(query.composite!),
for (final order in query.orderBy)
'orderBy ${order.field} ${order.dir}${conditional(order.conditional)}',
if (query.limit.kind != 'none')
'${query.limit.last ? 'limitToLast' : 'limit'} '
'${query.limit.value ?? 'n'}${conditional(query.limit.conditional)}',
if (query.cursor) 'cursor',
];
if (clauses.isNotEmpty) parts.add(clauses.join(', '));
final flags = [
if (query.realtime) 'realtime',
if (query.aggregate != null) query.aggregate!,
if (query.inLoop) 'in loop',
query.origin,
query.confidence,
];
var line = '${parts.join(' — ')} [${flags.join(', ')}]';
if (query.reasons.isNotEmpty) line += ' — ${query.reasons.join('; ')}';
return line;
}
String _describeComposite(QueryComposite composite) {
final children = [
for (final child in composite.filters)
child is QueryFilter
? '${child.field} ${child.op} ${child.valueType}'
: _describeComposite(child as QueryComposite),
];
return '${composite.op}(${children.join(' | ')})';
}

2032
lib/src/firestore_facts.dart Normal file

File diff suppressed because it is too large Load diff

605
lib/src/gateway_facts.dart Normal file
View file

@ -0,0 +1,605 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:yaml/yaml.dart';
import 'errors.dart';
import 'firestore_facts.dart';
/// Where an adapter reads a value: a positional index or a named argument.
class ArgRef {
const ArgRef.positional(int this.index) : name = null;
const ArgRef.named(String this.name) : index = null;
final int? index;
final String? name;
Expression? of(ArgumentList arguments) {
if (name != null) {
for (final argument in arguments.arguments) {
if (argument is NamedArgument && argument.name.lexeme == name) {
return argument.argumentExpression;
}
}
return null;
}
final positional = arguments.arguments.whereType<Expression>().toList();
return index! < positional.length ? positional[index!] : null;
}
static ArgRef parse(Object? value, String where) {
if (value is int && value >= 0) return ArgRef.positional(value);
if (value is String && value.isNotEmpty) return ArgRef.named(value);
throw PipeException(
'adapter config: $where must be a positional index or an argument name',
);
}
}
/// A query method of an adapted API.
class AdapterMethod {
const AdapterMethod({
required this.scope,
required this.path,
this.realtime = false,
});
/// `collection` or `collectionGroup`.
final String scope;
/// The argument holding the collection path (or group id).
final ArgRef path;
final bool realtime;
}
/// How to read one value type (a filter or an ordering) of an adapted API.
class AdapterType {
const AdapterType({
required this.kind,
required this.field,
this.op,
this.value,
this.ops = const {},
this.opArg,
this.opMap = const {},
this.descending,
this.library,
});
/// `filter` or `orderBy`.
final String kind;
final ArgRef field;
/// A fixed operator.
final String? op;
/// The compared value, for its type and a literal list's length.
final ArgRef? value;
/// Named bounds, each an operator in its own right
/// (`WhereRange(field, gte: a, lt: b)`).
final Map<String, String> ops;
/// An argument carrying the operator (an enum constant or a string), and
/// what each of its values means.
final ArgRef? opArg;
final Map<String, String> opMap;
/// For `orderBy`: the argument holding the direction flag.
final ArgRef? descending;
/// Library declaring the type, when it is not the adapter's library.
final String? library;
}
/// A declarative query API, described by `--adapter-config`.
///
/// Some code never chains `where(...)` calls: a server-side gateway takes the
/// whole query as data (`queryCollection('users', filters: [...])`). pipe
/// cannot know what those arguments mean, so an adapter says so, and the
/// resulting records use the same schema as cloud_firestore queries with
/// origin `adapter`.
class QueryAdapter {
const QueryAdapter({
required this.interfaces,
required this.library,
required this.methods,
required this.args,
required this.types,
});
/// Class names whose methods are adapted; implementations match too.
final List<String> interfaces;
/// `package:` URI of the library declaring [interfaces].
final String library;
final Map<String, AdapterMethod> methods;
/// Named argument -> role: `filters`, `orderBy`, `limit` or `cursor`.
final Map<String, String> args;
/// Constructor class name -> how to read it.
final Map<String, AdapterType> types;
}
const Set<String> _roles = {'filters', 'orderBy', 'limit', 'cursor'};
/// Parse an adapter config (YAML, or JSON, which is a subset of it).
List<QueryAdapter> parseAdapterConfig(String text) {
final Object? root;
try {
root = loadYaml(text);
} on YamlException catch (error) {
throw PipeException(
'adapter config is not valid YAML/JSON: ${error.message}',
);
}
if (root is! Map || root['adapters'] is! List) {
throw PipeException('adapter config needs a top-level "adapters" list');
}
final adapters = <QueryAdapter>[];
var index = 0;
for (final raw in root['adapters'] as List) {
final where = 'adapters[$index]';
index++;
if (raw is! Map) throw PipeException('adapter config: $where is not a map');
final interface = raw['interface'];
final interfaces = interface is String
? [interface]
: interface is List
? [for (final name in interface) '$name']
: const <String>[];
final library = raw['library'];
if (interfaces.isEmpty || library is! String) {
throw PipeException(
'adapter config: $where needs "interface" (a name or list of names) '
'and "library" (a package: URI)',
);
}
final methods = <String, AdapterMethod>{};
final rawMethods = raw['methods'];
if (rawMethods is! Map || rawMethods.isEmpty) {
throw PipeException('adapter config: $where needs a "methods" map');
}
for (final entry in rawMethods.entries) {
final spec = entry.value;
if (spec is! Map) {
throw PipeException(
'adapter config: $where.methods.${entry.key} is not a map',
);
}
final scope = spec['scope'];
if (scope != 'collection' && scope != 'collectionGroup') {
throw PipeException(
'adapter config: $where.methods.${entry.key}.scope must be '
'"collection" or "collectionGroup"',
);
}
methods['${entry.key}'] = AdapterMethod(
scope: scope as String,
path: ArgRef.parse(
spec['path'] ?? 0,
'$where.methods.${entry.key}.path',
),
realtime: spec['realtime'] == true,
);
}
final args = <String, String>{};
final rawArgs = raw['args'];
if (rawArgs is Map) {
for (final entry in rawArgs.entries) {
final role = '${entry.value}';
if (!_roles.contains(role)) {
throw PipeException(
'adapter config: $where.args.${entry.key} has role "$role"; '
'expected one of ${_roles.join(', ')}',
);
}
args['${entry.key}'] = role;
}
}
final types = <String, AdapterType>{};
final rawTypes = raw['types'];
if (rawTypes is Map) {
for (final entry in rawTypes.entries) {
types['${entry.key}'] = _parseType(
entry.value,
'$where.types.${entry.key}',
);
}
}
adapters.add(
QueryAdapter(
interfaces: interfaces,
library: library,
methods: methods,
args: args,
types: types,
),
);
}
return adapters;
}
AdapterType _parseType(Object? spec, String where) {
if (spec is! Map) throw PipeException('adapter config: $where is not a map');
final kind = spec['kind'] ?? 'filter';
if (kind != 'filter' && kind != 'orderBy') {
throw PipeException(
'adapter config: $where.kind must be "filter" or "orderBy"',
);
}
String checkedOp(Object? op, String at) {
if (op is String && queryOperators.contains(op)) return op;
throw PipeException(
'adapter config: $at is "$op"; expected one of ${queryOperators.join(' ')}',
);
}
Map<String, String> opTable(Object? raw, String at) => raw is Map
? {
for (final entry in raw.entries)
'${entry.key}': checkedOp(entry.value, '$at.${entry.key}'),
}
: const {};
final op = spec['op'];
final ops = opTable(spec['ops'], '$where.ops');
final opMap = opTable(spec['opMap'], '$where.opMap');
final opArg = spec['opArg'];
if (kind == 'filter' && op == null && ops.isEmpty && opArg == null) {
throw PipeException(
'adapter config: $where needs "op", "ops" or "opArg" + "opMap"',
);
}
return AdapterType(
kind: kind as String,
field: ArgRef.parse(spec['field'] ?? 0, '$where.field'),
op: op == null ? null : checkedOp(op, '$where.op'),
value: spec['value'] == null
? null
: ArgRef.parse(spec['value'], '$where.value'),
ops: ops,
opArg: opArg == null ? null : ArgRef.parse(opArg, '$where.opArg'),
opMap: opMap,
descending: spec['descending'] == null
? null
: ArgRef.parse(spec['descending'], '$where.descending'),
library: spec['library'] is String ? spec['library'] as String : null,
);
}
/// Every call of an adapted query method in [facts]' units.
List<QueryRecord> extractAdapterQueries(
SourceFacts facts,
QueryAdapter adapter,
) {
final records = <QueryRecord>[];
for (final unit in facts.units) {
unit.unit.accept(_AdapterCollector(facts, adapter, records));
}
return records;
}
class _AdapterCollector extends RecursiveAstVisitor<void> {
_AdapterCollector(this.facts, this.adapter, this.records);
final SourceFacts facts;
final QueryAdapter adapter;
final List<QueryRecord> records;
@override
void visitMethodInvocation(MethodInvocation node) {
super.visitMethodInvocation(node);
final method = adapter.methods[node.methodName.name];
if (method == null) return;
final element = node.methodName.element;
if (element is! MethodElement) return;
final owner = element.enclosingElement;
if (owner is! InterfaceElement || !_adapted(owner)) return;
records.add(_record(node, method));
}
/// The method belongs to an adapted interface, or to a class implementing
/// one; matched by name AND declaring library.
bool _adapted(InterfaceElement owner) =>
[owner.thisType, ...owner.allSupertypes].any(
(type) =>
adapter.interfaces.contains(type.element.name) &&
type.element.library.uri.toString() == adapter.library,
);
QueryRecord _record(MethodInvocation node, AdapterMethod method) {
final reasons = <String>{};
final state = _Clauses(reasons);
final pathArgument = method.path.of(node.argumentList);
final pattern = pathArgument == null
? '*'
: facts.stringPattern(pathArgument);
final segments = [
for (final segment in pattern.split('/'))
if (segment.isNotEmpty) segment,
];
final described = describePath(method.scope, segments, reasons);
for (final argument in node.argumentList.arguments) {
if (argument is! NamedArgument) continue;
final role = adapter.args[argument.name.lexeme];
final value = argument.argumentExpression;
switch (role) {
case 'filters' || 'orderBy':
_values(value, state, conditional: false);
case 'limit':
if (value.unParenthesized is NullLiteral) break;
final literal = facts.intValue(value);
state.limit = QueryLimit(
kind: literal == null ? 'variable' : 'literal',
value: literal,
);
case 'cursor':
if (value.unParenthesized is! NullLiteral) state.cursor = true;
}
}
return QueryRecord(
path: facts.relativePath(node),
line: facts.line(node.methodName.offset, node),
column: facts.column(node.methodName.offset, node),
owner: SourceFacts.ownerOf(node),
member: SourceFacts.memberOf(node),
scope: method.scope,
collection: described.collection,
pathPattern: described.path,
filters: state.filters,
composite: null,
orderBy: state.orders,
limit: state.limit,
cursor: state.cursor,
realtime: method.realtime,
aggregate: null,
inLoop: SourceFacts.inLoop(node),
confidence: reasons.isEmpty ? 'exact' : 'partial',
origin: 'adapter',
reasons: reasons.toList()..sort(),
);
}
/// Read filter / ordering values from an argument: a constructor call, a
/// list literal (with `if`/`for`/spread elements), a ternary, or a local
/// list built up with `add`/`addAll`.
void _values(
Expression expression,
_Clauses state, {
required bool conditional,
Set<String>? seen,
}) {
final node = expression.unParenthesized;
switch (node) {
case NullLiteral():
return;
case ListLiteral(:final elements):
for (final element in elements) {
_element(element, state, conditional: conditional, seen: seen);
}
case SetOrMapLiteral(:final elements):
for (final element in elements) {
_element(element, state, conditional: conditional, seen: seen);
}
case ConditionalExpression(:final thenExpression, :final elseExpression):
_values(thenExpression, state, conditional: true, seen: seen);
_values(elseExpression, state, conditional: true, seen: seen);
case InstanceCreationExpression():
_instance(node, state, conditional: conditional);
case SimpleIdentifier(:final element)
when element is LocalVariableElement:
_localList(element, state, conditional: conditional, seen: seen ?? {});
default:
state.reasons.add('an adapter argument is built outside the call');
}
}
void _element(
CollectionElement element,
_Clauses state, {
required bool conditional,
Set<String>? seen,
}) {
switch (element) {
case Expression():
_values(element, state, conditional: conditional, seen: seen);
case IfElement(:final thenElement, :final elseElement):
_element(thenElement, state, conditional: true, seen: seen);
if (elseElement != null) {
_element(elseElement, state, conditional: true, seen: seen);
}
case ForElement(:final body):
_element(body, state, conditional: true, seen: seen);
case SpreadElement(:final expression):
_values(expression, state, conditional: conditional, seen: seen);
default:
state.reasons.add('an adapter argument is built outside the call');
}
}
/// A local list: its initializer plus every `add`/`addAll` on it.
void _localList(
LocalVariableElement variable,
_Clauses state, {
required bool conditional,
required Set<String> seen,
}) {
final key = SourceFacts.keyOf(variable);
final declaration = key == null ? null : facts.declarations[key];
if (key == null || declaration is! VariableDeclaration || !seen.add(key)) {
state.reasons.add('an adapter argument is built outside the call');
return;
}
final initializer = declaration.initializer;
if (initializer != null) {
_values(initializer, state, conditional: conditional, seen: seen);
}
final body = declaration.thisOrAncestorOfType<FunctionBody>();
if (body == null) return;
final finder = _ListMutations(key);
body.accept(finder);
for (final invocation in finder.invocations) {
final argument = invocation.argumentList.arguments
.whereType<Expression>()
.firstOrNull;
if (argument == null) continue;
_values(
argument,
state,
conditional: conditional || SourceFacts.isConditional(invocation, body),
seen: seen,
);
}
if (finder.reassigned) {
state.reasons.add('an adapter argument list is reassigned');
}
}
void _instance(
InstanceCreationExpression node,
_Clauses state, {
required bool conditional,
}) {
final type = node.constructorName.type;
final name = type.name.lexeme;
final spec = adapter.types[name];
final element = type.element;
if (spec == null ||
element is! InterfaceElement ||
element.library.uri.toString() != (spec.library ?? adapter.library)) {
state.reasons.add(
'an adapter argument has a type the config does not map',
);
return;
}
final arguments = node.argumentList;
final fieldArgument = spec.field.of(arguments);
final field = _fieldName(fieldArgument, state);
if (spec.kind == 'orderBy') {
var dir = 'asc';
final flag = spec.descending?.of(arguments);
if (flag != null) {
final value = facts.boolValue(flag);
if (value == null) {
dir = 'unknown';
state.reasons.add('orderBy direction is not a constant');
} else {
dir = value ? 'desc' : 'asc';
}
}
state.orders.add(
QueryOrder(field: field, dir: dir, conditional: conditional),
);
return;
}
void add(String op, Expression? value) {
state.filters.add(
QueryFilter(
field: field,
op: op,
conditional: conditional,
valueType: value == null ? 'unknown' : SourceFacts.valueType(value),
inCount:
value != null &&
(op == 'in' || op == 'not-in' || op == 'array-contains-any')
? SourceFacts.literalCount(value)
: null,
),
);
}
if (spec.op != null) add(spec.op!, spec.value?.of(arguments));
for (final entry in spec.ops.entries) {
final value = ArgRef.named(entry.key).of(arguments);
if (value == null || value.unParenthesized is NullLiteral) continue;
add(entry.value, value);
}
if (spec.opArg != null) {
final raw = spec.opArg!.of(arguments);
final op = raw == null ? null : spec.opMap[_opToken(raw)];
if (op == null) {
state.reasons.add('a filter operator is not a mapped constant');
} else {
add(op, spec.value?.of(arguments));
}
}
}
/// An enum constant's name, or a string constant's value.
String? _opToken(Expression expression) {
final node = expression.unParenthesized;
final element = switch (node) {
PrefixedIdentifier(:final identifier) => identifier.element,
PropertyAccess(:final propertyName) => propertyName.element,
SimpleIdentifier(:final element) => element,
_ => null,
};
if (element != null && element.enclosingElement is EnumElement) {
return element.name;
}
final text = facts.stringPattern(node);
return text.contains('*') ? null : text;
}
String _fieldName(Expression? expression, _Clauses state) {
if (expression == null) {
state.reasons.add('a field name is not a compile-time constant');
return '*';
}
final field = facts.stringPattern(expression);
if (!safeNamePattern.hasMatch(field)) {
state.reasons.add('a field name has characters outside the safe set');
return '*';
}
if (field.contains('*')) {
state.reasons.add('a field name is not a compile-time constant');
}
return field;
}
}
class _Clauses {
_Clauses(this.reasons);
final Set<String> reasons;
final List<QueryFilter> filters = [];
final List<QueryOrder> orders = [];
QueryLimit limit = QueryLimit.none;
bool cursor = false;
}
/// `list.add(x)` / `list.addAll(xs)` calls on one local, by element key.
class _ListMutations extends RecursiveAstVisitor<void> {
_ListMutations(this.key);
final String key;
final List<MethodInvocation> invocations = [];
bool reassigned = false;
@override
void visitMethodInvocation(MethodInvocation node) {
final target = node.realTarget;
final name = node.methodName.name;
if ((name == 'add' || name == 'addAll') &&
target is SimpleIdentifier &&
SourceFacts.keyOf(target.element) == key) {
invocations.add(node);
}
super.visitMethodInvocation(node);
}
@override
void visitAssignmentExpression(AssignmentExpression node) {
if (SourceFacts.keyOf(node.writeElement) == key) reassigned = true;
super.visitAssignmentExpression(node);
}
}

View file

@ -1,26 +1,34 @@
import 'firestore_facts.dart';
import 'gateway_facts.dart';
import 'index.dart';
import 'project.dart';
import 'rules.dart';
import 'state_flow.dart';
/// One full analysis pass: resolved units, the state-flow graph, and findings.
/// One full analysis pass: resolved units, the state-flow graph, the Firestore
/// query inventory, and findings.
class ProjectAnalysis {
ProjectAnalysis({
required this.project,
required this.run,
required this.flow,
required this.firestore,
required this.report,
});
final Project project;
final ResolutionRun run;
final StateFlow flow;
/// Query surface only: no rule reads it, so it never changes findings.
final FirestoreModel firestore;
final FindingReport report;
Future<void> dispose() => run.dispose();
}
/// Resolve, build the state-flow graph, and evaluate the rules.
/// Resolve, build the state-flow graph and query inventory, and evaluate the
/// rules. [adapters] extend the inventory to declarative query APIs.
Future<ProjectAnalysis> analyzeProject(
Project project, {
String? sdkOverride,
@ -31,6 +39,7 @@ Future<ProjectAnalysis> analyzeProject(
Set<String>? enabledRules,
Set<String>? baseline,
FixPolicy policy = FixPolicy.wireUp,
List<QueryAdapter> adapters = const [],
}) async {
final run = await resolveProject(
project,
@ -42,6 +51,11 @@ Future<ProjectAnalysis> analyzeProject(
);
try {
final flow = buildStateFlow(project, run.outcomes);
final firestore = buildFirestoreModel(
project,
run.outcomes,
adapters: adapters,
);
final report = runChecks(
project: project,
outcomes: run.outcomes,
@ -54,6 +68,7 @@ Future<ProjectAnalysis> analyzeProject(
project: project,
run: run,
flow: flow,
firestore: firestore,
report: report,
);
} on Object {

View file

@ -62,6 +62,14 @@ void main() {
}
});
test('queries registers its own options on top of the common ones', () {
final queries = PipeRunner().commands['queries']!;
final options = queries.argParser.options;
expect(options.keys, containsAll(['adapter-config', 'require-firestore']));
expect(options['format']!.allowed, ['json', 'md']);
expect(options['format']!.defaultsTo, 'json');
});
test('the fixture project is a valid target for every command parser', () {
final runner = PipeRunner();
for (final entry in runner.commands.entries) {

View file

@ -0,0 +1,606 @@
import 'dart:convert';
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/cli.dart';
import 'package:test/test.dart';
const String fixturePath = 'test/fixtures/firestore_queries';
const String adapterConfigPath = '$fixturePath/adapters.json';
void main() {
late Project project;
late ResolutionRun run;
late FirestoreModel model;
setUpAll(() async {
project = await loadProject(fixturePath);
run = await resolveProject(project, concurrency: 1);
model = buildFirestoreModel(
project,
run.outcomes,
adapters: parseAdapterConfig(File(adapterConfigPath).readAsStringSync()),
);
});
tearDownAll(() async {
await run.dispose();
});
List<QueryRecord> byMember(String member, {String? collection}) => [
for (final query in model.queries)
if (query.member == member &&
(collection == null || query.collection == collection))
query,
];
QueryRecord single(String member, {String? collection}) {
final hits = byMember(member, collection: collection);
expect(
hits,
hasLength(1),
reason: '$member/${collection ?? '*'}: ${hits.map((q) => q.site)}',
);
return hits.single;
}
List<String> ops(QueryRecord query) => [
for (final filter in query.filters) '${filter.field} ${filter.op}',
];
test('fixture resolves with zero analyzer errors', () {
// test/fixtures/** has its own analysis_options, so `dart analyze` in the
// repo does not vouch for the fixture; the analyzer's own diagnostics do.
final errors = <String>[];
for (final outcome in run.outcomes) {
expect(outcome.resolved, isTrue, reason: outcome.failure);
for (final diagnostic in outcome.diagnostics) {
if (diagnostic.severity.name != 'error') continue;
errors.add('${outcome.path}: ${diagnostic.message}');
}
}
expect(errors, isEmpty, reason: errors.join('\n'));
expect(model.firestoreResolved, isTrue);
expect(model.packageDeclared, isTrue);
});
group('direct chains', () {
test('equality plus orderBy is exact', () {
final query = single('equalityAndOrder');
expect(query.scope, 'collection');
expect(query.collection, 'categories');
expect(query.pathPattern, 'categories');
expect(ops(query), ['isActive ==']);
expect(query.filters.single.valueType, 'bool');
expect(query.orderBy.single.toJson(), {
'field': 'name',
'dir': 'asc',
'conditional': false,
});
expect(query.limit.kind, 'none');
expect(query.realtime, isFalse);
expect(query.confidence, 'exact');
expect(query.origin, 'chain');
expect(query.owner, 'DirectQueries');
expect(query.reasons, isEmpty);
});
test('every named operator maps to its Firestore operator', () {
final query = single('everyOperator');
expect(ops(query), [
'ownerId ==',
'status !=',
'price <',
'price <=',
'createdAt >',
'rank >=',
'tags array-contains',
'genres array-contains-any',
'city in',
'kind not-in',
'deletedAt ==',
'publishedAt !=',
]);
final types = {
for (final filter in query.filters)
'${filter.field} ${filter.op}': filter.valueType,
};
expect(types['ownerId =='], 'string');
expect(types['price <'], 'int');
expect(types['price <='], 'double');
expect(types['createdAt >'], 'timestamp');
expect(types['genres array-contains-any'], 'list');
expect(types['deletedAt =='], 'null');
expect(types['publishedAt !='], 'null');
final counts = {
for (final filter in query.filters)
if (filter.inCount != null) filter.field: filter.inCount,
};
expect(counts, {'genres': 2, 'city': 3, 'kind': 1});
expect(query.limit.toJson(), {
'kind': 'literal',
'value': 20,
'conditional': false,
});
});
test('two operators in one where() are two filters', () {
final query = single('rangeInOneCall');
expect(ops(query), ['startsAt >=', 'startsAt <']);
expect(query.filters.map((f) => f.valueType), everyElement('timestamp'));
expect(query.orderBy.single.dir, 'desc');
});
test('collection names resolve through constants', () {
final names = byMember('constantNames').map((q) => q.collection);
expect(names, ['orders', 'auditTrail', 'reviews']);
final audit = single('constantNames', collection: 'auditTrail');
expect(audit.limit.last, isTrue);
expect(audit.limit.value, 5);
expect(byMember('constantNames').map((q) => q.confidence), [
'exact',
'exact',
'exact',
]);
});
test('interpolated and helper-built paths keep dynamic document ids', () {
final products = single('interpolatedPath');
expect(products.pathPattern, 'stores/*/products');
expect(products.collection, 'products');
expect(products.confidence, 'exact');
final payouts = single('pathHelpers', collection: 'payouts');
expect(payouts.pathPattern, 'stores/*/payouts');
final drafts = single('pathHelpers', collection: 'drafts');
expect(drafts.pathPattern, 'users/*/drafts');
});
test('doc() then collection() builds a subcollection path', () {
final services = single('documentThenSubcollection');
expect(services.pathPattern, 'stores/*/services');
expect(services.collection, 'services');
expect(services.cursor, isTrue);
final saved = single('rootDocumentPath');
expect(saved.pathPattern, 'users/*/saved');
});
test('collectionGroup with snapshots is a realtime group query', () {
final query = single('groupStream');
expect(query.scope, 'collectionGroup');
expect(query.collection, 'userSubscriptions');
expect(query.realtime, isTrue);
});
test('FieldPath.documentId is __name__, FieldPath([...]) is dotted', () {
final query = single('fieldPaths');
expect(ops(query), ['__name__ ==', 'address.city ==']);
expect(query.orderBy.single.field, '__name__');
expect(query.confidence, 'exact');
});
test('Filter.or / Filter.and become a composite node', () {
final query = single('disjunction');
expect(query.filters, isEmpty);
expect(query.composite!.toJson(), {
'op': 'or',
'filters': [
{
'field': 'buyerId',
'op': '==',
'conditional': false,
'valueType': 'string',
},
{
'composite': {
'op': 'and',
'filters': [
{
'field': 'sellerId',
'op': '==',
'conditional': false,
'valueType': 'string',
},
{
'field': 'open',
'op': '==',
'conditional': false,
'valueType': 'bool',
},
],
},
},
],
});
});
test('count() and aggregate(sum) are aggregation queries', () {
final queries = byMember('counting');
expect(queries.map((q) => q.aggregate), ['count', 'sum']);
expect(ops(queries.first), ['storeId ==']);
});
test('withConverter passes the query through', () {
final query = single('converted');
expect(query.collection, 'tips');
expect(ops(query), ['amount >']);
});
test('a fully dynamic collection is partial with no collection', () {
final query = single('dynamicCollection');
expect(query.collection, isNull);
expect(query.pathPattern, '*');
expect(query.confidence, 'partial');
expect(
query.reasons,
contains('collection name is not a compile-time constant'),
);
expect(ops(query), ['a ==']);
});
test('document reads are counted, never listed', () {
expect(byMember('documentRead'), isEmpty);
expect(model.documentReads, 1);
expect(model.notes.join(), contains('1 document read'));
});
});
group('name validation', () {
test('names outside the safe set are replaced, with a reason', () {
final query = single('unsafeNames');
expect(query.pathPattern, '*');
expect(query.collection, isNull);
expect(query.filters.single.field, '*');
expect(query.confidence, 'partial');
expect(
query.reasons,
containsAll([
'a field name has characters outside the safe set',
'a path segment has characters outside the safe set',
]),
);
});
test('no source text beyond safe names reaches the JSON', () {
final json = jsonEncode([for (final q in model.queries) q.toJson()]);
expect(json, isNot(contains('ignore previous')));
expect(json, isNot(contains('a b')));
for (final query in model.queries) {
final names = [
?query.collection,
?query.pathPattern,
for (final filter in query.filters) filter.field,
for (final order in query.orderBy) order.field,
];
for (final name in names) {
expect(safeNamePattern.hasMatch(name), isTrue, reason: name);
}
}
});
});
group('built queries', () {
test('a variable-built query unions its clauses; ifs make them '
'conditional', () {
final query = single('streamUserNotifications');
expect(query.collection, 'notifications');
expect(query.origin, 'variable');
expect(ops(query), ['userId ==']);
expect(query.filters.single.conditional, isFalse);
expect(query.orderBy.single.toJson(), {
'field': 'timestamp',
'dir': 'desc',
'conditional': false,
});
expect(query.limit.toJson(), {'kind': 'variable', 'conditional': true});
expect(query.cursor, isTrue);
expect(query.realtime, isTrue);
expect(query.confidence, 'exact');
});
test('an if around both assignment and terminal is not conditional, and '
'a later assignment does not reach an earlier terminal', () {
final queries = byMember('platform');
expect(queries, hasLength(2));
final inside = queries.first;
final after = queries.last;
expect(ops(inside), ['userId ==', 'isActive ==']);
expect(inside.filters.every((f) => !f.conditional), isTrue);
expect(inside.orderBy, isEmpty);
expect(ops(after), ['userId ==', 'isActive ==']);
expect(after.filters.last.conditional, isTrue);
expect(after.orderBy.single.conditional, isFalse);
});
test('ternary alternatives are both recorded as conditional', () {
final query = single('ternaryClause');
expect(query.orderBy.map((o) => '${o.dir} ${o.conditional}'), [
'desc true',
'asc true',
]);
expect(query.filters.single.conditional, isFalse);
});
test('fields are followed through constructor and late initializers', () {
expect(
single('receipts', collection: 'notificationReceipts').origin,
'variable',
);
expect(single('receipts', collection: 'inbox').limit.value, 3);
});
test('a helper is inlined with its call-site arguments', () {
final query = single('listingPage');
expect(query.scope, 'collectionGroup');
expect(query.collection, 'products');
expect(ops(query), ['userId ==']);
expect(query.orderBy.single.dir, 'desc');
expect(query.limit.kind, 'variable');
expect(query.confidence, 'exact');
final stores = single('countActiveStores');
expect(stores.origin, 'helper');
expect(stores.aggregate, 'count');
expect(ops(stores), ['active ==']);
});
test('helpers are inlined one level deep only', () {
final query = single('nestedHelper');
expect(query.confidence, 'unknown');
expect(query.origin, 'unknown');
expect(
query.reasons,
contains('query helper nested more than one level deep'),
);
});
test('a query from a parameter is unknown but keeps its local clauses', () {
final query = single('fromParameter');
expect(query.confidence, 'unknown');
expect(query.origin, 'unknown');
expect(query.scope, isNull);
expect(query.collection, isNull);
expect(ops(query), ['visible ==']);
expect(query.limit.value, 10);
expect(query.reasons, contains('query arrives as a parameter'));
final fallback = single('fallback');
expect(fallback.confidence, 'unknown');
expect(ops(fallback), ['kind ==']);
expect(fallback.filters.single.conditional, isTrue);
});
test('inLoop covers loops and per-element closures, not a fixed list', () {
final loops = {
for (final query in byMember('inLoops')) query.collection: query.inLoop,
};
expect(loops, {
'items': true,
'products': true,
'audit': true,
'once': false,
});
expect(single('equalityAndOrder').inLoop, isFalse);
});
});
test('look-alike names outside cloud_firestore are never extracted', () {
// Iterable.where/take, a project class named Query/CollectionReference and
// a `collection()` on a non-Firestore class all share method names with
// the Firestore API. Receivers are matched by type and library, not name.
expect(
model.queries.where((q) => q.path == 'lib/negative_controls.dart'),
isEmpty,
);
expect(model.queries.where((q) => q.member == 'lookalikes'), isEmpty);
});
group('adapters', () {
List<QueryRecord> adapted(String member) => [
for (final query in byMember(member))
if (query.origin == 'adapter') query,
];
test('a collection group with an in filter', () {
final query = adapted('reconcile').single;
expect(query.scope, 'collectionGroup');
expect(query.collection, 'settlements');
expect(ops(query), ['status in']);
expect(query.filters.single.valueType, 'list');
expect(query.limit.value, 200);
expect(query.realtime, isFalse);
expect(query.confidence, 'exact');
});
test('equality lists, named range bounds and orderBy objects', () {
final query = adapted('digest').single;
expect(ops(query), [
'userId ==',
'isRead ==',
'createdAt >=',
'createdAt <',
]);
expect(query.filters[1].valueType, 'bool');
expect(query.filters[2].valueType, 'timestamp');
expect(query.orderBy.single.toJson(), {
'field': 'createdAt',
'dir': 'desc',
'conditional': false,
});
});
test('a local list built with add() is followed; if makes it '
'conditional', () {
final query = adapted('followers').single;
expect(ops(query), ['active ==', 'following array-contains']);
expect(query.filters.first.conditional, isTrue);
expect(query.filters.last.conditional, isFalse);
expect(query.orderBy.single.field, '__name__');
expect(query.cursor, isTrue);
expect(query.limit.value, 500);
expect(query.confidence, 'exact');
});
test('an enum operator argument maps through opMap', () {
final query = adapted('storeScoped').single;
expect(query.pathPattern, 'stores/*/payouts');
expect(ops(query), ['amount >']);
expect(query.filters.single.valueType, 'int');
});
test('a transaction method on a second interface is matched', () {
final query = adapted('confirm').single;
expect(query.collection, 'confirmations');
expect(ops(query), ['token ==']);
expect(query.limit.value, 1);
});
test('calls through an implementing class match, in a loop', () {
final query = adapted('concrete').single;
expect(query.collection, 'carts');
expect(query.inLoop, isTrue);
});
test('an unmapped method is not a query', () {
expect(byMember('notAQuery'), isEmpty);
});
test('invalid configs are refused with a clear message', () {
expect(() => parseAdapterConfig('{}'), throwsA(isA<PipeException>()));
expect(
() => parseAdapterConfig(
'{"adapters":[{"interface":"G","library":"package:a/a.dart",'
'"methods":{"q":{"scope":"table"}}}]}',
),
throwsA(
isA<PipeException>().having(
(e) => e.toString(),
'message',
contains('scope'),
),
),
);
expect(
() => parseAdapterConfig(
'{"adapters":[{"interface":"G","library":"package:a/a.dart",'
'"methods":{"q":{"scope":"collection"}},'
'"types":{"W":{"field":0,"op":"LIKE"}}}]}',
),
throwsA(
isA<PipeException>().having(
(e) => e.toString(),
'message',
contains('LIKE'),
),
),
);
// YAML is accepted too.
final yaml = parseAdapterConfig('''
adapters:
- interface: G
library: package:a/a.dart
methods:
q: {scope: collectionGroup, path: 0}
''');
expect(yaml.single.methods['q']!.scope, 'collectionGroup');
});
});
test(
'the shared pipeline carries the inventory alongside the findings',
() async {
final analysis = await analyzeProject(project, concurrency: 1);
try {
expect(analysis.firestore.queries, isNotEmpty);
// No adapters were passed, so no adapter records.
expect(
analysis.firestore.queries.where((q) => q.origin == 'adapter'),
isEmpty,
);
} finally {
await analysis.dispose();
}
},
);
test('queries are sorted by site', () {
final sites = [for (final query in model.queries) (query.path, query.line)];
final sorted = [...sites]
..sort((a, b) {
final byPath = a.$1.compareTo(b.$1);
return byPath != 0 ? byPath : a.$2.compareTo(b.$2);
});
expect(sites, sorted);
});
test('the report root never carries an absolute path', () {
expect(queriesRoot('/work/app', cwd: '/work'), 'app');
expect(queriesRoot('/work', cwd: '/work'), '.');
expect(queriesRoot('/elsewhere/app', cwd: '/work'), 'app');
});
group('pipe queries CLI', () {
Future<ProcessResult> pipe(List<String> arguments) => Process.run(
Platform.resolvedExecutable,
['bin/pipe.dart', 'queries', ...arguments, '-c', '1'],
);
test('--format json keeps stdout pure JSON; notes go to stderr', () async {
final result = await pipe([
fixturePath,
'--format',
'json',
'--adapter-config',
adapterConfigPath,
]);
expect(result.exitCode, 0, reason: '${result.stderr}');
final json = jsonDecode(result.stdout as String) as Map<String, Object?>;
expect(json['version'], 1);
expect(json['root'], fixturePath);
expect((json['coverage'] as Map)['complete'], isTrue);
final totals = json['totals'] as Map<String, Object?>;
final queries = json['queries'] as List<Object?>;
expect(totals['queries'], queries.length);
expect(totals['byConfidence'], isA<Map<String, Object?>>());
expect((totals['byOrigin'] as Map)['adapter'], 6);
expect(json['unresolvedFirestoreUris'], isEmpty);
expect(result.stderr as String, contains('document read'));
});
test('--format md groups queries by collection', () async {
final result = await pipe([fixturePath, '--format', 'md']);
expect(result.exitCode, 0, reason: '${result.stderr}');
final text = result.stdout as String;
expect(text, startsWith('# Firestore queries'));
expect(text, contains('Coverage: '));
expect(text, contains('## `notifications` ('));
expect(text, contains('## `(unresolved)` ('));
expect(text, isNot(contains('ignore previous')));
// Unresolved queries are listed last.
expect(
text.indexOf('## `(unresolved)`'),
greaterThan(text.indexOf('## `userSubscriptions`')),
);
});
test('a project without cloud_firestore says so, and '
'--require-firestore fails', () async {
final plain = await pipe(['test/fixtures/stuck_loading']);
expect(plain.exitCode, 0, reason: '${plain.stderr}');
final json = jsonDecode(plain.stdout as String) as Map<String, Object?>;
expect(json['queries'], isEmpty);
expect(
(json['notes'] as List).join(),
contains('cloud_firestore is not in the package graph'),
);
final strict = await pipe([
'test/fixtures/stuck_loading',
'--require-firestore',
]);
expect(strict.exitCode, 2);
expect(strict.stderr as String, contains('--require-firestore'));
});
});
}

View file

@ -0,0 +1,20 @@
{
"configVersion": 2,
"packages": [
{
"name": "cloud_firestore",
"rootUri": "../packages/cloud_firestore/",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "firestore_queries",
"rootUri": "../",
"packageUri": "lib/",
"languageVersion": "3.0"
}
],
"generated": "2026-01-01T00:00:00.000Z",
"generator": "manual",
"generatorVersion": "1.0"
}

View file

@ -0,0 +1,29 @@
{
"adapters": [
{
"interface": ["Gateway", "Transaction"],
"library": "package:firestore_queries/data/gateway.dart",
"methods": {
"queryCollection": {"scope": "collection", "path": 0},
"queryCollectionGroup": {"scope": "collectionGroup", "path": 0}
},
"args": {
"filters": "filters",
"arrayContains": "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},
"WhereRange": {"field": 0, "ops": {"gte": ">=", "gt": ">", "lte": "<=", "lt": "<"}},
"WhereCmp": {"field": 0, "opArg": 1, "opMap": {"lessThan": "<", "greaterThan": ">"}, "value": 2},
"OrderBy": {"kind": "orderBy", "field": 0, "descending": "descending"}
}
}
]
}

View file

@ -0,0 +1,10 @@
# Rule fixtures are intentionally dependency-free standalone packages, so they
# must not inherit the repository analysis_options (which includes
# package:lints and would not resolve from here).
#
# They are deliberately NOT excluded from the repository analysis. Excluding
# them made any analyzer running in the repository context parse these files
# without resolving their imports, reporting phantom "undefined class" errors
# for perfectly valid fixtures.
analyzer:
exclude: []

View file

@ -0,0 +1,129 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Queries assembled across statements, getters and helpers.
class NotificationRepository {
NotificationRepository(this._firestore)
: _receipts = _firestore.collection('notificationReceipts');
final FirebaseFirestore _firestore;
final CollectionReference<Map<String, dynamic>> _receipts;
late final CollectionReference<Map<String, dynamic>> _inbox =
_firestore.collection('inbox');
CollectionReference<Map<String, dynamic>> get _collection =>
_firestore.collection('notifications');
Stream<QuerySnapshot<Map<String, dynamic>>> streamUserNotifications(
String userId, {
bool unreadOnly = false,
int? limit,
DocumentSnapshot<Object?>? startAfter,
}) {
Query<Map<String, dynamic>> query = _collection.where(
'userId',
isEqualTo: userId,
);
query = query.orderBy('timestamp', descending: true);
if (limit != null && !unreadOnly) {
query = query.limit(limit);
}
if (startAfter != null) {
query = query.startAfterDocument(startAfter);
}
return query.snapshots();
}
/// A clause assigned inside the same `if` as the terminal is not optional
/// there; an assignment after a terminal cannot reach it.
Stream<QuerySnapshot<Map<String, dynamic>>> platform({
bool onlyActive = false,
}) {
Query<Map<String, dynamic>> query = _collection.where(
'userId',
isNull: true,
);
if (onlyActive) {
query = query.where('isActive', isEqualTo: true);
return query.snapshots();
}
query = query.orderBy('timestamp', descending: true);
return query.snapshots();
}
Future<void> fallback(Query<Map<String, dynamic>>? custom) async {
await (custom ?? _collection.where('kind', isEqualTo: 'default')).get();
}
Future<void> ternaryClause(bool newestFirst) async {
final base = _collection.where('isRead', isEqualTo: false);
final ordered = newestFirst
? base.orderBy('createdAt', descending: true)
: base.orderBy('createdAt');
await ordered.get();
}
Future<void> receipts(String userId) async {
await _receipts.where('userId', isEqualTo: userId).get();
await _inbox.limit(3).get();
}
Query<Map<String, dynamic>> _buildListingQuery({
required String userId,
required String collectionName,
}) {
return _firestore
.collectionGroup(collectionName)
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true);
}
Future<void> listingPage(String userId, int pageSize) async {
final orderedQuery = _buildListingQuery(
userId: userId,
collectionName: 'products',
);
await orderedQuery.limit(pageSize).get();
}
Query<Map<String, dynamic>> _nested() => _buildListingQuery(
userId: 'u',
collectionName: 'services',
);
Future<void> nestedHelper() async {
await _nested().get();
}
Future<void> fromParameter(Query<Map<String, dynamic>> query) async {
await query.where('visible', isEqualTo: true).limit(10).get();
}
Future<void> inLoops(List<String> ids) async {
for (final id in ids) {
await _firestore.collection('stores').doc(id).collection('items').get();
}
await Future.wait(
ids.map(
(id) => _firestore
.collectionGroup('products')
.where('userId', whereIn: [id]).get(),
),
);
ids.forEach((id) {
_firestore.collection('audit').where('id', isEqualTo: id).get();
});
await Future.wait([_firestore.collection('once').get()]);
}
}
/// A top-level helper returning a query.
Query<Map<String, dynamic>> activeStores(FirebaseFirestore firestore) =>
firestore.collection('stores').where('active', isEqualTo: true);
Future<void> countActiveStores(FirebaseFirestore firestore) async {
await activeStores(firestore).count().get();
}

View file

@ -0,0 +1,10 @@
/// Collection names declared as constants, the way real apps centralise them.
const String kOrdersCollection = 'orders';
class Collections {
static const String auditTrail = 'auditTrail';
static const String stores = 'stores';
/// Final but not const: resolved from the declaration's literal initializer.
static final String reviews = 'reviews';
}

View file

@ -0,0 +1,110 @@
/// A declarative query API in the shape of a server-side Firestore gateway:
/// filters are value objects, not chained calls, so only an adapter config can
/// tell pipe what each argument means.
class WhereEquals {
const WhereEquals(this.field, this.value);
final String field;
final Object? value;
}
class WhereIn {
const WhereIn(this.field, this.values);
final String field;
final List<Object?> values;
}
class WhereArrayContains {
const WhereArrayContains(this.field, this.value);
final String field;
final Object? value;
}
class WhereRange {
const WhereRange(this.field, {this.gte, this.gt, this.lte, this.lt});
final String field;
final Object? gte;
final Object? gt;
final Object? lte;
final Object? lt;
}
enum Cmp { lessThan, greaterThan }
/// An operator carried as an enum argument rather than a named bound.
class WhereCmp {
const WhereCmp(this.field, this.cmp, this.value);
final String field;
final Cmp cmp;
final Object? value;
}
class OrderBy {
const OrderBy(this.field, {this.descending = false});
final String field;
final bool descending;
}
abstract class Transaction {
Future<List<Object>> queryCollection(
String collectionPath, {
List<WhereEquals> filters = const [],
int? limit,
});
}
abstract class Gateway {
Future<List<Object>> queryCollection(
String collectionPath, {
List<WhereEquals> filters = const [],
WhereArrayContains? arrayContains,
List<Object> ranges = const [],
List<OrderBy> orderBy = const [],
String? startAfterId,
int? limit,
});
Future<List<Object>> queryCollectionGroup(
String collectionId, {
List<WhereEquals> filters = const [],
WhereIn? inFilter,
List<Object> ranges = const [],
List<OrderBy> orderBy = const [],
int? limit,
});
Future<Object?> getDocument(String path);
Future<T> runTransaction<T>(Future<T> Function(Transaction txn) action);
}
class InMemoryGateway implements Gateway {
@override
Future<List<Object>> queryCollection(
String collectionPath, {
List<WhereEquals> filters = const [],
WhereArrayContains? arrayContains,
List<Object> ranges = const [],
List<OrderBy> orderBy = const [],
String? startAfterId,
int? limit,
}) async =>
const [];
@override
Future<List<Object>> queryCollectionGroup(
String collectionId, {
List<WhereEquals> filters = const [],
WhereIn? inFilter,
List<Object> ranges = const [],
List<OrderBy> orderBy = const [],
int? limit,
}) async =>
const [];
@override
Future<Object?> getDocument(String path) async => null;
@override
Future<T> runTransaction<T>(Future<T> Function(Transaction txn) action) =>
throw UnimplementedError();
}

View file

@ -0,0 +1,151 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'constants.dart';
/// A path helper: its return value is inlined with the call's arguments.
String payoutsPath(String storeId) => 'stores/$storeId/payouts';
/// Direct chains: every clause is visible in the one expression.
class DirectQueries {
DirectQueries(this.firestore);
final FirebaseFirestore firestore;
Future<void> equalityAndOrder() async {
await firestore
.collection('categories')
.where('isActive', isEqualTo: true)
.orderBy('name')
.get();
}
Future<void> everyOperator(String userId, DateTime since) async {
await firestore
.collection('events')
.where('ownerId', isEqualTo: userId)
.where('status', isNotEqualTo: 'archived')
.where('price', isLessThan: 10)
.where('price', isLessThanOrEqualTo: 9.5)
.where('createdAt', isGreaterThan: since)
.where('rank', isGreaterThanOrEqualTo: 2)
.where('tags', arrayContains: 'music')
.where('genres', arrayContainsAny: ['jazz', 'soul'])
.where('city', whereIn: ['nyc', 'la', 'sf'])
.where('kind', whereNotIn: ['spam'])
.where('deletedAt', isNull: true)
.where('publishedAt', isNull: false)
.limit(20)
.get();
}
Future<void> rangeInOneCall(Timestamp from, Timestamp to) async {
await firestore
.collection('bookings')
.where('startsAt', isGreaterThanOrEqualTo: from, isLessThan: to)
.orderBy('startsAt', descending: true)
.get();
}
Future<void> constantNames() async {
await firestore.collection(kOrdersCollection).get();
await firestore
.collection(Collections.auditTrail)
.orderBy('at', descending: true)
.limitToLast(5)
.get();
await firestore.collection(Collections.reviews).get();
}
Future<void> interpolatedPath(String storeId) async {
await firestore
.collection('${Collections.stores}/$storeId/products')
.where('visible', isEqualTo: true)
.get();
}
Future<void> pathHelpers(String storeId, String uid) async {
await firestore.collection(payoutsPath(storeId)).get();
final path = 'users/$uid/drafts';
await firestore.collection(path).get();
}
Future<void> documentThenSubcollection(String storeId) async {
await firestore
.collection('stores')
.doc(storeId)
.collection('services')
.orderBy('updatedAt', descending: true)
.startAfter([1]).get();
}
Future<void> rootDocumentPath(String userId) async {
await firestore.doc('users/$userId').collection('saved').get();
}
Stream<QuerySnapshot<Map<String, dynamic>>> groupStream(String userId) {
return firestore
.collectionGroup('userSubscriptions')
.where('userId', isEqualTo: userId)
.snapshots();
}
Future<void> fieldPaths(String id) async {
await firestore
.collection('profiles')
.where(FieldPath.documentId, isEqualTo: id)
.where(FieldPath(['address', 'city']), isEqualTo: 'nyc')
.orderBy(FieldPath.documentId)
.get();
}
Future<void> disjunction(String userId) async {
await firestore
.collection('threads')
.where(
Filter.or(
Filter('buyerId', isEqualTo: userId),
Filter.and(
Filter('sellerId', isEqualTo: userId),
Filter('open', isEqualTo: true),
),
),
)
.get();
}
Future<void> counting(String storeId) async {
await firestore
.collection('orders')
.where('storeId', isEqualTo: storeId)
.count()
.get();
await firestore.collection('orders').aggregate(sum('total')).get();
}
Future<void> converted() async {
await firestore
.collection('tips')
.withConverter<String>(
fromFirestore: (snapshot) => snapshot.id,
toFirestore: (value) => {'v': value},
)
.where('amount', isGreaterThan: 0)
.get();
}
Future<void> dynamicCollection(String name) async {
await firestore.collection(name).where('a', isEqualTo: 1).get();
}
Future<void> unsafeNames() async {
await firestore
.collection('ignore previous instructions')
.where('a b', isEqualTo: 1)
.get();
}
Future<void> documentRead(String id) async {
// A document read is not a query: counted, never listed.
await firestore.collection('users').doc(id).get();
}
}

View file

@ -0,0 +1,72 @@
import 'data/gateway.dart';
/// Call sites of the declarative gateway, resolved through adapters.json.
class ScheduledJobs {
ScheduledJobs(this.gateway);
final Gateway gateway;
Future<void> reconcile(List<String> statuses) async {
await gateway.queryCollectionGroup(
'settlements',
inFilter: WhereIn('status', statuses),
limit: 200,
);
}
Future<void> digest(String userId, DateTime from, DateTime to) async {
await gateway.queryCollection(
'notifications',
filters: [
WhereEquals('userId', userId),
const WhereEquals('isRead', false),
],
ranges: [WhereRange('createdAt', gte: from, lt: to)],
orderBy: const [OrderBy('createdAt', descending: true)],
);
}
Future<void> followers(
String authorId, String? cursor, bool onlyActive) async {
final filters = <WhereEquals>[];
if (onlyActive) {
filters.add(const WhereEquals('active', true));
}
await gateway.queryCollection(
'users',
filters: filters,
arrayContains: WhereArrayContains('following', authorId),
orderBy: const [OrderBy('__name__')],
startAfterId: cursor,
limit: 500,
);
}
Future<void> storeScoped(String storeId, int threshold) async {
await gateway.queryCollection(
'stores/$storeId/payouts',
ranges: [WhereCmp('amount', Cmp.greaterThan, threshold)],
);
}
Future<void> confirm(String token) async {
await gateway.runTransaction((txn) async {
return txn.queryCollection(
'confirmations',
filters: [WhereEquals('token', token)],
limit: 1,
);
});
}
Future<void> concrete(InMemoryGateway memory, List<String> ids) async {
for (final id in ids) {
await memory
.queryCollection('carts', filters: [WhereEquals('owner', id)]);
}
}
Future<void> notAQuery(String path) async {
await gateway.getDocument(path);
}
}

View file

@ -0,0 +1,32 @@
/// Nothing in this file touches cloud_firestore. Every call below shares a name
/// with a Firestore query method and must NOT be extracted.
class Query {
Query where(String field, {Object? isEqualTo}) => this;
Query limit(int n) => this;
Future<List<Object>> get() async => const [];
Stream<List<Object>> snapshots() => const Stream.empty();
}
class CollectionReference extends Query {
CollectionReference collection(String path) => this;
}
class FakeFirestore {
CollectionReference collection(String path) => CollectionReference();
}
class Limiter {
List<int> limit(List<int> values, int n) => values.take(n).toList();
}
Future<void> lookalikes(List<int> values) async {
values.where((value) => value > 2).take(3).toList();
Limiter().limit(values, 2);
await FakeFirestore()
.collection('lookalike')
.where('a', isEqualTo: 1)
.limit(2)
.get();
Query().snapshots();
}

View file

@ -0,0 +1,179 @@
/// A minimal skeleton of `package:cloud_firestore` for pipe's query-inventory
/// tests. Only the signatures matter: pipe identifies receivers by their static
/// type and this library's URI, so the bodies are never run.
library;
class GetOptions {
const GetOptions();
}
class Timestamp {
const Timestamp(this.seconds, this.nanoseconds);
final int seconds;
final int nanoseconds;
}
class GeoPoint {
const GeoPoint(this.latitude, this.longitude);
final double latitude;
final double longitude;
}
enum FieldPathType { documentId }
class FieldPath {
FieldPath(this.components);
final List<String> components;
static FieldPathType get documentId => FieldPathType.documentId;
}
class FirebaseFirestore {
FirebaseFirestore._();
static FirebaseFirestore get instance => FirebaseFirestore._();
CollectionReference<Map<String, dynamic>> collection(String collectionPath) =>
throw UnimplementedError();
Query<Map<String, dynamic>> collectionGroup(String collectionPath) =>
throw UnimplementedError();
DocumentReference<Map<String, dynamic>> doc(String documentPath) =>
throw UnimplementedError();
Future<T> runTransaction<T>(Future<T> Function(Transaction) handler) =>
throw UnimplementedError();
}
class Transaction {
Future<DocumentSnapshot<T>> get<T>(DocumentReference<T> reference) =>
throw UnimplementedError();
}
class DocumentSnapshot<T> {
String get id => throw UnimplementedError();
T? data() => throw UnimplementedError();
}
class QueryDocumentSnapshot<T> extends DocumentSnapshot<T> {}
class QuerySnapshot<T> {
List<QueryDocumentSnapshot<T>> get docs => throw UnimplementedError();
}
class AggregateQuerySnapshot {
int? get count => throw UnimplementedError();
}
abstract class AggregateField {}
// ignore: camel_case_types
class count extends AggregateField {}
// ignore: camel_case_types
class sum extends AggregateField {
sum(this.field);
final String field;
}
// ignore: camel_case_types
class average extends AggregateField {
average(this.field);
final String field;
}
class AggregateQuery {
Future<AggregateQuerySnapshot> get() => throw UnimplementedError();
}
class Filter {
Filter(
Object field, {
Object? isEqualTo,
Object? isNotEqualTo,
Object? isLessThan,
Object? isLessThanOrEqualTo,
Object? isGreaterThan,
Object? isGreaterThanOrEqualTo,
Object? arrayContains,
Iterable<Object?>? arrayContainsAny,
Iterable<Object?>? whereIn,
Iterable<Object?>? whereNotIn,
bool? isNull,
});
static Filter or(
Filter filter1,
Filter filter2, [
Filter? filter3,
Filter? filter4,
]) =>
throw UnimplementedError();
static Filter and(
Filter filter1,
Filter filter2, [
Filter? filter3,
Filter? filter4,
]) =>
throw UnimplementedError();
}
abstract class Query<T> {
Query<T> where(
Object field, {
Object? isEqualTo,
Object? isNotEqualTo,
Object? isLessThan,
Object? isLessThanOrEqualTo,
Object? isGreaterThan,
Object? isGreaterThanOrEqualTo,
Object? arrayContains,
Iterable<Object?>? arrayContainsAny,
Iterable<Object?>? whereIn,
Iterable<Object?>? whereNotIn,
bool? isNull,
});
Query<T> orderBy(Object field, {bool descending = false});
Query<T> limit(int limit);
Query<T> limitToLast(int limit);
Query<T> startAt(Iterable<Object?> values);
Query<T> startAfter(Iterable<Object?> values);
Query<T> endAt(Iterable<Object?> values);
Query<T> endBefore(Iterable<Object?> values);
Query<T> startAtDocument(DocumentSnapshot<Object?> documentSnapshot);
Query<T> startAfterDocument(DocumentSnapshot<Object?> documentSnapshot);
Query<T> endAtDocument(DocumentSnapshot<Object?> documentSnapshot);
Query<T> endBeforeDocument(DocumentSnapshot<Object?> documentSnapshot);
Query<R> withConverter<R>({
required R Function(DocumentSnapshot<Map<String, dynamic>>) fromFirestore,
required Map<String, Object?> Function(R) toFirestore,
});
Future<QuerySnapshot<T>> get([GetOptions? options]);
Stream<QuerySnapshot<T>> snapshots({bool includeMetadataChanges = false});
AggregateQuery count();
AggregateQuery aggregate(
AggregateField aggregateField1, [
AggregateField? aggregateField2,
AggregateField? aggregateField3,
]);
}
abstract class CollectionReference<T> implements Query<T> {
String get id;
String get path;
DocumentReference<T> doc([String? path]);
Future<DocumentReference<T>> add(T data);
}
abstract class DocumentReference<T> {
String get id;
CollectionReference<Map<String, dynamic>> collection(String collectionPath);
Future<DocumentSnapshot<T>> get([GetOptions? options]);
Stream<DocumentSnapshot<T>> snapshots({bool includeMetadataChanges = false});
Future<void> set(T data);
}

View file

@ -0,0 +1,9 @@
name: firestore_queries
description: >-
Fixture project for `pipe queries`. Depends on a hand-written cloud_firestore
skeleton under packages/, wired through a hand-written package_config.json.
version: 0.0.1
publish_to: none
environment:
sdk: ^3.0.0