diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7c3bd4..1e4c83a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: run: dart analyze - name: Run tests - run: firebase emulators:exec --project dart-firebase-admin --only auth,firestore "dart test" + run: ${{github.workspace}}/scripts/coverage.sh - name: Upload coverage to codecov run: curl -s https://codecov.io/bash | bash diff --git a/packages/dart_firebase_admin/CHANGELOG.md b/packages/dart_firebase_admin/CHANGELOG.md index 8e18474..c96d2ca 100644 --- a/packages/dart_firebase_admin/CHANGELOG.md +++ b/packages/dart_firebase_admin/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased minor +- Added `firestore.listCollections()` and `doc.listCollections()` - Fixes some errors incorrectly coming back as "unknown". - `Apns` parameters are no-longer required - Fixes argument error in FMC when sending booleans diff --git a/packages/dart_firebase_admin/README.md b/packages/dart_firebase_admin/README.md index 0e7dde1..6e0e3cc 100644 --- a/packages/dart_firebase_admin/README.md +++ b/packages/dart_firebase_admin/README.md @@ -163,7 +163,9 @@ print(user.data()?['age']); | Firestore | | | ------------------------------------------------ | --- | +| firestore.listCollections() | ✅ | | reference.id | ✅ | +| reference.listCollections() | ✅ | | reference.parent | ✅ | | reference.path | ✅ | | reference.== | ✅ | diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart index 431429a..f2d810e 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart @@ -51,11 +51,32 @@ class Firestore { // TODO batch // TODO bulkWriter // TODO bundle - // TODO listCollections // TODO getAll // TODO runTransaction // TODO recursiveDelete + /// Fetches the root collections that are associated with this Firestore + /// database. + /// + /// Returns a Promise that resolves with an array of CollectionReferences. + /// + /// ```dart + /// firestore.listCollections().then((collections) { + /// for (final collection in collections) { + /// print('Found collection with id: ${collection.id}'); + /// } + /// }); + /// ``` + Future>> listCollections() { + final rootDocument = DocumentReference._( + firestore: this, + path: _ResourcePath.empty, + converter: _jsonConverter, + ); + + return rootDocument.listCollections(); + } + /// Gets a [DocumentReference] instance that /// refers to the document at the specified path. /// diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart index 181f220..5d08fc0 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart @@ -33,7 +33,7 @@ abstract class _Path> implements Comparable<_Path> { List _split(String relativePath); /// Returns the path of the parent node. - T? _parent() { + T? parent() { if (segments.isEmpty) return null; return _construct(segments.sublist(0, segments.length - 1)); @@ -83,7 +83,6 @@ abstract class _Path> implements Comparable<_Path> { @override bool operator ==(Object other) { return other is _Path && - runtimeType == other.runtimeType && const ListEquality().equals(segments, other.segments); } @@ -179,8 +178,7 @@ class _QualifiedResourcePath extends _ResourcePath { final String _databaseId; @override - _QualifiedResourcePath? _parent() => - super._parent() as _QualifiedResourcePath?; + _QualifiedResourcePath? parent() => super.parent() as _QualifiedResourcePath?; /// String representation of a ResourcePath as expected by the API. String get _formattedName { diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart index 1a9ce7c..0e2ef21 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -3,14 +3,13 @@ part of 'firestore.dart'; final class CollectionReference extends Query { CollectionReference._({ required super.firestore, - required _QualifiedResourcePath path, + required _ResourcePath path, required _FirestoreDataConverter converter, }) : super._( queryOptions: _QueryOptions.forCollectionQuery(path, converter), ); - _QualifiedResourcePath get _resourcePath => - _queryOptions.parentPath._append(id) as _QualifiedResourcePath; + _ResourcePath get _resourcePath => _queryOptions.parentPath._append(id); /// The last path element of the referenced collection. String get id => _queryOptions.collectionId; @@ -28,7 +27,7 @@ final class CollectionReference extends Query { return DocumentReference._( firestore: firestore, - path: _queryOptions.parentPath as _QualifiedResourcePath, + path: _queryOptions.parentPath, converter: _jsonConverter, ); } @@ -62,7 +61,7 @@ final class CollectionReference extends Query { } if (!identical(_queryOptions.converter, _jsonConverter) && - path._parent() != _resourcePath) { + path.parent() != _resourcePath) { throw ArgumentError.value( documentPath, 'documentPath', @@ -159,12 +158,12 @@ final class CollectionReference extends Query { final class DocumentReference implements _Serializable { const DocumentReference._({ required this.firestore, - required _QualifiedResourcePath path, + required _ResourcePath path, required _FirestoreDataConverter converter, }) : _converter = converter, _path = path; - final _QualifiedResourcePath _path; + final _ResourcePath _path; final _FirestoreDataConverter _converter; final Firestore firestore; @@ -187,7 +186,7 @@ final class DocumentReference implements _Serializable { CollectionReference get parent { return CollectionReference._( firestore: firestore, - path: _path._parent()!, + path: _path.parent()!, converter: _converter, ); } @@ -202,6 +201,40 @@ final class DocumentReference implements _Serializable { ._formattedName; } + /// Fetches the subcollections that are direct children of this document. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.listCollections().then((collections) { + /// for (final collection in collections) { + /// print('Found subcollection with id: ${collection.id}'); + /// } + /// }); + /// ``` + Future>> listCollections() { + return this.firestore._client.v1((a) async { + final request = firestore1.ListCollectionIdsRequest( + // Setting `pageSize` to an arbitrarily large value lets the backend cap + // the page size (currently to 300). Note that the backend rejects + // MAX_INT32 (b/146883794). + pageSize: (math.pow(2, 16) - 1).toInt(), + ); + + final result = await a.projects.databases.documents.listCollectionIds( + request, + this._formattedName, + ); + + final ids = result.collectionIds ?? []; + ids.sort((a, b) => a.compareTo(b)); + + return [ + for (final id in ids) collection(id), + ]; + }); + } + /// Changes the de/serializing mechanism for this [DocumentReference]. /// /// This changes the return value of [DocumentSnapshot.data]. @@ -416,10 +449,6 @@ class _QueryCursor { @override bool operator ==(Object other) { - // if (other is! _QueryCursor) return false; - - // print(_valuesEqual(values, other.values)); - return other is _QueryCursor && runtimeType == other.runtimeType && before == other.before && @@ -510,11 +539,11 @@ class _QueryOptions with _$QueryOptions { /// Returns query options for a single-collection query. factory _QueryOptions.forCollectionQuery( - _QualifiedResourcePath collectionRef, + _ResourcePath collectionRef, _FirestoreDataConverter converter, ) { return _QueryOptions( - parentPath: collectionRef._parent()!, + parentPath: collectionRef.parent()!, collectionId: collectionRef.id!, converter: converter, allDescendants: false, diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart index 5844e8a..bbff485 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart @@ -7,7 +7,7 @@ void main() { group('collectionGroup', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('throws if collectionId contains "/"', () { expect( diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart index 17d6462..f0ab96e 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart @@ -7,7 +7,7 @@ void main() { group('Collection interface', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('has doc() method', () { final collection = firestore.collection('colId'); diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart index bc8820a..301fd6f 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart @@ -9,10 +9,32 @@ void main() { late DocumentReference> documentRef; setUp(() { - firestore = createInstance(); + firestore = createFirestore(); documentRef = firestore.doc('collectionId/documentId'); }); + test('listCollections', () async { + final doc1 = firestore.doc('collectionId/a'); + final doc2 = firestore.doc('collectionId/b'); + + final doc1col1 = doc1.collection('a'); + final doc1col2 = doc1.collection('b'); + + final doc2col1 = doc2.collection('c'); + final doc2col2 = doc2.collection('d'); + + await doc1col1.add({}); + await doc1col2.add({}); + await doc2col1.add({}); + await doc2col2.add({}); + + final doc1Collections = await doc1.listCollections(); + final doc2Collections = await doc2.listCollections(); + + expect(doc1Collections, unorderedEquals([doc1col1, doc1col2])); + expect(doc2Collections, unorderedEquals([doc2col1, doc2col2])); + }); + test('has collection() method', () { final collection = documentRef.collection('col'); expect(collection.id, 'col'); @@ -60,7 +82,7 @@ void main() { group('serialize document', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test("doesn't serialize unsupported types", () { expect( @@ -98,7 +120,7 @@ void main() { }); test('Supports BigInt', () async { - final firestore = createInstance(Settings(useBigInt: true)); + final firestore = createFirestore(Settings(useBigInt: true)); await firestore.doc('collectionId/bigInt').set({ 'foo': BigInt.from(9223372036854775807), @@ -199,10 +221,10 @@ void main() { group('get document', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('returns document', () async { - firestore = createInstance(); + firestore = createFirestore(); await firestore.doc('collectionId/getdocument').set({ 'foo': { 'bar': 'foobar', @@ -280,7 +302,7 @@ void main() { group('delete document', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('works', () async { await firestore.doc('collectionId/deletedoc').set({}); @@ -333,7 +355,7 @@ void main() { group('set documents', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('sends empty non-merge write even with just field transform', () async { @@ -386,7 +408,7 @@ void main() { group('create document', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('creates document', () async { await firestore.doc('collectionId/createdoc').delete(); @@ -433,7 +455,7 @@ void main() { group('update document', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('works', () async { await firestore.doc('collectionId/updatedoc').set({'foo': 'bar'}); diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/firestore_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/firestore_test.dart new file mode 100644 index 0000000..4a67b7d --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/firestore_test.dart @@ -0,0 +1,24 @@ +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:test/test.dart'; + +import 'util/helpers.dart'; + +void main() { + group('Firestore', () { + late Firestore firestore; + + setUp(() => firestore = createFirestore()); + + test('listCollections', () async { + final a = firestore.collection('a'); + final b = firestore.collection('b'); + + await a.doc('1').set({'a': 1}); + await b.doc('2').set({'b': 2}); + + final collections = await firestore.listCollections(); + + expect(collections, unorderedEquals([a, b])); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart index d68378d..62f5ed6 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart @@ -7,7 +7,7 @@ void main() { group('query interface', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('overrides ==', () { final queryA = firestore.collection('col1'); @@ -193,7 +193,7 @@ void main() { group('where()', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('handles all operators', () { expect(WhereFilter.equal.proto, 'EQUAL'); @@ -387,7 +387,7 @@ void main() { group('orderBy', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('accepts asc', () async { final collection = firestore.collection('orderByAsc'); @@ -434,7 +434,7 @@ void main() { group('limit()', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('uses latest limit', () async { final collection = firestore.collection('limitLatest'); @@ -451,7 +451,7 @@ void main() { group('limitToLatest()', () { late Firestore firestore; - setUp(() => firestore = createInstance()); + setUp(() => firestore = createFirestore()); test('uses latest limit', () async { final collection = firestore.collection('limitLatest'); diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart index 587014f..312346e 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart @@ -28,8 +28,22 @@ FirebaseAdminApp createApp({ return app; } -Firestore createInstance([Settings? settings]) { - return Firestore(createApp(), settings: settings); +Firestore createFirestore([Settings? settings]) { + final firestore = Firestore(createApp(), settings: settings); + + addTearDown(() async { + final collections = await firestore.listCollections(); + + for (final collection in collections) { + final docs = await collection.get(); + + await Future.wait([ + for (final doc in docs.docs) doc.ref.delete(), + ]); + } + }); + + return firestore; } Matcher isArgumentError({String? message}) { diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 9c65223..a9aac6d 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -3,8 +3,8 @@ # Fast fail the script on failures. set -e -dart pub global activate coverage +# dart pub global activate coverage -firebase emulators:exec --project dart-firebase-admin --only firestore,auth "dart test --coverage=coverage" +firebase emulators:exec --project dart-firebase-admin --only firestore,auth "dart test --concurrency=1" -format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib \ No newline at end of file +# format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib \ No newline at end of file