diff --git a/CHANGELOG.md b/CHANGELOG.md index f92322fec5..4a1aa6812b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## vNext (TBD) ### Enhancements + +* Added `User.getMongoDBbClient` exposing an API for CRUD operations on a Remote Atlas App Service.([#1162](https://github.com/realm/realm-dart/issues/1162)) * Add `RealmResults.isValid` ([#1231](https://github.com/realm/realm-dart/pull/1231)). * Support `Decimal128` datatype ([#1192](https://github.com/realm/realm-dart/pull/1192)). * Realm logging is extended to support logging of all Realm storage level messages. (Core upgrade). diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml index 683be5f02e..3a4682e3bc 100644 --- a/dartdoc_options.yaml +++ b/dartdoc_options.yaml @@ -15,7 +15,10 @@ dartdoc: "Sync": markdown: topic.md name: Sync - categoryOrder: ["Realm", "Configuration", "Annotations", "Application", "Sync"] + "Atlas App Services": + markdown: topic.md + name: Atlas App Services + categoryOrder: ["Realm", "Configuration", "Annotations", "Application", "Sync", "Atlas App Services"] examplePathPrefix: 'example' # nodoc: ['generator/flutter/ffigen/scripts/src/test/*.g.dart'] showUndocumentedCategories: true diff --git a/flutter/realm_flutter/tests/test_driver/realm_test.dart b/flutter/realm_flutter/tests/test_driver/realm_test.dart index 758e40d54f..8d37364b1b 100644 --- a/flutter/realm_flutter/tests/test_driver/realm_test.dart +++ b/flutter/realm_flutter/tests/test_driver/realm_test.dart @@ -26,6 +26,7 @@ import '../test/session_test.dart' as session_test; import '../test/subscription_test.dart' as subscription_test; import '../test/user_test.dart' as user_test; import '../test/realm_logger_test.dart' as realm_logger_test; +import '../test/mongodb_docs_test.dart' as mongodb_docs_test; Future main(List args) async { final Completer completer = Completer(); @@ -53,6 +54,7 @@ Future main(List args) async { await subscription_test.main(args); await user_test.main(args); await realm_logger_test.main(args); + await mongodb_docs_test.main(args); tearDown(() { if (Invoker.current?.liveTest.state.result == test_api.Result.error || Invoker.current?.liveTest.state.result == test_api.Result.failure) { diff --git a/lib/src/cli/atlas_apps/baas_client.dart b/lib/src/cli/atlas_apps/baas_client.dart index 18e86d0382..a19d5ba312 100644 --- a/lib/src/cli/atlas_apps/baas_client.dart +++ b/lib/src/cli/atlas_apps/baas_client.dart @@ -84,7 +84,32 @@ class BaasClient { } };'''; + static const String _documentCollectionName = "AtlasDocAllTypes"; + static const String _documentCollectionSchema = '''{ + "title": "AtlasDocAllTypes", + "bsonType": "object", + "required": ["_id", "stringProp", "boolProp", "dateProp", "doubleProp", "objectIdProp", "uuidProp", "intProp"], + "properties": { + "_id": {"bsonType": "objectId"}, + "stringProp": {"bsonType": "string"}, + "boolProp": {"bsonType": "bool"}, + "dateProp": {"bsonType": "date"}, + "doubleProp": {"bsonType": "double"}, + "objectIdProp": {"bsonType": "objectId"}, + "uuidProp": {"bsonType": "uuid"}, + "intProp": {"bsonType": "long"}, + "nullableStringProp": {"bsonType": "string"}, + "nullableBoolProp": {"bsonType": "bool"}, + "nullableDateProp": {"bsonType": "date"}, + "nullableDoubleProp": {"bsonType": "double"}, + "nullableObjectIdProp": {"bsonType": "objectId"}, + "nullableUuidProp": {"bsonType": "uuid"}, + "nullableIntProp": {"bsonType": "long"} + } + }'''; + static const String defaultAppName = "flexible"; + static const String serviceName = 'BackingDB'; final String _baseUrl; final String? _clusterName; @@ -355,6 +380,7 @@ class BaasClient { await _createMongoDBService( app, + serviceName, syncConfig: '''{ "flexible_sync": { "state": "enabled", @@ -382,6 +408,9 @@ class BaasClient { ); await _put('groups/$_groupId/apps/$appId/sync/config', '{ "development_mode_enabled": true }'); + if (confirmationType != "email" && confirmationType != "auto") { + await createSchema(app, serviceName: serviceName, collectionName: _documentCollectionName, schema: _documentCollectionSchema); + } //create email/password user for tests final dynamic createUserResult = await _post('groups/$_groupId/apps/$appId/users', '{"email": "realm-test@realm.io", "password":"123456"}'); print("Create user result: $createUserResult"); @@ -464,10 +493,10 @@ class BaasClient { }'''); } - Future _createMongoDBService(BaasApp app, {required String syncConfig, required String rules}) async { - final serviceName = _clusterName == null ? 'mongodb' : 'mongodb-atlas'; + Future _createMongoDBService(BaasApp app, String serviceName, {required String syncConfig, required String rules}) async { + final serviceType = _clusterName == null ? 'mongodb' : 'mongodb-atlas'; final mongoConfig = _clusterName == null ? '{ "uri": "mongodb://localhost:26000" }' : '{ "clusterName": "$_clusterName" }'; - final mongoServiceId = await _createService(app, 'BackingDB', serviceName, mongoConfig); + final mongoServiceId = await _createService(app, serviceName, serviceType, mongoConfig); await _post('groups/$_groupId/apps/$app/services/$mongoServiceId/default_rule', rules); @@ -581,7 +610,7 @@ class BaasClient { final app = BaasApp(appId, clientAppId, name, appUniqueName); final dynamic services = await _get('groups/$_groupId/apps/$appId/services'); - dynamic service = services.firstWhere((dynamic s) => s["name"] == "BackingDB", orElse: () => throw Exception("Func 'confirmFunc' not found")); + dynamic service = services.firstWhere((dynamic s) => s["name"] == serviceName, orElse: () => throw Exception("Func 'confirmFunc' not found")); final mongoServiceId = service['_id'] as String; final dynamic configDocs = await _get('groups/$_groupId/apps/$appId/services/$mongoServiceId/config'); final dynamic flexibleSync = configDocs['flexible_sync']; @@ -593,6 +622,15 @@ class BaasClient { }); await _patch('groups/$_groupId/apps/$app/services/$mongoServiceId/config', data); } + + Future createSchema(BaasApp app, {required String serviceName, required String collectionName, required dynamic schema}) async { + print('Create schema $collectionName for ${app.clientAppId}'); + + final urlSchema = 'groups/$_groupId/apps/$app/schemas'; + dynamic metadata = '{"database": "db_${app.uniqueName}", "collection": "$collectionName", "data_source": "$serviceName"}'; + String fullSchema = '{"metadata": $metadata, "schema": $schema }'; + await _post(urlSchema, fullSchema); + } } class BaasApp { diff --git a/lib/src/mongodb_collection.dart b/lib/src/mongodb_collection.dart new file mode 100644 index 0000000000..e7279afeb5 --- /dev/null +++ b/lib/src/mongodb_collection.dart @@ -0,0 +1,326 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Copyright 2023 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// + +import 'dart:convert'; +import 'native/realm_core.dart'; +import 'realm_class.dart'; + +/// The remote MongoClient used for working with data in MongoDB remotely via Realm. +/// +/// {@category Atlas App Services} +class MongoDBClient { + final User _user; + final String _serviceName; + + /// Gets the name of the remote MongoDB service for this client. + String get serviceName => _serviceName; + + /// Gets the [User] for this client. + User get user => _user; + + MongoDBClient(this._user, this._serviceName); + + /// Gets a [MongoDBDatabase] instance for the given database name. + MongoDBDatabase getDatabase(String databseName) { + return MongoDBDatabase._(this, databseName); + } +} + +/// A class representing a remote MongoDB database. +/// +/// {@category Atlas App Services} +class MongoDBDatabase { + final String _mame; + final MongoDBClient _client; + + /// Gets the [MongoDBClient] that manages this database. + MongoDBClient get client => _client; + + /// Gets the name of the database. + String get name => _mame; + + MongoDBDatabase._(this._client, this._mame); + + /// Gets a collection from the database. + MongoDBCollection getCollection(String collectionName) { + return MongoDBCollection._(this, collectionName); + } +} + +/// A class representing a remote MongoDB collections. +/// +/// {@category Atlas App Services} +class MongoDBCollection { + MongoDBDatabase _database; + final String _name; + final User _user; + + /// Gets the [MongoDBDatabase] this collection belongs to. + MongoDBDatabase get database => _database; + + /// Gets the name of the collection. + String get name => _name; + + MongoDBCollection._(this._database, this._name) : _user = _database.client.user; + + /// Finds the all documents in the collection up to [limit]. + /// The result is a string with EJson containing an array with the documents that match the find criteria. + /// See also [db.collection.find](https://docs.mongodb.com/manual/reference/method/db.collection.find/) documentation. + /// + /// The [filter] is a document describing the find criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If the [filter] is not specified, all documents in the collection will be returned. + /// The [sort] is a document describing the sort criteria. If not specified, the order of the returned documents is not guaranteed. + /// The [projection] is a document describing the fields to return for all matching documents. If not specified, all fields are returned. + /// The [limit] is the maximum number of documents to return. If not specified, all documents in the collection are returned. + Future find({ + dynamic filter, + dynamic sort, + dynamic projection, + int? limit, + }) async { + return await _mongoDBFunctionCall( + "find", + filter: filter, + sort: sort, + projection: projection, + limit: limit, + ); + } + + /// Finds the first document in the collection that satisfies the query criteria. + /// The result is a string with EJson containing the first document that matches the find criteria. + /// See also [db.collection.findOne](https://docs.mongodb.com/manual/reference/method/db.collection.findOne/) documentation. + /// + /// The [filter] is a document describing the find criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If the [filter] is not specified, all documents in the collection will match the request. + /// The [sort] is a document describing the sort criteria. If not specified, the order of the documents is not guaranteed. + /// The [projection] is a document describing the fields to return for the matching document. If not specified, all fields are returned. + Future findOne({ + dynamic filter, + dynamic sort, + dynamic projection, + }) async { + return await _mongoDBFunctionCall( + "findOne", + filter: filter, + sort: sort, + projection: projection, + ); + } + + /// Finds and delete the first document in the collection that satisfies the query criteria. + /// The result is a string with EJson containing the first document that matches the find criteria. + /// See also [db.collection.findOneAndDelete](https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndDelete/) documentation. + /// + /// The [filter] is a document describing the find criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If the [filter] is not specified, all documents in the collection will match the request. + /// The [sort] is a document describing the sort criteria. If not specified, the order of the documents is not guaranteed. + /// The [projection] is a document describing the fields to return for the matching document. If not specified, all fields are returned. + Future findOneAndDelete({ + required dynamic filter, + dynamic sort, + dynamic projection, + bool? upsert, + bool? returnNewDocument, + }) async { + return await _mongoDBFunctionCall( + "findOneAndDelete", + filter: filter, + sort: sort, + projection: projection, + upsert: upsert, + returnNewDocument: returnNewDocument, + ); + } + + /// Finds and replaces the first document in the collection that satisfies the query criteria. + /// The result is a string with EJson containing the first document that matches the find criteria. + /// See also [db.collection.findOneAndReplace](https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndReplace/) documentation. + /// + /// The [filter] is a document describing the find criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If the [filter] is not specified, all documents in the collection will match the request. + /// The replacement document [replacementDoc] cannot contain update operator expressions. + /// The [sort] is a document describing the sort criteria. If not specified, the order of the documents is not guaranteed. + /// The [projection] is a document describing the fields to return for the matching document. If not specified, all fields are returned. + /// If [upsert] is `true` the replace should insert a document if no documents match the [filter]. Defaults to `false`. + /// If [returnNewDocument] is `true` the replacement document will be returned as a result. If set to `false` the original document + /// before the replace is returned. Defaults to `false`. + Future findOneAndReplace({ + required dynamic filter, + required dynamic replacementDoc, + dynamic sort, + dynamic projection, + bool? upsert, + bool? returnNewDocument, + }) async { + return await _mongoDBFunctionCall( + "findOneAndReplace", + filter: filter, + updateDocument: replacementDoc, + sort: sort, + projection: projection, + upsert: upsert, + returnNewDocument: returnNewDocument, + ); + } + + /// Finds and update the first document in the collection that satisfies the query criteria. + /// The result is a string with EJson containing the first document that matches the find criteria. + /// See also [db.collection.findOneAndReplace](https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndReplace/) documentation. + /// + /// The [filter] is a document describing the find criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If the [filter] is not specified, all documents in the collection will match the request. + /// The document describing the update [updateDocument] can only contain [update operator expressions](https://docs.mongodb.com/manual/reference/operator/update/#id1). + /// The [sort] is a document describing the sort criteria. If not specified, the order of the documents is not guaranteed. + /// The [projection] is a document describing the fields to return for the matching document. If not specified, all fields are returned. + /// If [upsert] is `true` the update should insert a document if no documents match the [filter]. Defaults to `false`. + /// If [returnNewDocument] is `true` the new updated document will be returned as a result. If set to `false` the original document + /// before the update is returned. Defaults to `false`. + Future findOneAndUpdate({ + required dynamic filter, + required dynamic updateDocument, + dynamic sort, + dynamic projection, + bool? upsert, + bool? returnNewDocument, + }) async { + return await _mongoDBFunctionCall( + "findOneAndUpdate", + filter: filter, + updateDocument: updateDocument, + sort: sort, + projection: projection, + upsert: upsert, + returnNewDocument: returnNewDocument, + ); + } + + /// Inserts the provided [insertDocument] in the collection. + /// The result contains the `_id` of the inserted document. + /// See also [db.collection.insertOne](https://docs.mongodb.com/manual/reference/method/db.collection.insertOne/) documentation. + Future insertOne({required dynamic insertDocument}) async { + return await _mongoDBFunctionCall("insertOne", insertDocument: insertDocument); + } + + /// Inserts one or more [insertDocuments] in the collection. + /// The result contains the `_id`s of the inserted documents. + /// See also [db.collection.insertMany](https://docs.mongodb.com/manual/reference/method/db.collection.insertMany/) documentation. + Future insertMany({required dynamic insertDocuments}) async { + return await _mongoDBFunctionCall("insertMany", insertDocuments: insertDocuments); + } + + /// Updates a single [updateDocument] in the collection according to the specified arguments. + /// The result contains information about the number of matched and updated documents, as well as the `_id` of the + /// upserted document if [upsert] was set to `true` and the operation resulted in an upsert. + /// See also [db.collection.updateOne](https://docs.mongodb.com/manual/reference/method/db.collection.updateOne/) documentation. + /// + /// The [filter] is the document describing the selection criteria of the update. If not specified, the first document in the + /// collection will be updated. Can only contain [query selector expressions](https://docs.mongodb.com/manual/reference/operator/query/#query-selectors) + /// The [updateDocument] can only contain [update operator expressions](https://docs.mongodb.com/manual/reference/operator/update/#id1). + /// If [upsert] is `true` the update should insert a document if no documents match the [filter]. Defaults to `false`. + Future updateOne({required dynamic filter, required dynamic updateDocument, bool upsert = false}) async { + return await _mongoDBFunctionCall("updateOne", filter: filter, updateDocument: updateDocument, upsert: upsert); + } + + /// Updates one or more [updateDocuments] in the collection according to the specified arguments. + /// The result contains information about the number of matched and updated documents, as well as the `_id`s of the + /// upserted documents if [upsert] was set to `true` and the operation resulted in an upsert. + /// See also [db.collection.updateMany](https://docs.mongodb.com/manual/reference/method/db.collection.updateMany/) documentation. + /// + /// The [filter] is the document describing the selection criteria of the update. If not specified, the first document in the + /// collection will be updated. Can only contain [query selector expressions](https://docs.mongodb.com/manual/reference/operator/query/#query-selectors) + /// The [updateDocuments] can only contain [update operator expressions](https://docs.mongodb.com/manual/reference/operator/update/#id1). + /// If [upsert] is `true` the update should insert the documents if no documents match the [filter]. Defaults to `false`. + Future updateMany({required dynamic filter, required dynamic updateDocuments, bool upsert = false}) async { + return await _mongoDBFunctionCall("updateMany", filter: filter, updateDocument: updateDocuments, upsert: upsert); + } + + /// Removes a single document from a collection. If no documents match the [filter], the collection is not modified. + /// The result contains contains the number of deleted documents. + /// See also [db.collection.deleteOne](https://docs.mongodb.com/manual/reference/method/db.collection.deleteOne/) documentation. + /// + /// The [filter] is a document describing the deletion criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If not specified, the first document in the collection will be deleted. + Future deleteOne({dynamic filter}) async { + return await _mongoDBFunctionCall("deleteOne", filter: filter); + } + + /// Removes one or more documents from a collection. + /// If no documents match the [filter], the collection is not modified. + /// If the [filter] is not specified or set to `"{ }"`, all documents in the collection will be deleted. + /// The result contains the number of deleted documents. + /// See also [db.collection.deleteMany](https://docs.mongodb.com/manual/reference/method/db.collection.deleteMany/) documentation. + /// + /// The [filter] is a document describing the deletion criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + Future deleteMany({dynamic filter}) async { + return await _mongoDBFunctionCall("deleteMany", filter: filter ?? jsonDecode("{ }")); + } + + /// Counts the number of documents in the collection that match the provided [filter] and up to [limit]. + /// The result is the number of documents that match the [filter] and[limit] criteria. + /// + /// The [filter] is a document describing the find criteria using [query operators](https://docs.mongodb.com/manual/reference/operator/query/). + /// If the [filter] is not specified, all documents in the collection will be counted. + /// The [limit] is the maximum number of documents to count. If not specified, all documents in the collection are counted. + Future count({dynamic filter, int limit = 0}) async { + return await _mongoDBFunctionCall("count", filter: filter, limit: limit); + } + + /// Executes an aggregation pipeline on the collection and returns the results as a bson array. + /// Documents describing the different pipeline stages using [pipeline expressions](https://docs.mongodb.com/manual/core/aggregation-pipeline/#pipeline-expressions). + /// See also [aggregation](https://docs.mongodb.com/manual/aggregation/) documentation. + Future aggregate({dynamic pipeline}) async { + return await _mongoDBFunctionCall("aggregate", pipeline: pipeline); + } + + Future _mongoDBFunctionCall(String functionName, + {Object? filter, + Object? insertDocuments, + Object? insertDocument, + Object? updateDocument, + Object? sort, + Object? projection, + Object? pipeline, + int? limit, + bool? upsert, + bool? returnNewDocument}) async { + dynamic jsonArguments = { + "database": database.name, + "collection": name, + "query": filter, + "sort": sort, + "project": projection, + "document": insertDocument, + "documents": insertDocuments, + "update": updateDocument, + "pipeline": pipeline, + "limit": limit, + "upsert": upsert, + "returnNewDocument": returnNewDocument + }; + String args = joinDynamics(jsonArguments); + final response = await realmCore.callAppFunction(_user.app, _user, functionName, args, serviceName: _database.client._serviceName); + return jsonDecode(response); + } + + String joinDynamics(dynamic json) { + final Map jsonMap = (json as Map); + jsonMap.removeWhere((dynamic key, dynamic value) => value == null); + return "[${jsonEncode(jsonMap)}]"; + } +} diff --git a/lib/src/native/realm_core.dart b/lib/src/native/realm_core.dart index 30489e681e..5a3b3b7bac 100644 --- a/lib/src/native/realm_core.dart +++ b/lib/src/native/realm_core.dart @@ -2474,7 +2474,7 @@ class _RealmCore { completer.complete(stringResponse); } - Future callAppFunction(App app, User user, String functionName, String? argsAsJSON) { + Future callAppFunction(App app, User user, String functionName, String? argsAsJSON, {String? serviceName}) { return using((arena) { final completer = Completer(); _realmLib.invokeGetBool(() => _realmLib.realm_app_call_function( @@ -2482,7 +2482,7 @@ class _RealmCore { user.handle._pointer, functionName.toCharPtr(arena), argsAsJSON?.toCharPtr(arena) ?? nullptr, - nullptr, + serviceName?.toCharPtr(arena) ?? nullptr, Pointer.fromFunction(_call_app_function_callback), completer.toPersistentHandle(), _realmLib.addresses.realm_dart_delete_persistent_handle, diff --git a/lib/src/realm_class.dart b/lib/src/realm_class.dart index a67b60ce73..7176e9816e 100644 --- a/lib/src/realm_class.dart +++ b/lib/src/realm_class.dart @@ -121,6 +121,7 @@ export 'session.dart' SyncSessionErrorCode; export 'subscription.dart' show Subscription, SubscriptionSet, SubscriptionSetState, MutableSubscriptionSet; export 'user.dart' show User, UserState, ApiKeyClient, UserIdentity, ApiKey, FunctionsClient; +export 'mongodb_collection.dart' show MongoDBClient, MongoDBDatabase, MongoDBCollection; export 'native/realm_core.dart' show Decimal128; /// A [Realm] instance represents a `Realm` database. diff --git a/lib/src/user.dart b/lib/src/user.dart index 1ca8c08196..77d19da877 100644 --- a/lib/src/user.dart +++ b/lib/src/user.dart @@ -41,6 +41,7 @@ class User { late final ApiKeyClient _apiKeys = ApiKeyClient._(this); late final FunctionsClient _functions = FunctionsClient._(this); + late final MongoDBClient _mongodbClient; /// Gets an [ApiKeyClient] instance that exposes functionality for managing /// user API keys. @@ -61,6 +62,16 @@ class User { return _functions; } + /// Gets a [MongoDBClient] instance for accessing documents in an Atlas App Service database. + /// + /// Requires [serviceName] - the name of the service as configured on the server. + MongoDBClient getMongoDBClient({required String serviceName}) { + _ensureLoggedIn('access mongo DB'); + + _mongodbClient = MongoDBClient(this, serviceName); + return _mongodbClient; + } + User._(this._handle, this._app); /// The current state of this [User]. @@ -231,6 +242,8 @@ class UserProfile { /// A class exposing functionality for users to manage API keys from the client. It is always scoped /// to a particular [User] and can only be accessed via [User.apiKeys] +/// +/// {@category Atlas App Services} class ApiKeyClient { final User _user; @@ -309,6 +322,8 @@ class ApiKey { } /// A class exposing functionality for calling remote Atlas Functions. +/// +/// {@category Atlas App Services} class FunctionsClient { final User _user; diff --git a/test/mongodb_docs_test.dart b/test/mongodb_docs_test.dart new file mode 100644 index 0000000000..07d0fc27ee --- /dev/null +++ b/test/mongodb_docs_test.dart @@ -0,0 +1,283 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Copyright 2023 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// + +import 'dart:convert'; + +import 'package:test/test.dart'; + +import '../lib/realm.dart'; +import 'test.dart'; + +class AtlasDocAllTypes { + late ObjectId id; + late String stringProp; + late bool boolProp; + late DateTime dateProp; + late double doubleProp; + late ObjectId objectIdProp; + late Uuid uuidProp; + late int intProp; + + String? nullableStringProp; + bool? nullableBoolProp; + DateTime? nullableDateProp; + double? nullableDoubleProp; + ObjectId? nullableObjectIdProp; + Uuid? nullableUuidProp; + int? nullableIntProp; + + AtlasDocAllTypes(this.id, this.stringProp, this.boolProp, this.dateProp, this.doubleProp, this.objectIdProp, this.uuidProp, this.intProp); + + static AtlasDocAllTypes fromJson(Map json) => AtlasDocAllTypes( + json['_id'] as ObjectId, + json['stringProp'] as String, + json['boolProp'] as bool, + json['dateProp'] as DateTime, + json['doubleProp'] as double, + json['objectIdProp'] as ObjectId, + json['uuidProp'] as Uuid, + json['intProp'] as int) + ..nullableStringProp = json['nullableStringProp'] as String? + ..nullableBoolProp = json['nullableBoolProp'] as bool? + ..nullableDateProp = json['nullableDateProp'] as DateTime? + ..nullableDoubleProp = json['nullableDoubleProp'] as double? + ..nullableObjectIdProp = json['nullableObjectIdProp'] as ObjectId? + ..nullableUuidProp = json['nullableUuidProp'] as Uuid? + ..nullableIntProp = json['nullableIntProp'] as int?; + + Map toEJson() => convertToEJson({ + '_id': id, + 'stringProp': stringProp, + 'boolProp': boolProp, + 'dateProp': dateProp, + 'doubleProp': doubleProp, + 'objectIdProp': objectIdProp, + 'uuidProp': uuidProp, + 'intProp': intProp, + 'nullableStringProp': nullableStringProp, + 'nullableBoolProp': nullableBoolProp, + 'nullableDateProp': nullableDateProp, + 'nullableDoubleProp': nullableDoubleProp, + 'nullableObjectIdProp': nullableObjectIdProp, + 'nullableUuidProp': nullableUuidProp, + 'nullableIntProp': nullableIntProp + }); +} + +Map convertToEJson(Map fields) { + return fields.map((key, value) => MapEntry(key, getFieldEJsonValue(value))); +} + +dynamic getFieldEJsonValue(Object? object) { + if (object == null) { + return null; + } + if (object is String?) { + return object.toString(); + } else if (object is double?) { + double d = (object as double); + int i = d.ceil(); + return {"\$numberDouble": d == i ? i.toString() : d.toString()}; + } else if (object is int?) { + return {"\$numberInt": object.toString()}; + } else if (object is bool?) { + return object; + } else if (object is ObjectId?) { + return {"\$oid": object.toString()}; + } else if (object is Uuid?) { + return { + "\$binary": {"base64": base64.encode((object as Uuid).bytes.asUint8List()), "subType": "04"} + }; + } else if (object is DateTime?) { + return { + "\$date": {"\$numberLong": (object as DateTime).millisecondsSinceEpoch.toString()} + }; + } +} + +Future main([List? args]) async { + await setupTests(args); + + baasTest('MongoDB client find with empty filter returns all', (appConfiguration) async { + int itemsCount = 2; + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + String differentiator = generateRandomString(5); + List> inserts = _generateAtlasDocAllTypesObjects(itemsCount, differentiator: differentiator); + await collection.insertMany(insertDocuments: inserts); + + dynamic found = await collection.find(); + expect((found as List).length, itemsCount); + expect(found, inserts); + + dynamic deleted = await collection.deleteMany(filter: {"stringProp": differentiator}); + expect(deleted["deletedCount"], {"\$numberInt": "$itemsCount"}); + }); + + baasTest('MongoDB client find one with empty filter returns first', (appConfiguration) async { + int itemsCount = 3; + String differentiator = generateRandomString(5); + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + List> inserts = _generateAtlasDocAllTypesObjects(itemsCount, differentiator: differentiator); + await collection.insertMany(insertDocuments: inserts); + + dynamic found = await collection.findOne(); + expect(found, inserts.first); + + dynamic deleted = await collection.deleteMany(filter: {"stringProp": differentiator}); + expect(deleted["deletedCount"], {"\$numberInt": "$itemsCount"}); + }); + + baasTest('MongoDB client insert/find/delete one', (appConfiguration) async { + int itemsCount = 1; + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + dynamic eJson = _generateAtlasDocAllTypesObjects(itemsCount)[0]; + + dynamic inserted = await collection.insertOne(insertDocument: eJson); + expect(inserted["insertedId"], eJson["_id"]); + + dynamic found = await collection.findOne(filter: eJson); + expect(found, eJson); + + dynamic deleted = await await collection.deleteOne(filter: eJson); + expect(deleted["deletedCount"], {"\$numberInt": "$itemsCount"}); + + dynamic foundDeleted = await collection.findOne(filter: eJson); + expect(foundDeleted, null); + }); + + baasTest('MongoDB client insert/find/delete many', (appConfiguration) async { + int itemsCount = 3; + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + String differentiator = generateRandomString(5); + dynamic filterByString = {"stringProp": differentiator}; + List> inserts = _generateAtlasDocAllTypesObjects(itemsCount, differentiator: differentiator); + dynamic inserted = await collection.insertMany(insertDocuments: inserts); + expect(inserted["insertedIds"], inserts.map((item) => item["_id"]).toList()); + + dynamic found = await collection.find(filter: filterByString, sort: {"intProp": 1}); + expect((found as List).length, itemsCount); + expect(found, inserts); + + dynamic deleted = await collection.deleteMany(filter: filterByString); + expect(deleted["deletedCount"], {"\$numberInt": "$itemsCount"}); + + dynamic foundDeleted = await collection.find(filter: filterByString); + expect(foundDeleted, jsonDecode("[]")); + }); + + baasTest('MongoDB client projection and filter with OR', (appConfiguration) async { + int itemsCount = 3; + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + String differentiator = generateRandomString(5); + dynamic filterByString = {"stringProp": differentiator}; + List> inserts = _generateAtlasDocAllTypesObjects(itemsCount, differentiator: differentiator); + dynamic inserted = await collection.insertMany(insertDocuments: inserts); + + dynamic foundIds = await collection.find(filter: filterByString, projection: { + "_id": 1, + 'stringProp': 0, + 'boolProp': 0, + 'dateProp': 0, + 'doubleProp': 0, + 'objectIdProp': 0, + 'uuidProp': 0, + 'intProp': 0, + 'nullableStringProp': 0, + 'nullableBoolProp': 0, + 'nullableDateProp': 0, + 'nullableDoubleProp': 0, + 'nullableObjectIdProp': 0, + 'nullableUuidProp': 0, + 'nullableIntProp': 0 + }); + expect((foundIds as List).length, itemsCount); + + dynamic found = await collection.find(filter: {"\$or": foundIds}, sort: {"intProp": 1}, limit: itemsCount); + expect((found as List).length, inserts.length); + expect(found, inserts); + + dynamic deleted = await collection.deleteMany(filter: {"\$or": foundIds}); + expect(deleted["deletedCount"], {"\$numberInt": "$itemsCount"}); + }); + // TODO: Add tests about methods `count`, `aggregate`, `updateMany` `updateOne`, `findOneAndUpdate` and `findOneAndReplace` + + //TODO: Deletion of all documents should be done in a separate collection to avoid concurrency issues with the rest of the tests. + baasTest('MongoDB client delete all - no filter', (appConfiguration) async { + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + dynamic result = await collection.deleteMany(); + print(result); + }); + + //TODO: Deletion of all documents should be done in a separate collection to avoid concurrency issues with the rest of the tests. + baasTest('MongoDB client delete all with empty filter', (appConfiguration) async { + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + dynamic result = await collection.deleteMany(filter: jsonDecode("{ }")); + print(result); + }); + + baasTest('MongoDB delete with a filter that matches no documents deletes nothing', (appConfiguration) async { + User user = await loginToApp(appConfiguration); + MongoDBCollection collection = getMongoDbCollectionByName(user, "AtlasDocAllTypes"); + dynamic result = await collection.deleteMany(filter: { + "_id": {"\$oid": "64183477ff7f6e95f608784a"} + }); + print(result); + }); +} + +List> _generateAtlasDocAllTypesObjects(int count, {String? differentiator}) { + List> inserts = []; + differentiator = differentiator ?? generateRandomString(5); + for (var i = 0; i < count; i++) { + final doc = AtlasDocAllTypes(ObjectId(), differentiator, false, DateTime.now().toUtc(), 0, ObjectId(), Uuid.v4(), i); + if (i % 2 == 0) { + doc + ..nullableStringProp = "nullable$differentiator$i" + ..nullableBoolProp = true + ..nullableDateProp = DateTime.now().toUtc() + ..nullableDoubleProp = 10.1 + i + ..nullableObjectIdProp = ObjectId() + ..nullableUuidProp = Uuid.v4() + ..nullableIntProp = i; + } + var eJson = doc.toEJson(); + inserts.add(eJson); + } + return inserts; +} + +Future loginToApp(AppConfiguration appConfiguration) async { + final app = App(appConfiguration); + final credentials = Credentials.anonymous(); + final user = await app.logIn(credentials); + return user; +} + +MongoDBCollection getMongoDbCollectionByName(User user, String collectionName) { + final mongodbClient = user.getMongoDBClient(serviceName: "BackingDB"); + final database = mongodbClient.getDatabase(getBaasDatabaseName(appName: AppNames.flexible)); + final collection = database.getCollection(collectionName); + return collection; +} diff --git a/test/test.dart b/test/test.dart index 465b47b4f2..ed36c223f7 100644 --- a/test/test.dart +++ b/test/test.dart @@ -18,6 +18,7 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:math'; @@ -756,6 +757,13 @@ Future enableAllAutomaticRecovery() async { } } +String getBaasDatabaseName({AppNames appName = AppNames.flexible}) { + final client = _baasClient ?? (throw StateError("No BAAS client")); + final app = baasApps[appName.name] ?? + baasApps.values.firstWhere((element) => element.name == BaasClient.defaultAppName, orElse: () => throw RealmError("No BAAS apps")); + return "db_${app.uniqueName}"; +} + extension StreamEx on Stream> { Stream switchLatest() async* { StreamSubscription? inner;