pipe/test/firestore_queries_test.dart
trvon 31607b5bcb
Some checks failed
Lint Commits / commitlint (pull_request) Successful in 16s
Tests / Static Analysis (pull_request) Has been cancelled
Tests / Tests (pull_request) Has been cancelled
fix(queries): resolve a subdirectory path to its enclosing package
Found by dogfooding on nuwayze: `pipe queries lib` from the repo root
failed with

  no resolved package graph at .dart_tool/package_config.json
    remedy: run `flutter pub get` or `dart pub get` in .../nuwayze-w3/lib

It failed even with the package resolved, because the path was taken
as the project root: pipe looked for lib/.dart_tool/package_config.json,
which never exists, and the remedy sent the user to run pub get in a
directory that has no pubspec.

enclosingPackage() (project.dart) splits a path into the package that
contains it (the path itself when it holds a pubspec.yaml, else the
nearest ancestor that does) and the directory it names under that
root. `pipe queries` loads the package root and scans only that
directory, so `pipe queries lib/services` is `--dir lib/services` on
the package; an explicit --dir with such a path is relative to it.
Without a resolved graph the error now names the package root.

loadProject's own remedy uses the same lookup, so `pipe check lib`
(and the other commands, which still take the path as the root) now
says the path is inside the package at <root> and suggests passing
that with `--dir lib`, instead of pub get in lib.

Tests: enclosingPackage on a root, a nested directory, a nested
package and a missing path; loadProject's remedy for a directory
inside a package; and `pipe queries` on the fixture's lib/data
(root reported as the package, one file discovered), with
`lib --dir data`, and on an unresolved package where the remedy names
the root, not lib.

On nuwayze-w3: `pipe queries lib` exits 0 with 313 queries over 847
files, identical to `pipe queries .`; `pipe queries lib/services` scans
161 files and every one of its 212 queries sits under lib/services.
233 tests pass.
2026-09-25 20:16:23 -06:00

662 lines
23 KiB
Dart

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