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, ), ); } }