Add `pipe queries`, which lists every Firestore query a project can issue: scope, collection, path pattern, filters with operators and value types, composites, orderBy, limit, cursors, realtime, aggregation, in-loop, plus a confidence and the reasons it is not exact. Receivers are matched by static type and cloud_firestore library URI, never by name. Queries are followed through chains, local variables (flow-insensitive with two cuts), fields, getters and one level of helper methods with arguments bound. An --adapter-config (YAML or JSON) maps declarative query APIs such as a server-side gateway onto the same schema. Inventory only: no rules, findings or baseline changes. Every emitted name is validated against a safe character set so no source text reaches the output.
2032 lines
66 KiB
Dart
2032 lines
66 KiB
Dart
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 `<top>`), so they
|
|
/// are safe by construction; this pattern pins that down.
|
|
final RegExp _safeIdentifier = RegExp(r'^(<top>|[A-Za-z0-9_$]+)$');
|
|
|
|
/// Operators in the inventory, spelled the way Firestore's own index and
|
|
/// console tooling spells them.
|
|
const Set<String> queryOperators = {
|
|
'==',
|
|
'!=',
|
|
'<',
|
|
'<=',
|
|
'>',
|
|
'>=',
|
|
'array-contains',
|
|
'array-contains-any',
|
|
'in',
|
|
'not-in',
|
|
};
|
|
|
|
/// `Query.where` / `Filter` named arguments and the operator each one means.
|
|
const Map<String, String> _whereOperators = {
|
|
'isEqualTo': '==',
|
|
'isNotEqualTo': '!=',
|
|
'isLessThan': '<',
|
|
'isLessThanOrEqualTo': '<=',
|
|
'isGreaterThan': '>',
|
|
'isGreaterThanOrEqualTo': '>=',
|
|
'arrayContains': 'array-contains',
|
|
'arrayContainsAny': 'array-contains-any',
|
|
'whereIn': 'in',
|
|
'whereNotIn': 'not-in',
|
|
};
|
|
|
|
const Set<String> _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<String, Object?> 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<Object> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<QueryFilter> filters;
|
|
final QueryComposite? composite;
|
|
final List<QueryOrder> 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<String> reasons;
|
|
|
|
String get site => '$path:$line';
|
|
|
|
Map<String, Object?> 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<String, Object?> 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<QueryRecord> 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<UnresolvedFirestoreUri> 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<String> get notes {
|
|
final notes = <String>[];
|
|
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<String, Object?> totals() {
|
|
final byConfidence = {'exact': 0, 'partial': 0, 'unknown': 0};
|
|
final byScope = <String, int>{};
|
|
final byOrigin = <String, int>{};
|
|
final collections = <String, int>{};
|
|
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<String, int> _sorted(Map<String, int> 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<FileOutcome> outcomes, {
|
|
List<QueryAdapter> 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 = <QueryRecord>[];
|
|
var documentReads = 0;
|
|
var resolvedImports = 0;
|
|
final unresolved = <UnresolvedFirestoreUri>[];
|
|
|
|
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<String> segments,
|
|
Set<String> reasons,
|
|
) {
|
|
final safe = <String>[];
|
|
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 = <String>{};
|
|
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<ResolvedUnitResult> units;
|
|
|
|
/// Variable, parameter, method and function declarations by element key.
|
|
final Map<String, AstNode> 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 '<unknown>';
|
|
}
|
|
|
|
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 `<top>`.
|
|
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 : '<top>';
|
|
}
|
|
return '<top>';
|
|
}
|
|
|
|
/// 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 : '<top>';
|
|
}
|
|
return '<top>';
|
|
}
|
|
|
|
/// 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, <String>{}));
|
|
|
|
static String _collapse(String pattern) =>
|
|
pattern.replaceAll(RegExp(r'\*+'), '*');
|
|
|
|
String _string(Expression expression, Bindings? bindings, Set<String> 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<String> 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<String, BoundArgument> 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 = <String, BoundArgument>{};
|
|
if (parameters != null) {
|
|
final positional = arguments.arguments.whereType<Expression>().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<void> {
|
|
_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<String>? segments;
|
|
String origin;
|
|
bool unknownBase;
|
|
bool selfBase;
|
|
final List<QueryFilter> filters = [];
|
|
final List<QueryComposite> composites = [];
|
|
final List<QueryOrder> orders = [];
|
|
QueryLimit limit = QueryLimit.none;
|
|
bool cursor = false;
|
|
final Set<String> 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<T>(
|
|
List<T> into,
|
|
List<T> 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<QueryFilter>(
|
|
filters,
|
|
delta.filters,
|
|
(f) => f.identity,
|
|
(f) => f.conditional,
|
|
(f, c) => f.withConditional(c),
|
|
);
|
|
add<QueryComposite>(
|
|
composites,
|
|
delta.composites,
|
|
(c) => c.identity,
|
|
(c) => c.conditional,
|
|
(c, v) => c.withConditional(v),
|
|
);
|
|
add<QueryOrder>(
|
|
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<T> union<T>(
|
|
List<T> left,
|
|
List<T> 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<QueryFilter>(
|
|
a.filters,
|
|
b.filters,
|
|
(f) => f.identity,
|
|
(f) => f.conditional,
|
|
(f, c) => f.withConditional(c),
|
|
),
|
|
);
|
|
result.composites
|
|
..clear()
|
|
..addAll(
|
|
union<QueryComposite>(
|
|
a.composites,
|
|
b.composites,
|
|
(c) => c.identity,
|
|
(c) => c.conditional,
|
|
(c, v) => c.withConditional(v),
|
|
),
|
|
);
|
|
result.orders
|
|
..clear()
|
|
..addAll(
|
|
union<QueryOrder>(
|
|
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<String>? a, List<String>? 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<String, _Shape> _memo = {};
|
|
final Set<String> _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<Expression>().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<FunctionBody>() ??
|
|
_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 = <AstNode>{};
|
|
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<FormalParameterList>();
|
|
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<CompilationUnitMember>();
|
|
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 = <Expression>[];
|
|
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<String> _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 = <String>[];
|
|
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<Expression>().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<QueryFilter>) {
|
|
shape.filters.addAll(node);
|
|
}
|
|
return;
|
|
}
|
|
shape.filters.addAll(_namedFilters(first, arguments, context, shape));
|
|
}
|
|
|
|
/// Filters from `where(field, isEqualTo: ...)` or `Filter(field, ...)`.
|
|
List<QueryFilter> _namedFilters(
|
|
Expression field,
|
|
ArgumentList arguments,
|
|
_Context context,
|
|
_Shape shape,
|
|
) {
|
|
final name = _fieldName(field, context, shape);
|
|
final filters = <QueryFilter>[];
|
|
for (final argument in arguments.arguments.whereType<NamedArgument>()) {
|
|
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<Expression>().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 = <Object>[];
|
|
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<QueryFilter>) {
|
|
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<Expression>().toList();
|
|
if (positional.isEmpty) return;
|
|
var dir = 'asc';
|
|
for (final argument in arguments.arguments.whereType<NamedArgument>()) {
|
|
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<void> {
|
|
_AssignmentFinder(this.key);
|
|
|
|
final String key;
|
|
final List<AssignmentExpression> 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<void> {
|
|
_FieldDefinitionFinder(this.key);
|
|
|
|
final String key;
|
|
final List<Expression> 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<void> {
|
|
final List<Expression> 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<void> {
|
|
_TerminalCollector(this.resolver);
|
|
|
|
final _QueryResolver resolver;
|
|
final List<QueryRecord> 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<Expression>()
|
|
.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,
|
|
),
|
|
);
|
|
}
|
|
}
|