diff --git a/.gitignore b/.gitignore index e3f0a73..9c2762e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 52c77f6..ea9f2fb 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ pipe verify --baseline .pipe-baseline.json --expect-resolved # What to fix first, per file (ranked, with the scoring explained) pipe suggest --format agent --budget 3000 + +# Every Firestore query the code can issue, as typed records (inventory only) +pipe queries --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 ` (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 ` (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> queryCollection(String collectionPath, + {List filters = const [], WhereArrayContains? arrayContains, + List ranges = const [], List orderBy = const [], + String? startAfterId, int? limit}); + Future> queryCollectionGroup(String collectionId, + {List filters = const [], WhereIn? inFilter, + WhereArrayContainsAny? arrayContainsAny, List ranges = const [], + List orderBy = const [], int? limit}); +} +abstract class FirestoreTransaction { + Future> queryCollection(String collectionPath, + {List 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.`: `scope` (`collection` | `collectionGroup`), `path` (the + argument holding the path or group id), optional `realtime: true`. +- `args.`: 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.`: 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 diff --git a/lib/pipe.dart b/lib/pipe.dart index 3a50ae1..53eb347 100644 --- a/lib/pipe.dart +++ b/lib/pipe.dart @@ -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, diff --git a/lib/src/cli.dart b/lib/src/cli.dart index b3294b2..40eec41 100644 --- a/lib/src/cli.dart +++ b/lib/src/cli.dart @@ -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 _formats = ['text', 'json']; const List _guideFormats = ['agent', 'md', 'json']; const List _planFormats = ['text', 'agent', 'json']; +const List _queryFormats = ['json', 'md']; void _addCommonOptions( ArgParser parser, { @@ -232,6 +236,7 @@ class PipeRunner extends CommandRunner { addCommand(RecallCommand()); addCommand(VerifyCommand()); addCommand(SuggestCommand()); + addCommand(QueriesCommand()); } } @@ -846,3 +851,205 @@ class GuideCommand extends Command { return 0; } } + +/// Inventory every Firestore query the project can issue. +class QueriesCommand extends Command { + 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 run() async { + final results = argResults!; + final configPath = results['adapter-config'] as String?; + final adapters = configPath == null + ? const [] + : parseAdapterConfig(_readText(configPath, 'adapter config')); + final project = await loadProject( + _target(results), + directories: results['dir'] as List, + ); + 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 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; + 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 = >{}; + 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 = [ + '`${query.site}`', + '${query.scope ?? 'unknown'} `${query.pathPattern ?? '*'}`', + ]; + final clauses = [ + 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(' | ')})'; +} diff --git a/lib/src/firestore_facts.dart b/lib/src/firestore_facts.dart new file mode 100644 index 0000000..1936bd9 --- /dev/null +++ b/lib/src/firestore_facts.dart @@ -0,0 +1,2032 @@ +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import 'analysis.dart'; +import 'gateway_facts.dart'; +import 'project.dart'; + +/// The only characters a collection, path or field name may carry into output. +/// +/// Names come from string literals in the analysed repository, which is +/// untrusted input (see excerpt.dart). Instead of quoting them, every name is +/// checked against this pattern and replaced with `*` when it fails, so no +/// source text beyond an identifier-like token ever reaches the inventory. +final RegExp safeNamePattern = RegExp(r'^[A-Za-z0-9_.*/\-]+$'); + +/// Owners and members are Dart identifiers (or pipe's own ``), so they +/// are safe by construction; this pattern pins that down. +final RegExp _safeIdentifier = RegExp(r'^(|[A-Za-z0-9_$]+)$'); + +/// Operators in the inventory, spelled the way Firestore's own index and +/// console tooling spells them. +const Set queryOperators = { + '==', + '!=', + '<', + '<=', + '>', + '>=', + 'array-contains', + 'array-contains-any', + 'in', + 'not-in', +}; + +/// `Query.where` / `Filter` named arguments and the operator each one means. +const Map _whereOperators = { + 'isEqualTo': '==', + 'isNotEqualTo': '!=', + 'isLessThan': '<', + 'isLessThanOrEqualTo': '<=', + 'isGreaterThan': '>', + 'isGreaterThanOrEqualTo': '>=', + 'arrayContains': 'array-contains', + 'arrayContainsAny': 'array-contains-any', + 'whereIn': 'in', + 'whereNotIn': 'not-in', +}; + +const Set _cursorMethods = { + 'startAt', + 'startAfter', + 'endAt', + 'endBefore', + 'startAtDocument', + 'startAfterDocument', + 'endAtDocument', + 'endBeforeDocument', +}; + +/// One field filter of a query. +class QueryFilter { + const QueryFilter({ + required this.field, + required this.op, + required this.valueType, + this.conditional = false, + this.inCount, + }); + + /// Field name, dotted for a nested `FieldPath`, `__name__` for the id. + final String field; + + /// One of [queryOperators]. + final String op; + + /// True when the clause is only applied on some paths (an `if`, a ternary, + /// a loop, a `switch`). + final bool conditional; + + /// `string`, `int`, `double`, `num`, `bool`, `timestamp`, `null`, `list`, + /// `map`, `reference` or `unknown`. + final String valueType; + + /// Length of a literal list operand (`in`, `not-in`, `array-contains-any`). + final int? inCount; + + String get identity => '$field $op'; + + QueryFilter withConditional(bool value) => QueryFilter( + field: field, + op: op, + valueType: valueType, + conditional: value, + inCount: inCount, + ); + + Map toJson() => { + 'field': field, + 'op': op, + 'conditional': conditional, + 'valueType': valueType, + if (inCount != null) 'inCount': inCount, + }; +} + +/// A `Filter.or(...)` / `Filter.and(...)` node. Children are [QueryFilter]s +/// or nested [QueryComposite]s. +class QueryComposite { + const QueryComposite({ + required this.op, + required this.filters, + this.conditional = false, + }); + + /// `or` or `and`. + final String op; + final List filters; + final bool conditional; + + String get identity => + '$op(${[for (final child in filters) child is QueryFilter ? child.identity : (child as QueryComposite).identity].join(',')})'; + + QueryComposite withConditional(bool value) => + QueryComposite(op: op, filters: filters, conditional: value); + + Map toJson() => { + 'op': op, + 'filters': [ + for (final child in filters) + child is QueryFilter + ? child.toJson() + : {'composite': (child as QueryComposite).toJson()}, + ], + if (conditional) 'conditional': true, + }; +} + +/// One `orderBy` clause. +class QueryOrder { + const QueryOrder({ + required this.field, + required this.dir, + this.conditional = false, + }); + + final String field; + + /// `asc`, `desc`, or `unknown` when `descending:` is not a constant. + final String dir; + final bool conditional; + + String get identity => '$field $dir'; + + QueryOrder withConditional(bool value) => + QueryOrder(field: field, dir: dir, conditional: value); + + Map toJson() => { + 'field': field, + 'dir': dir, + 'conditional': conditional, + }; +} + +/// The `limit` / `limitToLast` clause, or its absence. +class QueryLimit { + const QueryLimit({ + required this.kind, + this.value, + this.conditional = false, + this.last = false, + }); + + static const QueryLimit none = QueryLimit(kind: 'none'); + + /// `literal`, `variable` or `none`. + final String kind; + final int? value; + final bool conditional; + + /// True for `limitToLast`. + final bool last; + + QueryLimit withConditional(bool value) => + QueryLimit(kind: kind, value: this.value, conditional: value, last: last); + + Map toJson() => { + 'kind': kind, + if (value != null) 'value': value, + 'conditional': conditional, + if (last) 'last': true, + }; +} + +/// One query as it reaches Firestore: a terminal call site with the clauses +/// that can reach it. +class QueryRecord { + QueryRecord({ + required this.path, + required this.line, + required this.owner, + required this.member, + required this.scope, + required this.collection, + required this.pathPattern, + required this.filters, + required this.composite, + required this.orderBy, + required this.limit, + required this.cursor, + required this.realtime, + required this.aggregate, + required this.inLoop, + required this.confidence, + required this.origin, + required this.reasons, + this.column = 0, + }); + + /// Relative file path of the terminal call. + final String path; + final int line; + + /// Orders two queries on one line; not part of the schema. + final int column; + final String owner; + final String member; + + /// `collection`, `collectionGroup`, or null when the source is unknown. + final String? scope; + + /// The last collection id, or null when it is not a compile-time constant. + final String? collection; + + /// Slash-separated path pattern, `*` for every unresolved segment. + final String? pathPattern; + final List filters; + final QueryComposite? composite; + final List orderBy; + final QueryLimit limit; + final bool cursor; + final bool realtime; + + /// `count`, `sum` or `average` for an aggregation query. + final String? aggregate; + final bool inLoop; + + /// `exact`, `partial` or `unknown`. + final String confidence; + + /// `chain`, `variable`, `helper`, `unknown`, or `adapter`. + final String origin; + + /// Why the record is not exact, sorted. + final List reasons; + + String get site => '$path:$line'; + + Map toJson() => { + 'site': site, + 'owner': owner, + 'member': member, + 'scope': scope, + 'collection': collection, + 'path': pathPattern, + 'filters': [for (final filter in filters) filter.toJson()], + if (composite != null) 'composite': composite!.toJson(), + 'orderBy': [for (final order in orderBy) order.toJson()], + 'limit': limit.toJson(), + 'cursor': cursor, + 'realtime': realtime, + if (aggregate != null) 'aggregate': aggregate, + 'inLoop': inLoop, + 'confidence': confidence, + 'origin': origin, + 'reasons': reasons, + }; +} + +/// An import of cloud_firestore that did not resolve. +class UnresolvedFirestoreUri { + UnresolvedFirestoreUri(this.site, this.uri); + + final String site; + final String uri; + + Map toJson() => {'site': site, 'uri': uri}; +} + +/// Every Firestore query surface found in the scanned sources. +class FirestoreModel { + FirestoreModel({ + required this.queries, + required this.documentReads, + required this.packageDeclared, + required this.resolvedImports, + required this.unresolvedUris, + required this.adapterCount, + }); + + /// Sorted by site. + final List queries; + + /// `DocumentReference.get()` / `.snapshots()` calls: point reads, which need + /// no index, so they are counted but not listed. + final int documentReads; + + /// True when `cloud_firestore` is in the target's package_config.json. + final bool packageDeclared; + + /// Scanned files whose cloud_firestore import resolved. + final int resolvedImports; + final List unresolvedUris; + + /// Adapters configured with `--adapter-config`. + final int adapterCount; + + /// True when cloud_firestore types actually resolved somewhere, so an empty + /// query list means "no queries" rather than "could not see them". + bool get firestoreResolved => resolvedImports > 0 && unresolvedUris.isEmpty; + + /// Why the inventory may be incomplete. Never empty when [queries] is. + List get notes { + final notes = []; + if (!packageDeclared) { + notes.add( + 'cloud_firestore is not in the package graph ' + '(.dart_tool/package_config.json), so no cloud_firestore query can be ' + 'identified${adapterCount > 0 ? '; only adapter call sites are listed' : ''}.', + ); + } else if (resolvedImports == 0 && unresolvedUris.isEmpty) { + notes.add( + 'cloud_firestore is in the package graph but no scanned file imports ' + 'it, so no cloud_firestore query was found in the scanned directories.', + ); + } + if (unresolvedUris.isNotEmpty) { + notes.add( + '${unresolvedUris.length} cloud_firestore import(s) did not resolve; ' + 'queries in those files are invisible. Run `flutter pub get`.', + ); + } + if (queries.isEmpty && firestoreResolved) { + notes.add( + 'cloud_firestore resolved, but no query terminal (get, snapshots, ' + 'count, aggregate) on a Query or CollectionReference was found.', + ); + } + if (queries.isEmpty && notes.isEmpty) { + notes.add('no query was found in the scanned sources.'); + } + if (documentReads > 0) { + notes.add( + '$documentReads document read(s) (DocumentReference.get/snapshots) ' + 'are point reads that need no index; they are counted, not listed.', + ); + } + return notes; + } + + Map totals() { + final byConfidence = {'exact': 0, 'partial': 0, 'unknown': 0}; + final byScope = {}; + final byOrigin = {}; + final collections = {}; + var realtime = 0; + var inLoop = 0; + for (final query in queries) { + byConfidence.update(query.confidence, (v) => v + 1, ifAbsent: () => 1); + byScope.update(query.scope ?? 'unknown', (v) => v + 1, ifAbsent: () => 1); + byOrigin.update(query.origin, (v) => v + 1, ifAbsent: () => 1); + if (query.collection != null) { + collections.update(query.collection!, (v) => v + 1, ifAbsent: () => 1); + } + if (query.realtime) realtime++; + if (query.inLoop) inLoop++; + } + return { + 'queries': queries.length, + 'byConfidence': byConfidence, + 'byScope': _sorted(byScope), + 'byOrigin': _sorted(byOrigin), + 'realtime': realtime, + 'inLoop': inLoop, + 'documentReads': documentReads, + 'collections': _sorted(collections), + }; + } + + static Map _sorted(Map map) => { + for (final key in map.keys.toList()..sort()) key: map[key]!, + }; +} + +/// Build the query inventory for [outcomes]. +/// +/// cloud_firestore receivers are identified by static type and library URI, +/// never by name, so `Iterable.where` or a project class called `Query` is +/// never mistaken for a Firestore query. [adapters] extend the inventory to +/// declarative query APIs (see gateway_facts.dart). +FirestoreModel buildFirestoreModel( + Project project, + List outcomes, { + List adapters = const [], +}) { + final units = [ + for (final outcome in outcomes) + if (outcome.unit != null) outcome.unit!, + ]; + final facts = SourceFacts(project, units); + final resolver = _QueryResolver(facts); + final records = []; + var documentReads = 0; + var resolvedImports = 0; + final unresolved = []; + + for (final unit in units) { + var importsFirestore = false; + for (final directive in unit.unit.directives) { + if (directive is! ImportDirective) continue; + final uri = directive.uri.stringValue ?? ''; + if (!_isFirestoreUri(uri)) continue; + if (directive.libraryImport?.importedLibrary == null) { + unresolved.add( + UnresolvedFirestoreUri( + facts.site(directive), + // The URI was matched by prefix; the check keeps it a plain token. + safeNamePattern.hasMatch(uri.replaceFirst('package:', '')) + ? uri + : 'package:cloud_firestore/*', + ), + ); + } else { + importsFirestore = true; + } + } + if (importsFirestore) resolvedImports++; + + final collector = _TerminalCollector(resolver); + unit.unit.accept(collector); + records.addAll(collector.records); + documentReads += collector.documentReads; + } + + for (final adapter in adapters) { + records.addAll(extractAdapterQueries(facts, adapter)); + } + + records.sort((a, b) { + final byPath = a.path.compareTo(b.path); + if (byPath != 0) return byPath; + final byLine = a.line.compareTo(b.line); + return byLine != 0 ? byLine : a.column.compareTo(b.column); + }); + final packages = project.packageConfig['packages']; + return FirestoreModel( + queries: records, + documentReads: documentReads, + packageDeclared: + packages is List && + packages.any( + (entry) => entry is Map && entry['name'] == 'cloud_firestore', + ), + resolvedImports: resolvedImports, + unresolvedUris: unresolved, + adapterCount: adapters.length, + ); +} + +/// Validate [segments] as a path of [scope] (`collection`, `collectionGroup` +/// or `document`), adding to [reasons] whatever keeps it from being exact. +/// +/// Collection ids sit at even positions of a collection path and must be +/// constants; document ids are expected to be dynamic and cost nothing. +({String? collection, String path}) describePath( + String? scope, + List segments, + Set reasons, +) { + final safe = []; + for (final segment in segments) { + if (safeNamePattern.hasMatch(segment)) { + safe.add(segment); + } else { + safe.add('*'); + reasons.add('a path segment has characters outside the safe set'); + } + } + if (safe.isEmpty) safe.add('*'); + final group = scope == 'collectionGroup'; + if (group) { + if (safe.length != 1) reasons.add('collection group id contains a slash'); + } else if (safe.length.isEven) { + reasons.add('path has an even number of segments, not a collection'); + } + for (var i = 0; i < safe.length; i += group ? 1 : 2) { + if (safe[i].contains('*')) { + reasons.add('collection name is not a compile-time constant'); + } + } + final last = safe.last; + return (collection: last == '*' ? null : last, path: safe.join('/')); +} + +bool _isFirestoreUri(String uri) => + uri.startsWith('package:cloud_firestore/') || + uri.startsWith('package:cloud_firestore_platform_interface/'); + +bool _isFirestoreLibrary(LibraryElement? library) => + library != null && _isFirestoreUri(library.uri.toString()); + +/// The cloud_firestore class that [type] is, or inherits from: one of +/// `FirebaseFirestore`, `CollectionReference`, `Query`, `DocumentReference`, +/// `AggregateQuery`, `Filter`, `FieldPath`, `Timestamp`, or null. +String? firestoreKind(DartType? type) { + if (type is! InterfaceType) return null; + const kinds = [ + 'CollectionReference', + 'Query', + 'DocumentReference', + 'FirebaseFirestore', + 'AggregateQuery', + 'Filter', + 'FieldPath', + 'Timestamp', + ]; + final names = {}; + for (final candidate in [type, ...type.element.allSupertypes]) { + final element = candidate.element; + if (!_isFirestoreLibrary(element.library)) continue; + final name = element.name; + if (name != null) names.add(name); + } + for (final kind in kinds) { + if (names.contains(kind)) return kind; + } + return null; +} + +bool _isQueryKind(String? kind) => + kind == 'Query' || kind == 'CollectionReference'; + +/// Resolved sources plus the lookups every query extractor needs: declarations +/// by element, compile-time strings and ints, sites, owners, loops. +/// +/// Elements are keyed by their declaring file and name offset rather than by +/// identity, so a lookup still works when two units were resolved through +/// different library contexts. +class SourceFacts { + SourceFacts(this.project, this.units) { + for (final unit in units) { + unit.unit.accept(_DeclarationIndexer(this)); + } + } + + final Project project; + final List units; + + /// Variable, parameter, method and function declarations by element key. + final Map declarations = {}; + + /// Key identifying [element]'s declaration, or null for a synthetic one. + static String? keyOf(Element? element) { + if (element == null) return null; + Element base = element.baseElement; + if (base is PropertyAccessorElement && base.isOriginVariable) { + base = base.variable; + } + final fragment = base.firstFragment; + final source = fragment.libraryFragment?.source.fullName; + final offset = fragment.nameOffset; + if (source == null || offset == null) return null; + return '$source@$offset'; + } + + AstNode? declarationOf(Element? element) { + final key = keyOf(element); + return key == null ? null : declarations[key]; + } + + /// `relative/path.dart:line` for [node]. + String site(AstNode node) => + '${relativePath(node)}:${line(node.offset, node)}'; + + String relativePath(AstNode node) { + final root = node.root; + if (root is CompilationUnit) { + final source = root.declaredFragment?.source.fullName; + if (source != null) return project.relative(source); + } + return ''; + } + + int line(int offset, AstNode node) { + final root = node.root; + return root is CompilationUnit + ? root.lineInfo.getLocation(offset).lineNumber + : 0; + } + + int column(int offset, AstNode node) { + final root = node.root; + return root is CompilationUnit + ? root.lineInfo.getLocation(offset).columnNumber + : 0; + } + + /// The enclosing class-like declaration of [node], or ``. + static String ownerOf(AstNode node) { + for (AstNode? current = node; current != null; current = current.parent) { + final name = switch (current) { + ClassDeclaration(:final namePart) => namePart.typeName.lexeme, + MixinDeclaration(:final name) => name.lexeme, + EnumDeclaration(:final namePart) => namePart.typeName.lexeme, + ExtensionDeclaration(:final name) => name?.lexeme, + ExtensionTypeDeclaration(:final namePart) => namePart.typeName.lexeme, + _ => null, + }; + if (name != null) return _safeIdentifier.hasMatch(name) ? name : ''; + } + return ''; + } + + /// The enclosing named member (method, function, constructor, field). + static String memberOf(AstNode node) { + for (AstNode? current = node; current != null; current = current.parent) { + final name = switch (current) { + MethodDeclaration(:final name) => name.lexeme, + FunctionDeclaration(:final name) + when current.parent is CompilationUnit => + name.lexeme, + ConstructorDeclaration(:final name) => + name == null ? 'new' : name.lexeme, + VariableDeclaration(:final name) + when current.parent?.parent is FieldDeclaration || + current.parent?.parent is TopLevelVariableDeclaration => + name.lexeme, + _ => null, + }; + if (name != null) return _safeIdentifier.hasMatch(name) ? name : ''; + } + return ''; + } + + /// True when [node] runs once per element of something: inside a loop body, + /// or inside a closure handed to `Iterable.map`/`forEach`/`expand`, a + /// `Stream.asyncMap`, or `Future.wait`. + static bool inLoop(AstNode node) { + var passedClosure = false; + AstNode child = node; + for (var current = node.parent; current != null; current = current.parent) { + switch (current) { + case ForStatement(:final body) when identical(body, child): + return true; + case ForElement(:final body) when identical(body, child): + return true; + case WhileStatement(:final body) when identical(body, child): + return true; + case DoStatement(:final body) when identical(body, child): + return true; + case FunctionExpression(): + passedClosure = true; + if (_isIterationCallback(current)) return true; + case MethodInvocation() when passedClosure && _isFutureWait(current): + return true; + case MethodDeclaration() || + ConstructorDeclaration() || + FieldDeclaration() || + TopLevelVariableDeclaration(): + return false; + case FunctionDeclaration() when current.parent is CompilationUnit: + return false; + } + child = current; + } + return false; + } + + static bool _isIterationCallback(FunctionExpression closure) { + var parent = closure.parent; + if (parent is NamedArgument) parent = parent.parent; + if (parent is! ArgumentList) return false; + final invocation = parent.parent; + if (invocation is! MethodInvocation) return false; + const names = {'map', 'forEach', 'expand', 'asyncMap', 'asyncExpand'}; + if (!names.contains(invocation.methodName.name)) return false; + final type = invocation.realTarget?.staticType; + if (type is! InterfaceType) return false; + return [type, ...type.element.allSupertypes].any( + (candidate) => + candidate.isDartCoreIterable || + (candidate.element.name == 'Stream' && + candidate.element.library.uri.toString() == 'dart:async'), + ); + } + + static bool _isFutureWait(MethodInvocation invocation) { + if (invocation.methodName.name != 'wait') return false; + final element = invocation.methodName.element; + final owner = element?.enclosingElement; + return owner is InterfaceElement && + owner.name == 'Future' && + owner.library.uri.toString() == 'dart:async'; + } + + /// True when [node] only runs on some paths through [stop]: under an `if` + /// branch, a ternary branch, a `switch` case, a loop body, the right side of + /// `&&`/`||`/`??`, a `catch`, or a closure. + static bool isConditional(AstNode node, AstNode stop) { + AstNode child = node; + for (var current = node.parent; current != null; current = current.parent) { + if (identical(current, stop)) return false; + final conditional = switch (current) { + IfStatement(:final expression) => !identical(expression, child), + IfElement(:final expression) => !identical(expression, child), + ConditionalExpression(:final condition) => !identical(condition, child), + SwitchStatement(:final expression) => !identical(expression, child), + SwitchExpression(:final expression) => !identical(expression, child), + ForStatement() || + ForElement() || + WhileStatement() || + DoStatement() || + CatchClause() || + FunctionExpression() => true, + BinaryExpression(:final operator, :final rightOperand) => + identical(rightOperand, child) && + (operator.type == TokenType.AMPERSAND_AMPERSAND || + operator.type == TokenType.BAR_BAR || + operator.type == TokenType.QUESTION_QUESTION), + _ => false, + }; + if (conditional) return true; + child = current; + } + return false; + } + + /// A compile-time pattern for a string expression, with `*` for every part + /// that is not a constant. Returns `*` for a fully dynamic value. + String stringPattern(Expression expression, [Bindings? bindings]) => + _collapse(_string(expression, bindings, {})); + + static String _collapse(String pattern) => + pattern.replaceAll(RegExp(r'\*+'), '*'); + + String _string(Expression expression, Bindings? bindings, Set seen) { + final node = expression.unParenthesized; + switch (node) { + case SimpleStringLiteral(:final value): + return value; + case AdjacentStrings(:final strings): + return strings.map((part) => _string(part, bindings, seen)).join(); + case StringInterpolation(:final elements): + final buffer = StringBuffer(); + for (final element in elements) { + if (element is InterpolationString) { + buffer.write(element.value); + } else if (element is InterpolationExpression) { + buffer.write(_string(element.expression, bindings, seen)); + } + } + return buffer.toString(); + case BinaryExpression(:final operator, :final leftOperand) + when operator.type == TokenType.PLUS: + return _string(leftOperand, bindings, seen) + + _string(node.rightOperand, bindings, seen); + case SimpleIdentifier() || PrefixedIdentifier() || PropertyAccess(): + final element = _elementOf(node); + final bound = bindings?.lookup(element); + if (bound != null) { + return _string(bound.expression, bound.bindings, seen); + } + if (element is PropertyAccessorElement && !element.isOriginVariable) { + return _stringHelper(element, null, bindings, seen); + } + final variable = _variableOf(element); + // A parameter's default is not its value: callers pass their own. + if (variable == null || variable is FormalParameterElement) return '*'; + final constant = variable.computeConstantValue()?.toStringValue(); + if (constant != null) return constant; + if (!variable.isFinal && !variable.isConst) return '*'; + final key = keyOf(variable); + if (key == null || !seen.add(key)) return '*'; + try { + final declaration = declarations[key]; + if (declaration is VariableDeclaration && + declaration.initializer != null) { + return _string( + declaration.initializer!, + variable is LocalVariableElement ? bindings : null, + seen, + ); + } + return '*'; + } finally { + seen.remove(key); + } + case MethodInvocation(:final methodName, :final argumentList): + final element = methodName.element; + if (element is! ExecutableElement) return '*'; + return _stringHelper(element, argumentList, bindings, seen); + default: + return '*'; + } + } + + /// The string a path helper in the project returns (`storePath(id)` => + /// `'stores/$id'`), with its arguments bound; `*` when it has more than one + /// return or is outside the scanned sources. + String _stringHelper( + ExecutableElement element, + ArgumentList? arguments, + Bindings? bindings, + Set seen, + ) { + final key = keyOf(element); + if (key == null || !seen.add(key)) return '*'; + try { + final declaration = declarations[key]; + final ( + FunctionBody? body, + FormalParameterList? parameters, + ) = switch (declaration) { + MethodDeclaration(:final body, :final parameters) => (body, parameters), + FunctionDeclaration(:final functionExpression) => ( + functionExpression.body, + functionExpression.parameters, + ), + _ => (null, null), + }; + Expression? value; + if (body is ExpressionFunctionBody) { + value = body.expression; + } else if (body is BlockFunctionBody) { + final statements = body.block.statements; + final last = statements.length == 1 ? statements.single : null; + if (last is ReturnStatement) value = last.expression; + } + if (value == null) return '*'; + final inner = arguments == null + ? null + : Bindings.of(parameters, arguments, bindings); + return _string(value, inner, seen); + } finally { + seen.remove(key); + } + } + + /// An integer constant, or null when [expression] is not one. + int? intValue(Expression expression, [Bindings? bindings]) { + final node = expression.unParenthesized; + if (node is IntegerLiteral) return node.value; + if (node is SimpleIdentifier || + node is PrefixedIdentifier || + node is PropertyAccess) { + final element = _elementOf(node); + final bound = bindings?.lookup(element); + if (bound != null) return intValue(bound.expression, bound.bindings); + final variable = _variableOf(element); + if (variable is FormalParameterElement) return null; + return variable?.computeConstantValue()?.toIntValue(); + } + return null; + } + + /// A boolean constant, or null when [expression] is not one. + bool? boolValue(Expression expression, [Bindings? bindings]) { + final node = expression.unParenthesized; + if (node is BooleanLiteral) return node.value; + if (node is SimpleIdentifier || + node is PrefixedIdentifier || + node is PropertyAccess) { + final element = _elementOf(node); + final bound = bindings?.lookup(element); + if (bound != null) return boolValue(bound.expression, bound.bindings); + final variable = _variableOf(element); + if (variable is FormalParameterElement) return null; + return variable?.computeConstantValue()?.toBoolValue(); + } + return null; + } + + /// The type of value a filter compares against, when statically known. + static String valueType(Expression expression) { + if (expression.unParenthesized is NullLiteral) return 'null'; + final type = expression.staticType; + if (type == null || type is DynamicType) return 'unknown'; + if (type.isDartCoreNull) return 'null'; + if (type is! InterfaceType) return 'unknown'; + if (type.isDartCoreString) return 'string'; + if (type.isDartCoreInt) return 'int'; + if (type.isDartCoreDouble) return 'double'; + if (type.isDartCoreNum) return 'num'; + if (type.isDartCoreBool) return 'bool'; + if (type.isDartCoreList || type.isDartCoreSet || type.isDartCoreIterable) { + return 'list'; + } + if (type.isDartCoreMap) return 'map'; + final element = type.element; + if (element.name == 'DateTime' && + element.library.uri.toString() == 'dart:core') { + return 'timestamp'; + } + switch (firestoreKind(type)) { + case 'Timestamp': + return 'timestamp'; + case 'DocumentReference': + return 'reference'; + } + if (type.element.allSupertypes.any((s) => s.isDartCoreIterable)) { + return 'list'; + } + return 'unknown'; + } + + /// Length of a literal list with only plain elements, else null. + static int? literalCount(Expression expression) { + final node = expression.unParenthesized; + if (node is! ListLiteral && node is! SetOrMapLiteral) return null; + final elements = node is ListLiteral + ? node.elements + : (node as SetOrMapLiteral).elements; + if (elements.any((element) => element is! Expression)) return null; + return elements.length; + } + + static Element? _elementOf(Expression node) => switch (node) { + SimpleIdentifier(:final element) => element, + PrefixedIdentifier(:final identifier) => identifier.element, + PropertyAccess(:final propertyName) => propertyName.element, + _ => null, + }; + + static VariableElement? _variableOf(Element? element) { + if (element is VariableElement) return element; + if (element is PropertyAccessorElement && element.isOriginVariable) { + return element.variable; + } + return null; + } +} + +/// Arguments bound to a helper's parameters at one call site. +class Bindings { + Bindings(this.values); + + final Map values; + + BoundArgument? lookup(Element? element) { + final key = SourceFacts.keyOf(element); + return key == null ? null : values[key]; + } + + /// Bind [arguments] to the parameters of [parameters]. + static Bindings of( + FormalParameterList? parameters, + ArgumentList arguments, + Bindings? outer, + ) { + final values = {}; + if (parameters != null) { + final positional = arguments.arguments.whereType().toList(); + final named = { + for (final argument in arguments.arguments) + if (argument is NamedArgument) + argument.name.lexeme: argument.argumentExpression, + }; + var index = 0; + for (final parameter in parameters.parameters) { + final key = SourceFacts.keyOf(parameter.declaredFragment?.element); + if (key == null) continue; + final name = parameter.name?.lexeme; + final Expression? value; + if (parameter.isNamed) { + value = named[name]; + } else { + value = index < positional.length ? positional[index] : null; + index++; + } + if (value != null) values[key] = BoundArgument(value, outer); + } + } + return Bindings(values); + } +} + +class BoundArgument { + BoundArgument(this.expression, this.bindings); + + final Expression expression; + + /// The bindings in force where [expression] was written. + final Bindings? bindings; +} + +class _DeclarationIndexer extends RecursiveAstVisitor { + _DeclarationIndexer(this.facts); + + final SourceFacts facts; + + void _add(Fragment? fragment, AstNode node) { + final key = SourceFacts.keyOf(fragment?.element); + if (key != null) facts.declarations[key] = node; + } + + @override + void visitVariableDeclaration(VariableDeclaration node) { + _add(node.declaredFragment, node); + super.visitVariableDeclaration(node); + } + + @override + void visitRegularFormalParameter(RegularFormalParameter node) { + _add(node.declaredFragment, node); + super.visitRegularFormalParameter(node); + } + + @override + void visitFieldFormalParameter(FieldFormalParameter node) { + _add(node.declaredFragment, node); + super.visitFieldFormalParameter(node); + } + + @override + void visitSuperFormalParameter(SuperFormalParameter node) { + _add(node.declaredFragment, node); + super.visitSuperFormalParameter(node); + } + + @override + void visitMethodDeclaration(MethodDeclaration node) { + _add(node.declaredFragment, node); + super.visitMethodDeclaration(node); + } + + @override + void visitFunctionDeclaration(FunctionDeclaration node) { + _add(node.declaredFragment, node); + super.visitFunctionDeclaration(node); + } +} + +/// The partial query an expression evaluates to. +/// +/// A shape is always a fresh copy: helper summaries are memoized, and chain +/// methods extend the shape they are handed. +class _Shape { + _Shape({ + this.scope, + this.segments, + this.origin = 'chain', + this.unknownBase = false, + this.selfBase = false, + }); + + /// A query whose source cannot be followed. + _Shape.unknown(String reason) + : origin = 'unknown', + unknownBase = true, + selfBase = false { + reasons.add(reason); + } + + /// The variable being defined, inside its own assignment (`q = q.where()`). + _Shape.self() : origin = 'variable', unknownBase = false, selfBase = true; + + /// `collection`, `collectionGroup` or `document`. + String? scope; + List? segments; + String origin; + bool unknownBase; + bool selfBase; + final List filters = []; + final List composites = []; + final List orders = []; + QueryLimit limit = QueryLimit.none; + bool cursor = false; + final Set reasons = {}; + + _Shape copy() { + final copy = _Shape( + scope: scope, + segments: segments == null ? null : [...segments!], + origin: origin, + unknownBase: unknownBase, + selfBase: selfBase, + ); + copy.filters.addAll(filters); + copy.composites.addAll(composites); + copy.orders.addAll(orders); + copy.limit = limit; + copy.cursor = cursor; + copy.reasons.addAll(reasons); + return copy; + } + + /// Mark every clause conditional (the whole shape is on one branch). + _Shape conditionalCopy() { + final copy = this.copy(); + _markConditional(copy); + return copy; + } + + static void _markConditional(_Shape shape) { + for (var i = 0; i < shape.filters.length; i++) { + shape.filters[i] = shape.filters[i].withConditional(true); + } + for (var i = 0; i < shape.composites.length; i++) { + shape.composites[i] = shape.composites[i].withConditional(true); + } + for (var i = 0; i < shape.orders.length; i++) { + shape.orders[i] = shape.orders[i].withConditional(true); + } + if (shape.limit.kind != 'none') { + shape.limit = shape.limit.withConditional(true); + } + } + + /// Append the clauses of [delta] (a self-based shape) to this one. + /// + /// A clause the shape already carries is not repeated: it is conditional + /// only if both occurrences are. + void apply(_Shape delta, {required bool conditional}) { + void add( + List into, + List from, + String Function(T) identity, + bool Function(T) isConditional, + T Function(T, bool) mark, + ) { + for (final item in from) { + final value = isConditional(item) || conditional; + final index = into.indexWhere((e) => identity(e) == identity(item)); + if (index < 0) { + into.add(mark(item, value)); + } else { + into[index] = mark(into[index], isConditional(into[index]) && value); + } + } + } + + add( + filters, + delta.filters, + (f) => f.identity, + (f) => f.conditional, + (f, c) => f.withConditional(c), + ); + add( + composites, + delta.composites, + (c) => c.identity, + (c) => c.conditional, + (c, v) => c.withConditional(v), + ); + add( + orders, + delta.orders, + (o) => o.identity, + (o) => o.conditional, + (o, c) => o.withConditional(c), + ); + if (delta.limit.kind != 'none') { + limit = delta.limit.withConditional( + delta.limit.conditional || conditional, + ); + } + cursor = cursor || delta.cursor; + reasons.addAll(delta.reasons); + } + + /// The union of two alternatives: a clause present in only one of them is + /// conditional. + static _Shape merge(_Shape a, _Shape b) { + final result = a.copy(); + result.reasons.addAll(b.reasons); + if (a.unknownBase || b.unknownBase) { + result.unknownBase = true; + result.origin = 'unknown'; + } else if (a.selfBase != b.selfBase) { + result.selfBase = false; + if (a.selfBase) { + result + ..scope = b.scope + ..segments = b.segments == null ? null : [...b.segments!] + ..origin = b.origin; + } + } else if (a.scope != b.scope || !_sameSegments(a.segments, b.segments)) { + final as = a.segments; + final bs = b.segments; + if (a.scope == b.scope && + as != null && + bs != null && + as.length == bs.length) { + result.segments = [ + for (var i = 0; i < as.length; i++) as[i] == bs[i] ? as[i] : '*', + ]; + } else { + // A collection on one branch and a collection group on the other are + // two different queries; naming either would misreport the other. + result + ..scope = null + ..segments = null; + } + result.reasons.add('alternative branches query different collections'); + } + + List union( + List left, + List right, + String Function(T) identity, + bool Function(T) isConditional, + T Function(T, bool) mark, + ) { + final rightById = {for (final item in right) identity(item): item}; + final leftIds = {for (final item in left) identity(item)}; + return [ + for (final item in left) + mark( + item, + isConditional(item) || + !rightById.containsKey(identity(item)) || + isConditional(rightById[identity(item)] as T), + ), + for (final item in right) + if (!leftIds.contains(identity(item))) mark(item, true), + ]; + } + + result.filters + ..clear() + ..addAll( + union( + a.filters, + b.filters, + (f) => f.identity, + (f) => f.conditional, + (f, c) => f.withConditional(c), + ), + ); + result.composites + ..clear() + ..addAll( + union( + a.composites, + b.composites, + (c) => c.identity, + (c) => c.conditional, + (c, v) => c.withConditional(v), + ), + ); + result.orders + ..clear() + ..addAll( + union( + a.orders, + b.orders, + (o) => o.identity, + (o) => o.conditional, + (o, c) => o.withConditional(c), + ), + ); + final la = a.limit; + final lb = b.limit; + if (la.kind == 'none' && lb.kind == 'none') { + result.limit = QueryLimit.none; + } else if (la.kind == lb.kind && + la.value == lb.value && + la.last == lb.last) { + result.limit = la.withConditional(la.conditional || lb.conditional); + } else { + result.limit = (la.kind == 'none' ? lb : la).withConditional(true); + } + result.cursor = a.cursor || b.cursor; + return result; + } + + static bool _sameSegments(List? a, List? b) { + if (a == null || b == null) return a == b; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +/// Evaluation context: helper depth, parameter bindings, and the variable +/// currently being defined. +class _Context { + const _Context({this.depth = 0, this.bindings, this.self}); + + final int depth; + final Bindings? bindings; + final String? self; + + _Context withSelf(String key) => + _Context(depth: depth, bindings: bindings, self: key); +} + +/// Follows query expressions back to their collection through chains, local +/// variables, getters, fields and one level of helper methods. +class _QueryResolver { + _QueryResolver(this.facts); + + final SourceFacts facts; + final Map _memo = {}; + final Set _active = {}; + + /// Helper methods are inlined this many levels deep. Getters and fields take + /// no arguments, so they do not count against it. + static const int maxHelperDepth = 1; + + _Shape evaluate(Expression expression, _Context context) { + final node = _unwrap(expression); + switch (node) { + case MethodInvocation(): + return _invocation(node, context); + case SimpleIdentifier() || PrefixedIdentifier() || PropertyAccess(): + return _reference(node, context); + case ConditionalExpression(:final thenExpression, :final elseExpression): + return _Shape.merge( + evaluate(thenExpression, context), + evaluate(elseExpression, context), + ); + case CascadeExpression(:final target): + return evaluate(target, context); + case BinaryExpression(:final operator, :final leftOperand) + when operator.type == TokenType.QUESTION_QUESTION: + return _Shape.merge( + evaluate(leftOperand, context), + evaluate(node.rightOperand, context), + ); + default: + return _Shape.unknown( + 'query built by an expression pipe cannot follow', + ); + } + } + + static Expression _unwrap(Expression expression) { + var node = expression.unParenthesized; + while (true) { + if (node is AsExpression) { + node = node.expression.unParenthesized; + } else if (node is PostfixExpression && + node.operator.type == TokenType.BANG) { + node = node.operand.unParenthesized; + } else { + return node; + } + } + } + + _Shape _invocation(MethodInvocation node, _Context context) { + final target = node.realTarget; + final name = node.methodName.name; + final element = node.methodName.element; + final targetKind = firestoreKind(target?.staticType); + final fromFirestore = _isFirestoreLibrary(element?.library); + final arguments = node.argumentList.arguments; + final positional = arguments.whereType().toList(); + + if (target != null && fromFirestore && targetKind != null) { + switch ((targetKind, name)) { + case ('FirebaseFirestore', 'collection'): + return _Shape( + scope: 'collection', + segments: _segments(positional.firstOrNull, context), + ); + case ('FirebaseFirestore', 'collectionGroup'): + return _Shape( + scope: 'collectionGroup', + segments: _segments(positional.firstOrNull, context), + ); + case ('FirebaseFirestore', 'doc'): + return _Shape( + scope: 'document', + segments: _segments(positional.firstOrNull, context), + ); + case ('DocumentReference', 'collection'): + final base = evaluate(target, context); + return base + ..scope = base.unknownBase ? null : 'collection' + ..segments = base.segments == null + ? null + : [ + ...base.segments!, + ..._segments(positional.firstOrNull, context), + ]; + case ('CollectionReference', 'doc'): + final base = evaluate(target, context); + return base + ..scope = base.unknownBase ? null : 'document' + ..segments = base.segments == null + ? null + : [ + ...base.segments!, + ..._segments(positional.firstOrNull, context), + ]; + } + if (_isQueryKind(targetKind)) { + switch (name) { + case 'where': + final base = evaluate(target, context); + _where(base, node.argumentList, context); + return base; + case 'orderBy': + final base = evaluate(target, context); + _orderBy(base, node.argumentList, context); + return base; + case 'limit' || 'limitToLast': + final base = evaluate(target, context); + final value = positional.isEmpty + ? null + : facts.intValue(positional.first, context.bindings); + base.limit = QueryLimit( + kind: value == null ? 'variable' : 'literal', + value: value, + last: name == 'limitToLast', + ); + return base; + case 'withConverter': + return evaluate(target, context); + } + if (_cursorMethods.contains(name)) { + return evaluate(target, context)..cursor = true; + } + } + return _Shape.unknown('cloud_firestore call pipe does not model'); + } + + // Not a cloud_firestore call: a helper in the project returning a query. + if (element is ExecutableElement) { + return _helper(element, node.argumentList, context); + } + return _Shape.unknown('query returned by an unresolved call'); + } + + _Shape _reference(Expression node, _Context context) { + final element = SourceFacts._elementOf(node); + final key = SourceFacts.keyOf(element); + if (key != null && key == context.self) return _Shape.self(); + + final bound = context.bindings?.lookup(element); + if (bound != null) { + // A query passed into a helper: follow the argument at the call site. + return evaluate( + bound.expression, + _Context(depth: context.depth, bindings: bound.bindings), + ); + } + + if (element is LocalVariableElement || element is FormalParameterElement) { + return _variable(element!, context, node); + } + if (element is PropertyAccessorElement) { + if (element.isOriginVariable) return _field(element.variable, context); + return _helper(element, null, context); + } + return _Shape.unknown('query from a source pipe cannot resolve'); + } + + /// A local variable or parameter read at [use]: the union of every + /// definition in its function body. + /// + /// Flow-insensitive, with one cut: an assignment that sits textually after + /// [use] cannot reach it unless both are inside the same loop. + _Shape _variable(Element element, _Context context, AstNode use) { + final key = SourceFacts.keyOf(element); + final declaration = key == null ? null : facts.declarations[key]; + if (key == null || declaration == null) { + return _Shape.unknown( + 'query variable declared outside the scanned sources', + ); + } + if (!_active.add(key)) { + return _Shape.unknown('query variable defined in terms of itself'); + } + try { + final body = + declaration.thisOrAncestorOfType() ?? + _parameterBody(declaration); + final bases = <_Shape>[]; + final deltas = <(_Shape, bool)>[]; + final selfContext = context.withSelf(key); + + if (declaration is VariableDeclaration) { + final initializer = declaration.initializer; + if (initializer != null) bases.add(evaluate(initializer, selfContext)); + } else { + bases.add( + _Shape.unknown('query arrives as a parameter')..origin = 'unknown', + ); + } + + if (body != null) { + final finder = _AssignmentFinder(key); + body.accept(finder); + for (final assignment in finder.assignments) { + if (assignment.offset > use.offset && + !_shareLoop(use, assignment, body)) { + continue; + } + final shape = evaluate(assignment.rightHandSide, selfContext); + // Conditional relative to the use: an `if` enclosing both the + // assignment and the terminal does not make the clause optional. + final conditional = + SourceFacts.isConditional( + assignment, + _commonAncestor(assignment, use) ?? body, + ) || + assignment.operator.type == TokenType.QUESTION_QUESTION_EQ; + if (shape.selfBase) { + deltas.add((shape, conditional)); + } else { + bases.add(conditional ? shape.conditionalCopy() : shape); + } + } + } + + if (bases.isEmpty) { + return _Shape.unknown('query variable has no visible definition'); + } + final result = bases.reduce(_Shape.merge); + for (final (delta, conditional) in deltas) { + result.apply(delta, conditional: conditional); + } + if (!result.unknownBase) result.origin = 'variable'; + return result; + } finally { + _active.remove(key); + } + } + + static AstNode? _commonAncestor(AstNode a, AstNode b) { + final ancestors = {}; + for (AstNode? node = b; node != null; node = node.parent) { + ancestors.add(node); + } + for (AstNode? node = a; node != null; node = node.parent) { + if (ancestors.contains(node)) return node; + } + return null; + } + + /// True when a loop inside [body] encloses both [use] and [other]. + static bool _shareLoop(AstNode use, AstNode other, AstNode body) { + for (var node = use.parent; node != null; node = node.parent) { + if (identical(node, body)) return false; + if ((node is ForStatement || + node is WhileStatement || + node is DoStatement || + node is ForElement) && + node.offset <= other.offset && + other.end <= node.end) { + return true; + } + } + return false; + } + + static FunctionBody? _parameterBody(AstNode declaration) { + final list = declaration.thisOrAncestorOfType(); + return switch (list?.parent) { + MethodDeclaration(:final body) => body, + FunctionExpression(:final body) => body, + ConstructorDeclaration(:final body) => body, + _ => null, + }; + } + + /// A field or top-level variable: its initializer, constructor initializers, + /// and assignments within its class. + _Shape _field(Element variable, _Context context) { + final key = SourceFacts.keyOf(variable); + final declaration = key == null ? null : facts.declarations[key]; + if (key == null || declaration is! VariableDeclaration) { + return _Shape.unknown( + 'query held by a field outside the scanned sources', + ); + } + final memo = _memo[key]; + if (memo != null) return memo.copy(); + if (!_active.add(key)) { + return _Shape.unknown('query field defined in terms of itself'); + } + try { + final bases = <_Shape>[]; + const root = _Context(); + final initializer = declaration.initializer; + if (initializer != null) bases.add(evaluate(initializer, root)); + final owner = declaration.thisOrAncestorOfType(); + if (owner != null) { + final finder = _FieldDefinitionFinder(key); + owner.accept(finder); + for (final value in finder.values) { + bases.add(evaluate(value, root)); + } + if (finder.fromParameter) { + bases.add( + _Shape.unknown('query field assigned from a constructor parameter'), + ); + } + } + final result = bases.isEmpty + ? _Shape.unknown('query field has no visible definition') + : bases.reduce(_Shape.merge); + if (!result.unknownBase) result.origin = 'variable'; + _memo[key] = result.copy(); + return result; + } finally { + _active.remove(key); + } + } + + /// A getter, method or function in the project that returns a query. + _Shape _helper( + ExecutableElement element, + ArgumentList? arguments, + _Context context, + ) { + final key = SourceFacts.keyOf(element); + final declaration = key == null ? null : facts.declarations[key]; + final FunctionBody? body; + final FormalParameterList? parameters; + switch (declaration) { + case MethodDeclaration(): + body = declaration.body; + parameters = declaration.parameters; + case FunctionDeclaration(:final functionExpression): + body = functionExpression.body; + parameters = functionExpression.parameters; + default: + return _Shape.unknown( + 'query returned by code outside the scanned sources', + ); + } + final isGetter = element is PropertyAccessorElement; + if (!isGetter && context.depth >= maxHelperDepth) { + return _Shape.unknown('query helper nested more than one level deep'); + } + final hasArguments = arguments != null && arguments.arguments.isNotEmpty; + if (!hasArguments) { + final memo = _memo[key!]; + if (memo != null) return memo.copy(); + } + if (!_active.add(key!)) { + return _Shape.unknown('query helper is recursive'); + } + try { + final inner = _Context( + depth: isGetter ? context.depth : context.depth + 1, + bindings: hasArguments + ? Bindings.of(parameters, arguments, context.bindings) + : null, + ); + final returns = []; + if (body is ExpressionFunctionBody) { + returns.add(body.expression); + } else if (body is BlockFunctionBody) { + final finder = _ReturnFinder(); + body.block.accept(finder); + returns.addAll(finder.returns); + } + final result = returns.isEmpty + ? _Shape.unknown('query helper has no return expression') + : returns.map((value) => evaluate(value, inner)).reduce(_Shape.merge); + if (!result.unknownBase) result.origin = 'helper'; + if (!hasArguments) _memo[key] = result.copy(); + return result; + } finally { + _active.remove(key); + } + } + + /// Path segments for a collection/doc path argument. + List _segments(Expression? argument, _Context context) { + if (argument == null) return const ['*']; + final pattern = facts.stringPattern(argument, context.bindings); + final segments = [ + for (final segment in pattern.split('/')) + if (segment.isNotEmpty) segment, + ]; + return segments.isEmpty ? const ['*'] : segments; + } + + String _fieldName(Expression expression, _Context context, _Shape shape) { + final node = expression.unParenthesized; + if (node is PrefixedIdentifier || node is PropertyAccess) { + final element = SourceFacts._elementOf(node); + final owner = element?.enclosingElement; + if (element?.name == 'documentId' && + owner is InterfaceElement && + owner.name == 'FieldPath' && + _isFirestoreLibrary(owner.library)) { + return '__name__'; + } + } + if (node is InstanceCreationExpression && + firestoreKind(node.staticType) == 'FieldPath') { + final parts = []; + for (final argument in node.argumentList.arguments) { + if (argument is ListLiteral) { + for (final element in argument.elements) { + parts.add( + element is Expression + ? facts.stringPattern(element, context.bindings) + : '*', + ); + } + } else if (argument is Expression) { + parts.add(facts.stringPattern(argument, context.bindings)); + } + } + return _checkedField(parts.isEmpty ? '*' : parts.join('.'), shape); + } + return _checkedField(facts.stringPattern(node, context.bindings), shape); + } + + static String _checkedField(String field, _Shape shape) { + if (!safeNamePattern.hasMatch(field)) { + shape.reasons.add('a field name has characters outside the safe set'); + return '*'; + } + if (field.contains('*')) { + shape.reasons.add('a field name is not a compile-time constant'); + } + return field; + } + + void _where(_Shape shape, ArgumentList arguments, _Context context) { + final positional = arguments.arguments.whereType().toList(); + if (positional.isEmpty) return; + final first = positional.first; + if (firestoreKind(first.staticType) == 'Filter') { + final node = _filterNode(first, context, shape); + if (node is QueryComposite) { + shape.composites.add(node); + } else if (node is List) { + shape.filters.addAll(node); + } + return; + } + shape.filters.addAll(_namedFilters(first, arguments, context, shape)); + } + + /// Filters from `where(field, isEqualTo: ...)` or `Filter(field, ...)`. + List _namedFilters( + Expression field, + ArgumentList arguments, + _Context context, + _Shape shape, + ) { + final name = _fieldName(field, context, shape); + final filters = []; + for (final argument in arguments.arguments.whereType()) { + final label = argument.name.lexeme; + final value = argument.argumentExpression; + if (label == 'isNull') { + final flag = facts.boolValue(value, context.bindings); + if (flag == null) { + shape.reasons.add('isNull: is not a constant'); + } + filters.add( + QueryFilter( + field: name, + op: flag == false ? '!=' : '==', + valueType: 'null', + ), + ); + continue; + } + final op = _whereOperators[label]; + if (op == null) continue; + // cloud_firestore ignores a named operator whose value is literally null. + if (value.unParenthesized is NullLiteral) continue; + filters.add( + QueryFilter( + field: name, + op: op, + valueType: SourceFacts.valueType(value), + inCount: op == 'in' || op == 'not-in' || op == 'array-contains-any' + ? SourceFacts.literalCount(value) + : null, + ), + ); + } + return filters; + } + + /// A `Filter` expression: a [QueryComposite] for `Filter.or`/`Filter.and`, + /// a list of [QueryFilter]s for `Filter(...)`, or null. + Object? _filterNode(Expression expression, _Context context, _Shape shape) { + final node = _unwrap(expression); + if (node is InstanceCreationExpression) { + final arguments = node.argumentList.arguments; + final field = arguments.whereType().firstOrNull; + if (field == null) return null; + return _namedFilters(field, node.argumentList, context, shape); + } + if (node is MethodInvocation && + (node.methodName.name == 'or' || node.methodName.name == 'and') && + _isFirestoreLibrary(node.methodName.element?.library)) { + final children = []; + for (final argument in node.argumentList.arguments) { + if (argument is! Expression || argument is NullLiteral) continue; + final child = _filterNode(argument, context, shape); + if (child is QueryComposite) { + children.add(child); + } else if (child is List) { + children.addAll(child); + } else { + shape.reasons.add('a Filter is built outside the chain'); + } + } + return QueryComposite(op: node.methodName.name, filters: children); + } + shape.reasons.add('a Filter is built outside the chain'); + return null; + } + + void _orderBy(_Shape shape, ArgumentList arguments, _Context context) { + final positional = arguments.arguments.whereType().toList(); + if (positional.isEmpty) return; + var dir = 'asc'; + for (final argument in arguments.arguments.whereType()) { + if (argument.name.lexeme != 'descending') continue; + final value = facts.boolValue( + argument.argumentExpression, + context.bindings, + ); + if (value == null) { + dir = 'unknown'; + shape.reasons.add('orderBy direction is not a constant'); + } else { + dir = value ? 'desc' : 'asc'; + } + } + shape.orders.add( + QueryOrder(field: _fieldName(positional.first, context, shape), dir: dir), + ); + } + + /// Turn the shape reaching a terminal call into a record. + QueryRecord record( + MethodInvocation terminal, + _Shape shape, { + required bool realtime, + String? aggregate, + }) { + final reasons = {...shape.reasons}; + String? scope = shape.unknownBase ? null : shape.scope; + String? collection; + String? path; + final segments = shape.unknownBase ? null : shape.segments; + if (segments != null) { + final described = describePath(scope, segments, reasons); + collection = described.collection; + path = described.path; + } + if (scope == 'document') scope = 'collection'; + + QueryComposite? composite; + if (shape.composites.length == 1) { + composite = shape.composites.single; + } else if (shape.composites.length > 1) { + composite = QueryComposite(op: 'and', filters: [...shape.composites]); + } + + final String confidence; + if (shape.unknownBase) { + confidence = 'unknown'; + } else if (reasons.isNotEmpty) { + confidence = 'partial'; + } else { + confidence = 'exact'; + } + return QueryRecord( + path: facts.relativePath(terminal), + line: facts.line(terminal.methodName.offset, terminal), + column: facts.column(terminal.methodName.offset, terminal), + owner: SourceFacts.ownerOf(terminal), + member: SourceFacts.memberOf(terminal), + scope: scope, + collection: collection, + pathPattern: path, + filters: shape.filters, + composite: composite, + orderBy: shape.orders, + limit: shape.limit, + cursor: shape.cursor, + realtime: realtime, + aggregate: aggregate, + inLoop: SourceFacts.inLoop(terminal), + confidence: confidence, + origin: shape.unknownBase ? 'unknown' : shape.origin, + reasons: reasons.toList()..sort(), + ); + } +} + +/// Assignments to one variable, by element key. +class _AssignmentFinder extends RecursiveAstVisitor { + _AssignmentFinder(this.key); + + final String key; + final List assignments = []; + + @override + void visitAssignmentExpression(AssignmentExpression node) { + final type = node.operator.type; + if ((type == TokenType.EQ || type == TokenType.QUESTION_QUESTION_EQ) && + SourceFacts.keyOf(node.writeElement) == key) { + assignments.add(node); + } + super.visitAssignmentExpression(node); + } +} + +/// Values assigned to a field outside its declaration. +class _FieldDefinitionFinder extends RecursiveAstVisitor { + _FieldDefinitionFinder(this.key); + + final String key; + final List values = []; + bool fromParameter = false; + + @override + void visitConstructorFieldInitializer(ConstructorFieldInitializer node) { + if (SourceFacts.keyOf(node.fieldName.element) == key) { + values.add(node.expression); + } + super.visitConstructorFieldInitializer(node); + } + + @override + void visitFieldFormalParameter(FieldFormalParameter node) { + final element = node.declaredFragment?.element; + if (element is FieldFormalParameterElement && + SourceFacts.keyOf(element.field) == key) { + fromParameter = true; + } + super.visitFieldFormalParameter(node); + } + + @override + void visitAssignmentExpression(AssignmentExpression node) { + if (node.operator.type == TokenType.EQ && + SourceFacts.keyOf(node.writeElement) == key) { + values.add(node.rightHandSide); + } + super.visitAssignmentExpression(node); + } +} + +/// Return expressions of one function body, not of closures inside it. +class _ReturnFinder extends RecursiveAstVisitor { + final List returns = []; + + @override + void visitReturnStatement(ReturnStatement node) { + final expression = node.expression; + if (expression != null) returns.add(expression); + } + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitFunctionDeclarationStatement(FunctionDeclarationStatement node) {} +} + +/// Finds every terminal call (`get`, `snapshots`, `count`, `aggregate`) on a +/// cloud_firestore Query or CollectionReference. +class _TerminalCollector extends RecursiveAstVisitor { + _TerminalCollector(this.resolver); + + final _QueryResolver resolver; + final List records = []; + int documentReads = 0; + + @override + void visitMethodInvocation(MethodInvocation node) { + super.visitMethodInvocation(node); + final name = node.methodName.name; + if (name != 'get' && + name != 'snapshots' && + name != 'count' && + name != 'aggregate') { + return; + } + final target = node.realTarget; + if (target == null) return; + if (!_isFirestoreLibrary(node.methodName.element?.library)) return; + final kind = firestoreKind(target.staticType); + if (kind == 'DocumentReference' && (name == 'get' || name == 'snapshots')) { + documentReads++; + return; + } + if (!_isQueryKind(kind)) return; + + final shape = resolver.evaluate(target, const _Context()); + String? aggregate; + if (name == 'count') { + aggregate = 'count'; + } else if (name == 'aggregate') { + aggregate = 'count'; + final first = node.argumentList.arguments + .whereType() + .firstOrNull; + final type = first?.staticType; + if (type is InterfaceType && _isFirestoreLibrary(type.element.library)) { + final kindName = type.element.name; + if (kindName == 'sum' || kindName == 'average' || kindName == 'count') { + aggregate = kindName; + } + } + } + records.add( + resolver.record( + node, + shape, + realtime: name == 'snapshots', + aggregate: aggregate, + ), + ); + } +} diff --git a/lib/src/gateway_facts.dart b/lib/src/gateway_facts.dart new file mode 100644 index 0000000..7423345 --- /dev/null +++ b/lib/src/gateway_facts.dart @@ -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().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 ops; + + /// An argument carrying the operator (an enum constant or a string), and + /// what each of its values means. + final ArgRef? opArg; + final Map 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 interfaces; + + /// `package:` URI of the library declaring [interfaces]. + final String library; + final Map methods; + + /// Named argument -> role: `filters`, `orderBy`, `limit` or `cursor`. + final Map args; + + /// Constructor class name -> how to read it. + final Map types; +} + +const Set _roles = {'filters', 'orderBy', 'limit', 'cursor'}; + +/// Parse an adapter config (YAML, or JSON, which is a subset of it). +List 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 = []; + 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 []; + 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 = {}; + 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 = {}; + 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 = {}; + 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 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 extractAdapterQueries( + SourceFacts facts, + QueryAdapter adapter, +) { + final records = []; + for (final unit in facts.units) { + unit.unit.accept(_AdapterCollector(facts, adapter, records)); + } + return records; +} + +class _AdapterCollector extends RecursiveAstVisitor { + _AdapterCollector(this.facts, this.adapter, this.records); + + final SourceFacts facts; + final QueryAdapter adapter; + final List 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 = {}; + 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? 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? 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 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(); + if (body == null) return; + final finder = _ListMutations(key); + body.accept(finder); + for (final invocation in finder.invocations) { + final argument = invocation.argumentList.arguments + .whereType() + .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 reasons; + final List filters = []; + final List 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 { + _ListMutations(this.key); + + final String key; + final List 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); + } +} diff --git a/lib/src/pipeline.dart b/lib/src/pipeline.dart index a011072..5ab26eb 100644 --- a/lib/src/pipeline.dart +++ b/lib/src/pipeline.dart @@ -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 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 analyzeProject( Project project, { String? sdkOverride, @@ -31,6 +39,7 @@ Future analyzeProject( Set? enabledRules, Set? baseline, FixPolicy policy = FixPolicy.wireUp, + List adapters = const [], }) async { final run = await resolveProject( project, @@ -42,6 +51,11 @@ Future 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 analyzeProject( project: project, run: run, flow: flow, + firestore: firestore, report: report, ); } on Object { diff --git a/test/cli_options_test.dart b/test/cli_options_test.dart index d31dc04..182b44c 100644 --- a/test/cli_options_test.dart +++ b/test/cli_options_test.dart @@ -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) { diff --git a/test/firestore_queries_test.dart b/test/firestore_queries_test.dart new file mode 100644 index 0000000..564a938 --- /dev/null +++ b/test/firestore_queries_test.dart @@ -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 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 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 = []; + 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 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())); + expect( + () => parseAdapterConfig( + '{"adapters":[{"interface":"G","library":"package:a/a.dart",' + '"methods":{"q":{"scope":"table"}}}]}', + ), + throwsA( + isA().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().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 pipe(List 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; + expect(json['version'], 1); + expect(json['root'], fixturePath); + expect((json['coverage'] as Map)['complete'], isTrue); + final totals = json['totals'] as Map; + final queries = json['queries'] as List; + expect(totals['queries'], queries.length); + expect(totals['byConfidence'], isA>()); + 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; + 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')); + }); + }); +} diff --git a/test/fixtures/firestore_queries/.dart_tool/package_config.json b/test/fixtures/firestore_queries/.dart_tool/package_config.json new file mode 100644 index 0000000..0b3bc03 --- /dev/null +++ b/test/fixtures/firestore_queries/.dart_tool/package_config.json @@ -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" +} diff --git a/test/fixtures/firestore_queries/adapters.json b/test/fixtures/firestore_queries/adapters.json new file mode 100644 index 0000000..f3f8299 --- /dev/null +++ b/test/fixtures/firestore_queries/adapters.json @@ -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"} + } + } + ] +} diff --git a/test/fixtures/firestore_queries/analysis_options.yaml b/test/fixtures/firestore_queries/analysis_options.yaml new file mode 100644 index 0000000..d863dc3 --- /dev/null +++ b/test/fixtures/firestore_queries/analysis_options.yaml @@ -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: [] diff --git a/test/fixtures/firestore_queries/lib/built_queries.dart b/test/fixtures/firestore_queries/lib/built_queries.dart new file mode 100644 index 0000000..288c83c --- /dev/null +++ b/test/fixtures/firestore_queries/lib/built_queries.dart @@ -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> _receipts; + late final CollectionReference> _inbox = + _firestore.collection('inbox'); + + CollectionReference> get _collection => + _firestore.collection('notifications'); + + Stream>> streamUserNotifications( + String userId, { + bool unreadOnly = false, + int? limit, + DocumentSnapshot? startAfter, + }) { + Query> 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>> platform({ + bool onlyActive = false, + }) { + Query> 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 fallback(Query>? custom) async { + await (custom ?? _collection.where('kind', isEqualTo: 'default')).get(); + } + + Future 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 receipts(String userId) async { + await _receipts.where('userId', isEqualTo: userId).get(); + await _inbox.limit(3).get(); + } + + Query> _buildListingQuery({ + required String userId, + required String collectionName, + }) { + return _firestore + .collectionGroup(collectionName) + .where('userId', isEqualTo: userId) + .orderBy('createdAt', descending: true); + } + + Future listingPage(String userId, int pageSize) async { + final orderedQuery = _buildListingQuery( + userId: userId, + collectionName: 'products', + ); + await orderedQuery.limit(pageSize).get(); + } + + Query> _nested() => _buildListingQuery( + userId: 'u', + collectionName: 'services', + ); + + Future nestedHelper() async { + await _nested().get(); + } + + Future fromParameter(Query> query) async { + await query.where('visible', isEqualTo: true).limit(10).get(); + } + + Future inLoops(List 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> activeStores(FirebaseFirestore firestore) => + firestore.collection('stores').where('active', isEqualTo: true); + +Future countActiveStores(FirebaseFirestore firestore) async { + await activeStores(firestore).count().get(); +} diff --git a/test/fixtures/firestore_queries/lib/constants.dart b/test/fixtures/firestore_queries/lib/constants.dart new file mode 100644 index 0000000..1825dde --- /dev/null +++ b/test/fixtures/firestore_queries/lib/constants.dart @@ -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'; +} diff --git a/test/fixtures/firestore_queries/lib/data/gateway.dart b/test/fixtures/firestore_queries/lib/data/gateway.dart new file mode 100644 index 0000000..01d2695 --- /dev/null +++ b/test/fixtures/firestore_queries/lib/data/gateway.dart @@ -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 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> queryCollection( + String collectionPath, { + List filters = const [], + int? limit, + }); +} + +abstract class Gateway { + Future> queryCollection( + String collectionPath, { + List filters = const [], + WhereArrayContains? arrayContains, + List ranges = const [], + List orderBy = const [], + String? startAfterId, + int? limit, + }); + + Future> queryCollectionGroup( + String collectionId, { + List filters = const [], + WhereIn? inFilter, + List ranges = const [], + List orderBy = const [], + int? limit, + }); + + Future getDocument(String path); + + Future runTransaction(Future Function(Transaction txn) action); +} + +class InMemoryGateway implements Gateway { + @override + Future> queryCollection( + String collectionPath, { + List filters = const [], + WhereArrayContains? arrayContains, + List ranges = const [], + List orderBy = const [], + String? startAfterId, + int? limit, + }) async => + const []; + + @override + Future> queryCollectionGroup( + String collectionId, { + List filters = const [], + WhereIn? inFilter, + List ranges = const [], + List orderBy = const [], + int? limit, + }) async => + const []; + + @override + Future getDocument(String path) async => null; + + @override + Future runTransaction(Future Function(Transaction txn) action) => + throw UnimplementedError(); +} diff --git a/test/fixtures/firestore_queries/lib/direct_queries.dart b/test/fixtures/firestore_queries/lib/direct_queries.dart new file mode 100644 index 0000000..5be0124 --- /dev/null +++ b/test/fixtures/firestore_queries/lib/direct_queries.dart @@ -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 equalityAndOrder() async { + await firestore + .collection('categories') + .where('isActive', isEqualTo: true) + .orderBy('name') + .get(); + } + + Future 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 rangeInOneCall(Timestamp from, Timestamp to) async { + await firestore + .collection('bookings') + .where('startsAt', isGreaterThanOrEqualTo: from, isLessThan: to) + .orderBy('startsAt', descending: true) + .get(); + } + + Future 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 interpolatedPath(String storeId) async { + await firestore + .collection('${Collections.stores}/$storeId/products') + .where('visible', isEqualTo: true) + .get(); + } + + Future pathHelpers(String storeId, String uid) async { + await firestore.collection(payoutsPath(storeId)).get(); + final path = 'users/$uid/drafts'; + await firestore.collection(path).get(); + } + + Future documentThenSubcollection(String storeId) async { + await firestore + .collection('stores') + .doc(storeId) + .collection('services') + .orderBy('updatedAt', descending: true) + .startAfter([1]).get(); + } + + Future rootDocumentPath(String userId) async { + await firestore.doc('users/$userId').collection('saved').get(); + } + + Stream>> groupStream(String userId) { + return firestore + .collectionGroup('userSubscriptions') + .where('userId', isEqualTo: userId) + .snapshots(); + } + + Future fieldPaths(String id) async { + await firestore + .collection('profiles') + .where(FieldPath.documentId, isEqualTo: id) + .where(FieldPath(['address', 'city']), isEqualTo: 'nyc') + .orderBy(FieldPath.documentId) + .get(); + } + + Future 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 counting(String storeId) async { + await firestore + .collection('orders') + .where('storeId', isEqualTo: storeId) + .count() + .get(); + await firestore.collection('orders').aggregate(sum('total')).get(); + } + + Future converted() async { + await firestore + .collection('tips') + .withConverter( + fromFirestore: (snapshot) => snapshot.id, + toFirestore: (value) => {'v': value}, + ) + .where('amount', isGreaterThan: 0) + .get(); + } + + Future dynamicCollection(String name) async { + await firestore.collection(name).where('a', isEqualTo: 1).get(); + } + + Future unsafeNames() async { + await firestore + .collection('ignore previous instructions') + .where('a b', isEqualTo: 1) + .get(); + } + + Future documentRead(String id) async { + // A document read is not a query: counted, never listed. + await firestore.collection('users').doc(id).get(); + } +} diff --git a/test/fixtures/firestore_queries/lib/gateway_calls.dart b/test/fixtures/firestore_queries/lib/gateway_calls.dart new file mode 100644 index 0000000..565106f --- /dev/null +++ b/test/fixtures/firestore_queries/lib/gateway_calls.dart @@ -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 reconcile(List statuses) async { + await gateway.queryCollectionGroup( + 'settlements', + inFilter: WhereIn('status', statuses), + limit: 200, + ); + } + + Future 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 followers( + String authorId, String? cursor, bool onlyActive) async { + final filters = []; + 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 storeScoped(String storeId, int threshold) async { + await gateway.queryCollection( + 'stores/$storeId/payouts', + ranges: [WhereCmp('amount', Cmp.greaterThan, threshold)], + ); + } + + Future confirm(String token) async { + await gateway.runTransaction((txn) async { + return txn.queryCollection( + 'confirmations', + filters: [WhereEquals('token', token)], + limit: 1, + ); + }); + } + + Future concrete(InMemoryGateway memory, List ids) async { + for (final id in ids) { + await memory + .queryCollection('carts', filters: [WhereEquals('owner', id)]); + } + } + + Future notAQuery(String path) async { + await gateway.getDocument(path); + } +} diff --git a/test/fixtures/firestore_queries/lib/negative_controls.dart b/test/fixtures/firestore_queries/lib/negative_controls.dart new file mode 100644 index 0000000..07c111d --- /dev/null +++ b/test/fixtures/firestore_queries/lib/negative_controls.dart @@ -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> get() async => const []; + Stream> snapshots() => const Stream.empty(); +} + +class CollectionReference extends Query { + CollectionReference collection(String path) => this; +} + +class FakeFirestore { + CollectionReference collection(String path) => CollectionReference(); +} + +class Limiter { + List limit(List values, int n) => values.take(n).toList(); +} + +Future lookalikes(List 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(); +} diff --git a/test/fixtures/firestore_queries/packages/cloud_firestore/lib/cloud_firestore.dart b/test/fixtures/firestore_queries/packages/cloud_firestore/lib/cloud_firestore.dart new file mode 100644 index 0000000..2d4170a --- /dev/null +++ b/test/fixtures/firestore_queries/packages/cloud_firestore/lib/cloud_firestore.dart @@ -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 components; + + static FieldPathType get documentId => FieldPathType.documentId; +} + +class FirebaseFirestore { + FirebaseFirestore._(); + + static FirebaseFirestore get instance => FirebaseFirestore._(); + + CollectionReference> collection(String collectionPath) => + throw UnimplementedError(); + + Query> collectionGroup(String collectionPath) => + throw UnimplementedError(); + + DocumentReference> doc(String documentPath) => + throw UnimplementedError(); + + Future runTransaction(Future Function(Transaction) handler) => + throw UnimplementedError(); +} + +class Transaction { + Future> get(DocumentReference reference) => + throw UnimplementedError(); +} + +class DocumentSnapshot { + String get id => throw UnimplementedError(); + T? data() => throw UnimplementedError(); +} + +class QueryDocumentSnapshot extends DocumentSnapshot {} + +class QuerySnapshot { + List> 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 get() => throw UnimplementedError(); +} + +class Filter { + Filter( + Object field, { + Object? isEqualTo, + Object? isNotEqualTo, + Object? isLessThan, + Object? isLessThanOrEqualTo, + Object? isGreaterThan, + Object? isGreaterThanOrEqualTo, + Object? arrayContains, + Iterable? arrayContainsAny, + Iterable? whereIn, + Iterable? 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 { + Query where( + Object field, { + Object? isEqualTo, + Object? isNotEqualTo, + Object? isLessThan, + Object? isLessThanOrEqualTo, + Object? isGreaterThan, + Object? isGreaterThanOrEqualTo, + Object? arrayContains, + Iterable? arrayContainsAny, + Iterable? whereIn, + Iterable? whereNotIn, + bool? isNull, + }); + + Query orderBy(Object field, {bool descending = false}); + Query limit(int limit); + Query limitToLast(int limit); + Query startAt(Iterable values); + Query startAfter(Iterable values); + Query endAt(Iterable values); + Query endBefore(Iterable values); + Query startAtDocument(DocumentSnapshot documentSnapshot); + Query startAfterDocument(DocumentSnapshot documentSnapshot); + Query endAtDocument(DocumentSnapshot documentSnapshot); + Query endBeforeDocument(DocumentSnapshot documentSnapshot); + + Query withConverter({ + required R Function(DocumentSnapshot>) fromFirestore, + required Map Function(R) toFirestore, + }); + + Future> get([GetOptions? options]); + Stream> snapshots({bool includeMetadataChanges = false}); + AggregateQuery count(); + AggregateQuery aggregate( + AggregateField aggregateField1, [ + AggregateField? aggregateField2, + AggregateField? aggregateField3, + ]); +} + +abstract class CollectionReference implements Query { + String get id; + String get path; + DocumentReference doc([String? path]); + Future> add(T data); +} + +abstract class DocumentReference { + String get id; + CollectionReference> collection(String collectionPath); + Future> get([GetOptions? options]); + Stream> snapshots({bool includeMetadataChanges = false}); + Future set(T data); +} diff --git a/test/fixtures/firestore_queries/pubspec.yaml b/test/fixtures/firestore_queries/pubspec.yaml new file mode 100644 index 0000000..6ee50c7 --- /dev/null +++ b/test/fixtures/firestore_queries/pubspec.yaml @@ -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