diff --git a/packages/dart/CHANGELOG.md b/packages/dart/CHANGELOG.md index 0eb4ec32a..b4d8b8caa 100644 --- a/packages/dart/CHANGELOG.md +++ b/packages/dart/CHANGELOG.md @@ -1,12 +1,14 @@ -## [5.0.0](https://github.com/parse-community/Parse-SDK-Flutter/compare/dart-4.0.2...dart-5.0.0) (UNRELEASED) +## [5.0.0](https://github.com/parse-community/Parse-SDK-Flutter/compare/dart-4.0.2...dart-5.0.0) (2023-05-14) ### BREAKING CHANGES -* The minimum required Dart SDK version is 2.18.0 ([#867](https://github.com/parse-community/Parse-SDK-Flutter/pull/867)) +* The minimum required Dart SDK version is 2.18.0. ([#867](https://github.com/parse-community/Parse-SDK-Flutter/pull/867)) +* Performing an atomic update on a key of a Parse Object now returns the prospective value, instead of a map of the operation that will be sent to the server; for example for a Parse Object `obj` with a key `count`, the atomic update `obj.setIncrement('count', 1);` previously returned the value `{__op: Increment, amount: 1}` but now returns the prospective result of the operation, which would be `1` if the key's previous value was `0`. ([#860](https://github.com/parse-community/Parse-SDK-Flutter/pull/860)) ### Bug Fixes * Incorrect Dart and Flutter SDKs compatibility range ([#867](https://github.com/parse-community/Parse-SDK-Flutter/pull/867)) +* Setting atomic operation on Parse Object returns operation instead of prospective value ([#860](https://github.com/parse-community/Parse-SDK-Flutter/pull/860)) ## [4.0.2](https://github.com/parse-community/Parse-SDK-Flutter/compare/dart-4.0.1...dart-4.0.2) (2023-03-23) diff --git a/packages/dart/lib/parse_server_sdk.dart b/packages/dart/lib/parse_server_sdk.dart index 72f1df0df..93ada12ac 100644 --- a/packages/dart/lib/parse_server_sdk.dart +++ b/packages/dart/lib/parse_server_sdk.dart @@ -2,9 +2,10 @@ library flutter_parse_sdk; import 'dart:async'; import 'dart:convert'; -import 'package:universal_io/io.dart'; import 'dart:math'; import 'dart:typed_data'; + +import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:meta/meta.dart'; import 'package:mime_type/mime_type.dart'; @@ -14,6 +15,7 @@ import 'package:sembast/sembast_io.dart'; import 'package:sembast_web/sembast_web.dart'; import 'package:timezone/data/latest.dart' as tz; import 'package:timezone/timezone.dart' as tz; +import 'package:universal_io/io.dart'; import 'package:uuid/uuid.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:xxtea/xxtea.dart'; @@ -34,20 +36,30 @@ part 'src/network/parse_connectivity.dart'; part 'src/network/parse_live_query.dart'; part 'src/network/parse_query.dart'; part 'src/objects/parse_acl.dart'; +part 'src/objects/parse_array.dart'; part 'src/objects/parse_base.dart'; part 'src/objects/parse_cloneable.dart'; part 'src/objects/parse_config.dart'; part 'src/objects/parse_error.dart'; part 'src/objects/parse_file.dart'; +part 'src/objects/parse_number.dart'; part 'src/objects/parse_file_base.dart'; part 'src/objects/parse_file_web.dart'; part 'src/objects/parse_function.dart'; part 'src/objects/parse_geo_point.dart'; part 'src/objects/parse_installation.dart'; -part 'src/objects/parse_merge.dart'; part 'src/objects/parse_object.dart'; +part 'src/objects/parse_exception.dart'; +part 'src/objects/parse_operation/parse_add_operation.dart'; +part 'src/objects/parse_operation/parse_add_relation_operation.dart'; +part 'src/objects/parse_operation/parse_add_unique_operation.dart'; +part 'src/objects/parse_operation/parse_increment_operation.dart'; +part 'src/objects/parse_operation/parse_operation.dart'; +part 'src/objects/parse_operation/parse_remove_operation.dart'; +part 'src/objects/parse_operation/parse_remove_relation_operation.dart'; part 'src/objects/parse_relation.dart'; part 'src/objects/parse_response.dart'; +part 'src/objects/parse_save_state_aware_child.dart'; part 'src/objects/parse_session.dart'; part 'src/objects/parse_user.dart'; part 'src/objects/response/parse_error_response.dart'; @@ -66,6 +78,7 @@ part 'src/utils/parse_live_list.dart'; part 'src/utils/parse_logger.dart'; part 'src/utils/parse_login_helpers.dart'; part 'src/utils/parse_utils.dart'; +part 'src/utils/valuable.dart'; class Parse { bool _hasBeenInitialized = false; diff --git a/packages/dart/lib/src/base/parse_constants.dart b/packages/dart/lib/src/base/parse_constants.dart index 95a357435..3ba1d09f7 100644 --- a/packages/dart/lib/src/base/parse_constants.dart +++ b/packages/dart/lib/src/base/parse_constants.dart @@ -1,7 +1,7 @@ part of flutter_parse_sdk; // Library -const String keySdkVersion = '4.0.2'; +const String keySdkVersion = '5.0.0'; const String keyLibraryName = 'Flutter Parse SDK'; // End Points diff --git a/packages/dart/lib/src/network/parse_query.dart b/packages/dart/lib/src/network/parse_query.dart index 971a90eb3..d7f86e2d9 100644 --- a/packages/dart/lib/src/network/parse_query.dart +++ b/packages/dart/lib/src/network/parse_query.dart @@ -50,6 +50,12 @@ class QueryBuilder { T object; List> queries = >[]; final Map limiters = {}; + final Map extraOptions = {}; + + /// Used by ParseRelation getQuery() + void setRedirectClassNameForKey(String key) { + extraOptions['redirectClassNameForKey'] = key; + } /// Adds a limit to amount of results return from Parse void setLimit(int limit) { @@ -408,7 +414,7 @@ class QueryBuilder { /// Builds the query for Parse String buildQuery() { queries = _checkForMultipleColumnInstances(queries); - return 'where={${buildQueries(queries)}}${getLimiters(limiters)}'; + return 'where={${buildQueries(queries)}}${getLimiters(limiters)}${getExtraOptions(extraOptions)}'; } /// Builds the query relational for Parse @@ -528,6 +534,15 @@ class QueryBuilder { return result; } + /// Adds extra options to the query + String getExtraOptions(Map map) { + String result = ''; + map.forEach((String key, dynamic value) { + result = '$result&$key=$value'; + }); + return result; + } + /// Adds the limiters to the query relational, i.e. skip=10, limit=10 String getLimitersRelational(Map map) { String result = ''; diff --git a/packages/dart/lib/src/objects/parse_array.dart b/packages/dart/lib/src/objects/parse_array.dart new file mode 100644 index 000000000..1bf36e273 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_array.dart @@ -0,0 +1,121 @@ +part of flutter_parse_sdk; + +class _ParseArray implements _Valuable, _ParseSaveStateAwareChild { + _ParseArray({this.setMode = false}); + + bool setMode; + + List _savedArray = []; + List estimatedArray = []; + + set savedArray(List array) { + _savedArray = array.toList(); + estimatedArray = array.toList(); + } + + List get savedArray => _savedArray; + + _ParseArrayOperation? lastPreformedOperation; + + _ParseArray preformArrayOperation( + _ParseArrayOperation arrayOperation, + ) { + arrayOperation.mergeWithPrevious(lastPreformedOperation ?? this); + + lastPreformedOperation = arrayOperation; + + estimatedArray = lastPreformedOperation!.value.toList(); + + if (setMode) { + lastPreformedOperation = null; + } + + return this; + } + + Object toJson({bool full = false}) { + if (full) { + return { + 'className': 'ParseArray', + 'estimatedArray': parseEncode(estimatedArray, full: full), + 'savedArray': parseEncode(_savedArray, full: full), + 'lastPreformedOperation': lastPreformedOperation?.toJson(full: full) + }; + } + + return lastPreformedOperation?.toJson(full: full) ?? + parseEncode(estimatedArray, full: full); + } + + factory _ParseArray.fromFullJson(Map json) { + return _ParseArray() + .._savedArray = parseDecode(json['savedArray']) + ..estimatedArray = parseDecode(json['estimatedArray']) + ..lastPreformedOperation = json['lastPreformedOperation'] == null + ? null + : _ParseArrayOperation.fromFullJson(json['lastPreformedOperation']); + } + + @override + List getValue() { + return estimatedArray.toList(); + } + + _ParseArrayOperation? _lastPreformedOperationBeforeSaving; + List? _estimatedArrayBeforeSaving; + + @override + @mustCallSuper + void onSaved() { + setMode = false; + _savedArray.clear(); + _savedArray.addAll(_estimatedArrayBeforeSaving ?? []); + _estimatedArrayBeforeSaving = null; + + if (_lastPreformedOperationBeforeSaving == lastPreformedOperation) { + // No operations were performed during the save process + lastPreformedOperation = null; + } else { + // remove the saved objects and keep the new added objects while saving + if (lastPreformedOperation is _ParseRemoveOperation) { + lastPreformedOperation?.valueForApiRequest + .retainWhere((e) => _savedArray.contains(e)); + } else { + lastPreformedOperation?.valueForApiRequest + .removeWhere((e) => _savedArray.contains(e)); + } + } + + _lastPreformedOperationBeforeSaving = null; + } + + @override + @mustCallSuper + void onSaving() { + _lastPreformedOperationBeforeSaving = lastPreformedOperation; + _estimatedArrayBeforeSaving = estimatedArray.toList(); + } + + @override + @mustCallSuper + void onRevertSaving() { + _lastPreformedOperationBeforeSaving = null; + _estimatedArrayBeforeSaving = null; + } + + @override + @mustCallSuper + void onErrorSaving() { + _lastPreformedOperationBeforeSaving = null; + _estimatedArrayBeforeSaving = null; + } + + @override + @mustCallSuper + void onClearUnsaved() { + estimatedArray = savedArray; + lastPreformedOperation = null; + _lastPreformedOperationBeforeSaving = null; + _estimatedArrayBeforeSaving = null; + } +} diff --git a/packages/dart/lib/src/objects/parse_base.dart b/packages/dart/lib/src/objects/parse_base.dart index 112a8721c..97eced61e 100644 --- a/packages/dart/lib/src/objects/parse_base.dart +++ b/packages/dart/lib/src/objects/parse_base.dart @@ -1,6 +1,7 @@ part of flutter_parse_sdk; abstract class ParseBase { + /// refers to the Table Name in your Parse Server String parseClassName = 'ParseBase'; final bool _dirty = false; // reserved property final Map _unsavedChanges = {}; @@ -91,12 +92,17 @@ abstract class ParseBase { map[keyVarUpdatedAt] = _parseDateFormat.format(updatedAt!); } - final Map target = - forApiRQ ? _unsavedChanges : _getObjectData(); + final target = forApiRQ ? _unsavedChanges : _getObjectData(); target.forEach((String key, dynamic value) { if (!map.containsKey(key)) { map[key] = parseEncode(value, full: full); } + + if (forApiRQ && + value is _ParseRelation && + !value.shouldIncludeInRequest()) { + map.remove(key); + } }); if (forApiRQ) { @@ -115,7 +121,7 @@ abstract class ParseBase { } @override - String toString() => json.encode(toJson()); + String toString() => json.encode(toJson(full: true)); dynamic fromJsonForManualObject(Map objectData) { return _fromJson(objectData, true); @@ -146,9 +152,29 @@ abstract class ParseBase { } else if (key == keyVarAcl) { _getObjectData()[keyVarAcl] = ParseACL().fromJson(value); } else { - _getObjectData()[key] = parseDecode(value); + var decodedValue = parseDecode(value); + + if (decodedValue is List) { + if (addInUnSave) { + decodedValue = _ParseArray()..estimatedArray = decodedValue; + } else { + decodedValue = _ParseArray()..savedArray = decodedValue; + } + } + + if (decodedValue is num) { + if (addInUnSave) { + decodedValue = _ParseNumber(decodedValue); + } else { + decodedValue = _ParseNumber(decodedValue) + ..savedNumber = decodedValue; + } + } + + _getObjectData()[key] = decodedValue; + if (addInUnSave) { - _unsavedChanges[key] = _getObjectData()[key]; + _unsavedChanges[key] = decodedValue; } } }); @@ -170,7 +196,13 @@ abstract class ParseBase { Map _getObjectData() => _objectData; bool containsValue(Object value) { - return _getObjectData().containsValue(value); + for (final val in _getObjectData().values) { + if (val == value || (val is _Valuable && val.getValue() == value)) { + return true; + } + } + + return false; } bool containsKey(String key) { @@ -193,42 +225,62 @@ abstract class ParseBase { void clearUnsavedChanges() { _unsavedChanges.clear(); + _notifyChildrenAboutClearUnsaved(); + } + + void _notifyChildrenAboutClearUnsaved() { + for (final child in _getObjectData().values) { + if (child is _ParseSaveStateAwareChild) { + child.onClearUnsaved(); + } + } } - /// Sets type [T] from objectData + /// Add a key-value pair to this object. + /// + /// It is recommended to name keys in `camelCaseLikeThis` /// - /// To set an int, call setType and an int will be saved /// [bool] forceUpdate is always true, if unsure as to whether an item is /// needed or not, set to false void set(String key, T value, {bool forceUpdate = true}) { - if (_getObjectData().containsKey(key)) { - if (_getObjectData()[key] == value && !forceUpdate) { - return; - } - _getObjectData()[key] = - ParseMergeTool().mergeWithPrevious(_unsavedChanges[key], value); - } else { - _getObjectData()[key] = value; + if (_getObjectData()[key] == value && !forceUpdate) { + return; } + + _getObjectData()[key] = _ParseOperation.maybeMergeWithPrevious( + newValue: value, + previousValue: _getObjectData()[key], + parent: this as ParseObject, + key: key, + ); + _unsavedChanges[key] = _getObjectData()[key]; } - /// Gets type [T] from objectData + /// Get a value of type [T] associated with a given [key] /// - /// Returns null or [defaultValue] if provided. To get an int, call - /// getType and an int will be returned, null, or a defaultValue if - /// provided + /// Returns null or [defaultValue] if provided. T? get(String key, {T? defaultValue}) { if (_getObjectData().containsKey(key)) { - return _getObjectData()[key] as T?; + final result = _getObjectData()[key]; + + if (result is _Valuable) { + return result.getValue() as T?; + } + + if (result is _ParseRelation) { + return (result + ..parent = (this as ParseObject) + ..key = key) as T?; + } + + return result as T?; } else { return defaultValue; } } - /// Saves item to simple key pair value storage - /// - /// Replicates Android SDK pin process and saves object to storage + /// Saves item to value storage Future pin() async { if (objectId != null) { await unpin(); @@ -241,9 +293,7 @@ abstract class ParseBase { } } - /// Saves item to simple key pair value storage - /// - /// Replicates Android SDK pin process and saves object to storage + /// Remove item from value storage Future unpin({String? key}) async { if (objectId != null || key != null) { await ParseCoreData().getStore().remove(key ?? objectId!); @@ -253,10 +303,8 @@ abstract class ParseBase { return false; } - /// Saves item to simple key pair value storage - /// - /// Replicates Android SDK pin process and saves object to storage - dynamic fromPin(String objectId) async { + /// Get item from value storage + Future fromPin(String objectId) async { final CoreStore coreStore = ParseCoreData().getStore(); final String? itemFromStore = await coreStore.getString(objectId); @@ -268,12 +316,12 @@ abstract class ParseBase { Map toPointer() => encodeObject(parseClassName, objectId!); - ///Set the [ParseACL] governing this object. + /// Set the [ParseACL] governing this object. void setACL(ParseACL acl) { set(keyVarAcl, acl); } - ///Access the [ParseACL] governing this object. + /// Access the [ParseACL] governing this object. ParseACL getACL() { if (_getObjectData().containsKey(keyVarAcl)) { return _getObjectData()[keyVarAcl]; diff --git a/packages/dart/lib/src/objects/parse_exception.dart b/packages/dart/lib/src/objects/parse_exception.dart new file mode 100644 index 000000000..27cdaf70a --- /dev/null +++ b/packages/dart/lib/src/objects/parse_exception.dart @@ -0,0 +1,44 @@ +part of flutter_parse_sdk; + +abstract class ParseException implements Exception {} + +class ParseRelationException implements ParseException { + final String? message; + + const ParseRelationException([this.message]); + + @override + String toString() { + if (message == null) return "ParseRelationException"; + return "ParseRelationException: $message"; + } +} + +class ParseOperationException implements ParseException { + final String? message; + + const ParseOperationException([this.message]); + + @override + String toString() { + if (message == null) return "ParseOperationException"; + return "ParseOperationException: $message"; + } +} + +class _UnmergeableOperationException extends ParseOperationException { + final _ParseOperation current; + final Object previous; + + const _UnmergeableOperationException(this.current, this.previous); + + @override + String toString() { + if (previous is _ParseOperation) { + return '${current.operationName} operation is invalid after ' + '${(previous as _ParseOperation).operationName} operation'; + } + + return 'can not perform ${current.operationName} merge operation on the previous value $previous'; + } +} diff --git a/packages/dart/lib/src/objects/parse_merge.dart b/packages/dart/lib/src/objects/parse_merge.dart deleted file mode 100644 index f0cfe7381..000000000 --- a/packages/dart/lib/src/objects/parse_merge.dart +++ /dev/null @@ -1,253 +0,0 @@ -part of flutter_parse_sdk; - -class ParseMergeTool { - // merge method - dynamic mergeWithPrevious(dynamic previous, dynamic values) { - if (previous == null) { - return values; - } - String? previousAction = 'Set'; - if (previous is Map) { - previousAction = previous['__op']; - } - if (values is Map) { - if (values['__op'] == 'Add') { - values = _mergeWithPreviousAdd(previousAction, previous, values); - } else if (values['__op'] == 'Remove') { - values = _mergeWithPreviousRemove(previousAction, previous, values); - } else if (values['__op'] == 'Increment') { - values = _mergeWithPreviousIncrement(previousAction, previous, values); - } else if (values['__op'] == 'AddUnique') { - values = _mergeWithPreviousAddUnique(previousAction, previous, values); - } else if (values['__op'] == 'AddRelation') { - values = - _mergeWithPreviousAddRelation(previousAction, previous, values); - } else if (values['__op'] == 'RemoveRelation') { - values = - _mergeWithPreviousRemoveRelation(previousAction, previous, values); - } - } - return values; - } - - // Add operation Merge - dynamic _mergeWithPreviousAdd( - String? previousAction, dynamic previous, dynamic values) { - if (previousAction == 'Set') { - if (previous is List) { - return List.from(previous)..addAll(values['objects']); - } else { - throw 'Unable to add an item to a non-array.'; - } - } - if (previousAction == 'Add') { - if (values['objects'].length == 1) { - previous['objects'].add(values['objects'].first); - } else { - previous['objects'].add(values['objects']); - } - values = previous; - } - if (previousAction == 'Increment') { - throw 'Add operation is invalid after Increment operation'; - } - if (previousAction == 'Remove') { - throw 'Add operation is invalid after Remove operation'; - } - if (previousAction == 'AddUnique') { - throw 'Add operation is invalid after AddUnique operation'; - } - if (previousAction == 'AddRelation') { - throw 'Add operation is invalid after AddRelation operation'; - } - if (previousAction == 'RemoveRelation') { - throw 'Add operation is invalid after RemoveRelation operation'; - } - return values; - } - - // Remove operation Merge - dynamic _mergeWithPreviousRemove( - String? previousAction, dynamic previous, dynamic values) { - if (previousAction == 'Set') { - return previous; - } - if (previousAction == 'Remove') { - if (values['objects'].length == 1) { - previous['objects'].add(values['objects'].first); - } else { - previous['objects'].add(values['objects']); - } - values = previous; - } - if (previousAction == 'Increment') { - throw 'Remove operation is invalid after Increment operation'; - } - if (previousAction == 'Add') { - throw 'Remove operation is invalid after Add operation'; - } - if (previousAction == 'AddUnique') { - throw 'Remove operation is invalid after AddUnique operation'; - } - if (previousAction == 'AddRelation') { - throw 'Remove operation is invalid after AddRelation operation'; - } - if (previousAction == 'RemoveRelation') { - throw 'Remove operation is invalid after RemoveRelation operation'; - } - return values; - } - - // Increment operation Merge - dynamic _mergeWithPreviousIncrement( - String? previousAction, dynamic previous, dynamic values) { - if (previousAction == 'Set') { - if (previous is num) { - values['amount'] += previous; - } else { - throw 'Invalid Operation'; - } - } - if (previousAction == 'Increment') { - values['amount'] += previous['amount']; - } - if (previousAction == 'Add') { - throw 'Increment operation is invalid after Add operation'; - } - if (previousAction == 'Remove') { - throw 'Increment operation is invalid after Remove operation'; - } - if (previousAction == 'AddUnique') { - throw 'Increment operation is invalid after AddUnique operation'; - } - if (previousAction == 'AddRelation') { - throw 'Increment operation is invalid after AddRelation operation'; - } - if (previousAction == 'RemoveRelation') { - throw 'Increment operation is invalid after RemoveRelation operation'; - } - return values; - } - - // AddUnique operation Merge - dynamic _mergeWithPreviousAddUnique( - String? previousAction, dynamic previous, dynamic values) { - if (previousAction == 'Set') { - if (previous is List) { - return _applyToValueAddUnique(previous, values['objects']); - } else { - throw 'Unable to add an item to a non-array.'; - } - } - if (previousAction == 'AddUnique') { - values['objects'] = - _applyToValueAddUnique(previous['objects'], values['objects']); - return values; - } - if (previousAction == 'Add') { - throw 'AddUnique operation is invalid after Add operation'; - } - if (previousAction == 'Remove') { - throw 'AddUnique operation is invalid after Reomve operation'; - } - if (previousAction == 'Increment') { - throw 'AddUnique operation is invalid after Increment operation'; - } - if (previousAction == 'AddRelation') { - throw 'AddUnique operation is invalid after AddRelation operation'; - } - if (previousAction == 'RemoveRelation') { - throw 'AddUnique operation is invalid after RemoveRelation operation'; - } - return values; - } - - // AddRelation operation Merge - dynamic _mergeWithPreviousAddRelation( - String? previousAction, dynamic previous, dynamic values) { - if (previousAction == 'AddRelation') { - if (values['objects'].length == 1) { - previous['objects'].add(values['objects'].first); - } else { - previous['objects'].add(values['objects']); - } - values = previous; - } - if (previousAction == 'Set') { - throw 'AddRelation operation is invalid after Set operation.'; - } - if (previousAction == 'Increment') { - throw 'AddRelation operation is invalid after Increment operation'; - } - if (previousAction == 'Add') { - throw 'AddRelation operation is invalid after Add operation'; - } - if (previousAction == 'Remove') { - throw 'AddRelation operation is invalid after Remove operation'; - } - if (previousAction == 'AddUnique') { - throw 'AddRelation operation is invalid after AddUnique operation'; - } - if (previousAction == 'RemoveRelation') { - throw 'AddRelation operation is invalid after RemoveRelation operation'; - } - return values; - } - - // RemoveRelation operation Merge - dynamic _mergeWithPreviousRemoveRelation( - String? previousAction, dynamic previous, dynamic values) { - if (previousAction == 'RemoveRelation') { - if (values['objects'].length == 1) { - previous['objects'].add(values['objects'].first); - } else { - previous['objects'].add(values['objects']); - } - values = previous; - } - if (previousAction == 'Set') { - throw 'RemoveRelation operation is invalid after Set operation.'; - } - if (previousAction == 'Increment') { - throw 'RemoveRelation operation is invalid after Increment operation'; - } - if (previousAction == 'Add') { - throw 'RemoveRelation operation is invalid after Add operation'; - } - if (previousAction == 'Remove') { - throw 'RemoveRelation operation is invalid after Remove operation'; - } - if (previousAction == 'AddUnique') { - throw 'RemoveRelation operation is invalid after AddUnique operation'; - } - if (previousAction == 'AddRelation') { - throw 'RemoveRelation operation is invalid after AddRelation operation'; - } - return values; - } - - // service for AddUnique method - dynamic _applyToValueAddUnique(dynamic oldValue, dynamic newValue) { - // ignore: always_specify_types - for (var objectToAdd in newValue) { - if (objectToAdd is ParseObject && objectToAdd.objectId != null) { - int index = 0; - // ignore: always_specify_types - for (var objc in oldValue) { - if (objc is ParseObject && objc.objectId == objectToAdd.objectId) { - oldValue[index] = objectToAdd; - break; - } - index += 1; - } - if (index == oldValue.length) { - oldValue.add(objectToAdd); - } - } else if (!oldValue.contains(objectToAdd)) { - oldValue.add(objectToAdd); - } - } - print(oldValue); - return oldValue; - } -} diff --git a/packages/dart/lib/src/objects/parse_number.dart b/packages/dart/lib/src/objects/parse_number.dart new file mode 100644 index 000000000..2b6c1be25 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_number.dart @@ -0,0 +1,128 @@ +part of flutter_parse_sdk; + +class _ParseNumber implements _Valuable, _ParseSaveStateAwareChild { + num estimateNumber; + + num _savedNumber = 0.0; + + set savedNumber(num number) { + estimateNumber = _savedNumber = number; + } + + num get savedNumber => _savedNumber; + + _ParseNumber(this.estimateNumber, {this.setMode = false}); + + bool setMode; + + _ParseNumberOperation? lastPreformedOperation; + + _ParseNumber preformNumberOperation( + _ParseNumberOperation incrementOperation, + ) { + incrementOperation.mergeWithPrevious(lastPreformedOperation ?? this); + + lastPreformedOperation = incrementOperation; + + estimateNumber = lastPreformedOperation!.value; + + return this; + } + + Object toJson({bool full = false}) { + if (full) { + return { + 'className': 'ParseNumber', + 'estimateNumber': estimateNumber, + 'savedNumber': _savedNumber, + 'setMode': setMode, + 'lastPreformedOperation': lastPreformedOperation?.toJson(full: full) + }; + } + + return setMode + ? estimateNumber + : lastPreformedOperation?.toJson(full: full) ?? estimateNumber; + } + + factory _ParseNumber.fromFullJson(Map json) { + return _ParseNumber(json['estimateNumber'] as num) + .._savedNumber = json['savedNumber'] as num + ..setMode = json['setMode'] as bool + ..lastPreformedOperation = json['lastPreformedOperation'] == null + ? null + : _ParseNumberOperation.fromFullJson(json['lastPreformedOperation']); + } + + @override + num getValue() { + return estimateNumber; + } + + _ParseNumberOperation? _lastPreformedOperationBeforeSaving; + num? _numberForApiRequestBeforeSaving; + num? _estimateNumberBeforeSaving; + + @override + @mustCallSuper + void onSaved() { + setMode = false; + + if (_lastPreformedOperationBeforeSaving == lastPreformedOperation) { + // No operations were performed during the save process + lastPreformedOperation = null; + } else { + // Some operations performed during the save process. + // Subtract the saved APiNumber from the modified APiNumber while saving, + // in order to keep only the modifications that were made while saving the object + if (lastPreformedOperation != null) { + lastPreformedOperation!.valueForApiRequest -= + _numberForApiRequestBeforeSaving ?? 0.0; + } + } + + if (_estimateNumberBeforeSaving != null) { + _savedNumber = _estimateNumberBeforeSaving!; + } + + _lastPreformedOperationBeforeSaving = null; + _estimateNumberBeforeSaving = null; + _numberForApiRequestBeforeSaving = null; + } + + @override + @mustCallSuper + void onSaving() { + _lastPreformedOperationBeforeSaving = lastPreformedOperation; + _estimateNumberBeforeSaving = estimateNumber; + _numberForApiRequestBeforeSaving = + lastPreformedOperation?.valueForApiRequest; + } + + @override + @mustCallSuper + void onRevertSaving() { + _lastPreformedOperationBeforeSaving = null; + _numberForApiRequestBeforeSaving = null; + _estimateNumberBeforeSaving = null; + } + + @override + @mustCallSuper + void onErrorSaving() { + _lastPreformedOperationBeforeSaving = null; + _numberForApiRequestBeforeSaving = null; + _estimateNumberBeforeSaving = null; + } + + @override + @mustCallSuper + void onClearUnsaved() { + estimateNumber = _savedNumber; + + lastPreformedOperation = null; + _lastPreformedOperationBeforeSaving = null; + _estimateNumberBeforeSaving = null; + _numberForApiRequestBeforeSaving = null; + } +} diff --git a/packages/dart/lib/src/objects/parse_object.dart b/packages/dart/lib/src/objects/parse_object.dart index 62f2ff566..dc60239a8 100644 --- a/packages/dart/lib/src/objects/parse_object.dart +++ b/packages/dart/lib/src/objects/parse_object.dart @@ -1,14 +1,28 @@ part of flutter_parse_sdk; +/// [ParseObject] is a local representation of data that can be saved and +/// retrieved from the Parse cloud. +/// +/// The basic workflow for creating new data is to construct a new [ParseObject], +/// use set(key, value) to fill it with data, and then use [save] to persist +/// to the cloud. +/// +/// The basic workflow for accessing existing data is to use a [QueryBuilder] +/// to specify which existing data to retrieve. class ParseObject extends ParseBase implements ParseCloneable { /// Creates a new Parse Object /// - /// [String] className refers to the Table Name in your Parse Server, - /// [bool] debug will overwrite the current default debug settings and - /// [ParseHttpClient] can be overwritten to create your own HTTP Client - ParseObject(String className, - {bool? debug, ParseClient? client, bool? autoSendSessionId}) - : super() { + /// [className], refers to the Table Name in your Parse Server + /// + /// [debug], will overwrite the current default debug settings + /// + /// [client], can be overwritten to create your own HTTP Client + ParseObject( + String className, { + bool? debug, + ParseClient? client, + bool? autoSendSessionId, + }) : super() { parseClassName = className; _path = '$keyEndPointClasses$className'; _aggregatepath = '$keyEndPointAggregate$className'; @@ -32,11 +46,14 @@ class ParseObject extends ParseBase implements ParseCloneable { late bool _debug; late ParseClient _client; - /// Gets an object from the server using it's [String] objectId + /// Gets an object from the server using it's [objectId] /// - /// `List` include refers to other ParseObjects stored as a Pointer - Future getObject(String objectId, - {List? include}) async { + /// [include], is a list of [ParseObject]s keys to be included directly and + /// not as a pointer. + Future getObject( + String objectId, { + List? include, + }) async { try { String? query; if (include != null) { @@ -67,6 +84,8 @@ class ParseObject extends ParseBase implements ParseCloneable { } /// Creates a new object and saves it online + /// + /// Prefer using [save] over [create] Future create({bool allowCustomObjectId = false}) async { try { final Uri url = getSanitisedUri(_client, _path); @@ -74,17 +93,33 @@ class ParseObject extends ParseBase implements ParseCloneable { forApiRQ: true, allowCustomObjectId: allowCustomObjectId, )); + _saveChanges(); + final ParseNetworkResponse result = await _client.post(url.toString(), data: body); - return handleResponse( + final response = handleResponse( this, result, ParseApiRQ.create, _debug, parseClassName); + + if (!response.success) { + _notifyChildrenAboutErrorSaving(); + } + + return response; } on Exception catch (e) { + _notifyChildrenAboutErrorSaving(); return handleException(e, ParseApiRQ.create, _debug, parseClassName); } } + /// Send the updated object to the server. + /// + /// Will only send the dirty (modified) data and not the entire object + /// + /// The object should hold an [objectId] in order to update it + /// + /// Prefer using [save] over [update] Future update() async { assert( objectId != null && (objectId?.isNotEmpty ?? false), @@ -94,20 +129,62 @@ class ParseObject extends ParseBase implements ParseCloneable { try { final Uri url = getSanitisedUri(_client, '$_path/$objectId'); final String body = json.encode(toJson(forApiRQ: true)); + _saveChanges(); + final Map headers = { keyHeaderContentType: keyHeaderContentTypeJson }; + final ParseNetworkResponse result = await _client.put(url.toString(), data: body, options: ParseNetworkOptions(headers: headers)); - return handleResponse( + + final response = handleResponse( this, result, ParseApiRQ.save, _debug, parseClassName); + + if (!response.success) { + _notifyChildrenAboutErrorSaving(); + } + + return response; } on Exception catch (e) { + _notifyChildrenAboutErrorSaving(); return handleException(e, ParseApiRQ.save, _debug, parseClassName); } } - /// Saves the current object online + /// Saves the current object online. + /// + /// If the object not saved yet, this will create it. Otherwise, + /// it will send the updated object to the server. + /// + /// This will save any nested(child) object in this object. So you do not need + /// to each one of them manually. + /// + /// Example of saving child and parent objects using save(): + /// + /// ```dart + /// final dietPlan = ParseObject('Diet_Plans')..set('Fat', 15); + /// final plan = ParseObject('Plan')..set('planName', 'John.W'); + /// dietPlan.set('plan', plan); + /// + /// // the save function will create the nested(child) object first and then + /// // attempts to save the parent object. + /// // + /// // using create in this situation will throw an error, because the child + /// // object is not saved/created yet and you need to create it manually + /// await dietPlan.save(); + /// + /// print(plan.objectId); // DLde4rYA8C + /// print(dietPlan.objectId); // RGd4fdEUB + /// + /// ``` + /// + /// The same principle works with [ParseRelation] + /// + /// Its safe to call this function aging if an error occurred while saving. + /// + /// Prefer using [save] over [update] and [create] Future save() async { final ParseResponse childrenResponse = await _saveChildren(this); if (childrenResponse.success) { @@ -235,12 +312,14 @@ class ParseObject extends ParseBase implements ParseCloneable { _savingChanges.clear(); _savingChanges.addAll(_unsavedChanges); _unsavedChanges.clear(); + _notifyChildrenAboutSaving(); } void _revertSavingChanges() { _savingChanges.addAll(_unsavedChanges); _unsavedChanges.addAll(_savingChanges); _savingChanges.clear(); + _notifyChildrenAboutRevertSaving(); } dynamic _getRequestJson(String method) { @@ -270,7 +349,15 @@ class ParseObject extends ParseBase implements ParseCloneable { return false; } } - } else if (value is List) { + } else if (value is _Valuable) { + if (!_canbeSerialized(aftersaving, value: value.getValue())) { + return false; + } + } else if (value is _ParseRelation) { + if (!_canbeSerialized(aftersaving, value: value.valueForApiRequest())) { + return false; + } + } else if (value is Iterable) { for (dynamic child in value) { if (!_canbeSerialized(aftersaving, value: child)) { return false; @@ -290,7 +377,7 @@ class ParseObject extends ParseBase implements ParseCloneable { Set uniqueFiles, Set seen, Set seenNew) { - if (object is List) { + if (object is Iterable) { for (dynamic child in object) { if (!_collectionDirtyChildren( child, uniqueObjects, uniqueFiles, seen, seenNew)) { @@ -304,6 +391,16 @@ class ParseObject extends ParseBase implements ParseCloneable { return false; } } + } else if (object is _Valuable) { + if (!_collectionDirtyChildren( + object.getValue(), uniqueObjects, uniqueFiles, seen, seenNew)) { + return false; + } + } else if (object is _ParseRelation) { + if (!_collectionDirtyChildren(object.valueForApiRequest(), uniqueObjects, + uniqueFiles, seen, seenNew)) { + return false; + } } else if (object is ParseACL) { // TODO(yulingtianxia): handle ACL } else if (object is ParseFileBase) { @@ -343,65 +440,116 @@ class ParseObject extends ParseBase implements ParseCloneable { return true; } - /// Get the instance of ParseRelation class associated with the given key. - ParseRelation getRelation(String key) { - return ParseRelation(parent: this, key: key); + void _notifyChildrenAboutSave() { + for (final child in _getObjectData().values) { + if (child is _ParseSaveStateAwareChild) { + child.onSaved(); + } + } } - /// Removes an element from an Array - void setRemove(String key, dynamic value) { - _arrayOperation('Remove', key, [value]); + void _notifyChildrenAboutSaving() { + for (final child in _getObjectData().values) { + if (child is _ParseSaveStateAwareChild) { + child.onSaving(); + } + } + } + + void _notifyChildrenAboutErrorSaving() { + for (final child in _getObjectData().values) { + if (child is _ParseSaveStateAwareChild) { + child.onErrorSaving(); + } + } + } + + void _notifyChildrenAboutRevertSaving() { + for (final child in _getObjectData().values) { + if (child is _ParseSaveStateAwareChild) { + child.onRevertSaving(); + } + } + } + + /// Get the instance of [ParseRelation] class associated with the given [key] + ParseRelation getRelation(String key) { + final potentialRelation = _getObjectData()[key]; + + if (potentialRelation == null) { + final relation = ParseRelation(parent: this, key: key); + + set(key, relation); + + return relation; + } + + if (potentialRelation is _ParseRelation) { + return potentialRelation + ..parent = this + ..key = key; + } + + throw ParseRelationException( + 'The key $key is associated with a value ($potentialRelation) ' + 'can not be a relation'); } - /// Remove multiple elements from an array of an object - void setRemoveAll(String key, List values) { - _arrayOperation('Remove', key, values); + /// Remove every instance of an [element] from an array + /// associated with a given [key] + void setRemove(String key, dynamic element) { + set(key, _ParseRemoveOperation([element])); } - /// Add a multiple elements to an array of an object - void setAddAll(String key, List values) { - _arrayOperation('Add', key, values); + /// Removes all instances of the [elements] contained in a [List] from the + /// array associated with a given [key] + void setRemoveAll(String key, List elements) { + set(key, _ParseRemoveOperation(elements)); } - void setAddUnique(String key, dynamic value) { - _arrayOperation('AddUnique', key, [value]); + /// Add multiple [elements] to the end of the array + /// associated with a given [key] + void setAddAll(String key, List elements) { + set(key, _ParseAddOperation(elements)); } - /// Add a multiple elements to an array of an object - void setAddAllUnique(String key, List values) { - _arrayOperation('AddUnique', key, values); + /// Add an [element] to the array associated with a given [key], only if + /// it is not already present in the array. The position of the insert is not + /// guaranteed + void setAddUnique(String key, dynamic element) { + set(key, _ParseAddUniqueOperation([element])); } - /// Add a single element to an array of an object - void setAdd(String key, dynamic value) { - _arrayOperation('Add', key, [value]); + /// Add multiple [elements] to the array associated with a given [key], only + /// adding elements which are not already present in the array. The position + /// of the insert is not guaranteed + void setAddAllUnique(String key, List elements) { + set(key, _ParseAddUniqueOperation(elements)); } - void addRelation(String key, List values) { - _arrayOperation('AddRelation', key, values); + /// Add an [element] to the end of the array associated with a given [key] + void setAdd(String key, T element) { + set(key, _ParseAddOperation([element])); } - void removeRelation(String key, List values) { - _arrayOperation('RemoveRelation', key, values); + /// Add multiple [objets] to a relation associated with a given [key] + void addRelation(String key, List objets) { + set(key, _ParseAddRelationOperation(objets.toSet())); } - /// Used in array Operations in save() method - void _arrayOperation(String arrayAction, String key, List values) { - // TODO(yulingtianxia): Array operations should be incremental. Merge add and remove operation. - set>( - key, {'__op': arrayAction, 'objects': values}); + /// Remove multiple [objets] from a relation associated with a given [key] + void removeRelation(String key, List objets) { + set(key, _ParseRemoveRelationOperation(objets.toSet())); } - /// Increases a num of an object by x amount + /// Increment a num value associated with a given [key] by the given [amount] void setIncrement(String key, num amount) { - set>( - key, {'__op': 'Increment', 'amount': amount}); + set(key, _ParseIncrementOperation(amount)); } - /// Decreases a num of an object by x amount + /// Decrement a num value associated with a given [key] by the given [amount] void setDecrement(String key, num amount) { - set>( - key, {'__op': 'Increment', 'amount': -amount}); + set(key, _ParseIncrementOperation(-amount)); } /// Can be used set an objects variable to undefined rather than null @@ -417,29 +565,37 @@ class ParseObject extends ParseBase implements ParseCloneable { return ParseResponse()..success = true; } + if (objectId == null) { + return ParseResponse()..success = false; + } + try { - if (objectId != null) { - final Uri url = getSanitisedUri(_client, '$_path/$objectId'); - final String body = '{"$key":{"__op":"Delete"}}'; - final ParseNetworkResponse result = - await _client.put(url.toString(), data: body); - final ParseResponse response = handleResponse( - this, result, ParseApiRQ.unset, _debug, parseClassName); - if (!response.success) { - _objectData[key] = object; - _unsavedChanges[key] = object; - _savingChanges[key] = object; - } else { - return ParseResponse()..success = true; - } + final Uri url = getSanitisedUri(_client, '$_path/$objectId'); + + final String body = '{"$key":{"__op":"Delete"}}'; + + final ParseNetworkResponse result = + await _client.put(url.toString(), data: body); + + final ParseResponse response = handleResponse( + this, result, ParseApiRQ.unset, _debug, parseClassName); + + if (response.success) { + return ParseResponse()..success = true; + } else { + _objectData[key] = object; + _unsavedChanges[key] = object; + _savingChanges[key] = object; + + return response; } - } on Exception { + } on Exception catch (e) { _objectData[key] = object; _unsavedChanges[key] = object; _savingChanges[key] = object; - } - return ParseResponse()..success = false; + return handleException(e, ParseApiRQ.unset, _debug, parseClassName); + } } /// Can be used to create custom queries @@ -501,10 +657,13 @@ class ParseObject extends ParseBase implements ParseCloneable { } } - ///Fetches this object with the data from the server. Call this whenever you want the state of the - ///object to reflect exactly what is on the server. + /// Fetches this object with the data from the server. + /// + /// Call this whenever you want the state of the object to reflect exactly + /// what is on the server. /// - /// `List` include refers to other ParseObjects stored as a Pointer + /// [include], is a list of [ParseObject]s keys to be included directly and + /// not as a pointer. Future fetch({List? include}) async { if (objectId == null || objectId!.isEmpty) { throw 'can not fetch without a objectId'; diff --git a/packages/dart/lib/src/objects/parse_operation/parse_add_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_add_operation.dart new file mode 100644 index 000000000..16f892de6 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_add_operation.dart @@ -0,0 +1,39 @@ +part of flutter_parse_sdk; + +/// An operation that adds a new element to an array +class _ParseAddOperation extends _ParseArrayOperation { + _ParseAddOperation(List value) : super(value); + + @override + String get operationName => 'Add'; + + @override + bool canMergeWith(Object other) { + return other is _ParseAddOperation || other is _ParseArray; + } + + @override + _ParseOperation merge(Object previous) { + final List previousValue; + + if (previous is _ParseArray) { + previousValue = previous.estimatedArray; + + if (previous.savedArray.isEmpty) { + valueForApiRequest.addAll(previous.estimatedArray); + } + } else { + final previousAdd = (previous as _ParseAddOperation); + + previousValue = previousAdd.value; + + valueForApiRequest.addAll(previousAdd.valueForApiRequest); + } + + valueForApiRequest.addAll(value); + + value = [...previousValue, ...value]; + + return this; + } +} diff --git a/packages/dart/lib/src/objects/parse_operation/parse_add_relation_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_add_relation_operation.dart new file mode 100644 index 000000000..716868096 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_add_relation_operation.dart @@ -0,0 +1,40 @@ +part of flutter_parse_sdk; + +/// An operation that adds new objects to a [ParseRelation] +class _ParseAddRelationOperation extends _ParseRelationOperation { + _ParseAddRelationOperation(Set value) : super(value); + + @override + String get operationName => 'AddRelation'; + + @override + bool canMergeWith(Object other) { + return other is _ParseAddRelationOperation || other is _ParseRelation; + } + + @override + _ParseOperation> merge(Object previous) { + Set previousValue = {}; + + if (previous is _ParseRelation) { + previousValue = previous.knownObjects.toSet(); + } else { + final previousAdd = (previous as _ParseAddRelationOperation); + + previousValue = previousAdd.value.toSet(); + + valueForApiRequest.addAll(previousAdd.valueForApiRequest); + } + + valueForApiRequest.addAll(value); + + value = {...previousValue, ...value}; + + value = Set.from(removeDuplicateParseObjectByObjectId(value)); + + valueForApiRequest = + Set.from(removeDuplicateParseObjectByObjectId(valueForApiRequest)); + + return this; + } +} diff --git a/packages/dart/lib/src/objects/parse_operation/parse_add_unique_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_add_unique_operation.dart new file mode 100644 index 000000000..a7cb87ceb --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_add_unique_operation.dart @@ -0,0 +1,52 @@ +part of flutter_parse_sdk; + +/// An operation that adds a new element to an array field, +/// only if it wasn't already present +class _ParseAddUniqueOperation extends _ParseArrayOperation { + _ParseAddUniqueOperation(List value) : super(value); + + @override + String get operationName => 'AddUnique'; + + @override + bool canMergeWith(Object other) { + return other is _ParseAddUniqueOperation || other is _ParseArray; + } + + @override + _ParseOperation merge(Object previous) { + final List previousValue; + + value = value.toSet().toList(); + + // if the previous is _ParseArray this indicates that this operation + // is the first operation on this array + if (previous is _ParseArray) { + previousValue = previous.estimatedArray; + + if (previous.savedArray.isEmpty) { + valueForApiRequest.addAll(previous.estimatedArray.toSet()); + } + } else { + final previousAddUnique = (previous as _ParseAddUniqueOperation); + + previousValue = previousAddUnique.value; + + valueForApiRequest.addAll(previousAddUnique.valueForApiRequest); + } + + valueForApiRequest.addAll(value); + + value = [ + ...previousValue, + ...value.where((element) => !previousValue.contains(element)), + ]; + + value = removeDuplicateParseObjectByObjectId(value); + + valueForApiRequest = + removeDuplicateParseObjectByObjectId(valueForApiRequest); + + return this; + } +} diff --git a/packages/dart/lib/src/objects/parse_operation/parse_increment_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_increment_operation.dart new file mode 100644 index 000000000..765cf3494 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_increment_operation.dart @@ -0,0 +1,33 @@ +part of flutter_parse_sdk; + +/// An operation that increment a numeric value by a given amount +class _ParseIncrementOperation extends _ParseNumberOperation { + _ParseIncrementOperation(num value) : super(value); + + @override + String get operationName => 'Increment'; + + @override + bool canMergeWith(Object other) { + return other is _ParseIncrementOperation || other is _ParseNumber; + } + + @override + _ParseOperation merge(Object previous) { + final num previousValue; + + if (previous is _ParseNumber) { + previousValue = previous.estimateNumber; + valueForApiRequest += previous.estimateNumber - previous.savedNumber; + } else { + final previousIncrement = (previous as _ParseIncrementOperation); + previousValue = previousIncrement.value; + + valueForApiRequest += previousIncrement.valueForApiRequest; + } + + value = value + previousValue; + + return this; + } +} diff --git a/packages/dart/lib/src/objects/parse_operation/parse_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_operation.dart new file mode 100644 index 000000000..68fc92ee4 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_operation.dart @@ -0,0 +1,314 @@ +part of flutter_parse_sdk; + +/// Represents an operation performed on Parse data. It defines the core +/// functionality of any operation performed on Parse data. +abstract class _ParseOperation implements _Valuable { + /// Used to store the estimated value for operation. + /// + /// This is what the user will see as the result of any operation on the data. + /// For example, if the operation is an array addition and the user wants + /// to add the value 4 to the list, and the list originally looks like this: + /// [1,2,3], then the [value] variable will initially hold [1,2,3]. + /// After the addition operation is performed, the [value] variable will + /// hold [1,2,3,4], which is what the user will see. + /// The add operation itself will be stored in a separate variable, + /// [valueForApiRequest], which will only hold the data that needs + /// to be sent to the server, in this case [4]. + T value; + + /// The actual that will be sent to the server. + late T valueForApiRequest; + + _ParseOperation(this.value); + + /// The name of the preformed operation. + /// + /// This value can be used when sending the operation to the server. + /// + /// e.g: Add, AddUnique, Remove, Increment, AddRelation, RemoveRelation + String get operationName; + + /// Checks if [other] can be merged with the current operation. + /// + /// Some operations can be merged with others, and each operation defines + /// what can be merged. For example, an Add operation can be merged with + /// another Add operation or with a [_ParseArray] object. + bool canMergeWith(Object other); + + /// Preform the merge between [previous] and current operation. + /// + /// This should be called after [canMergeWith] to check if the [previous] + /// operation is eligible to merge with the current operation + /// + /// Will return the current(this) operation merged with the other(previous) + /// operation + _ParseOperation merge(Object previous); + + /// Merges the current operation with the [previous] operation if possible. + /// + /// Throws a [_UnmergeableOperationException] if the [previous] operation + /// cannot be merged with the current operation. + _ParseOperation mergeWithPrevious(Object previous) { + if (!canMergeWith(previous)) { + throw _UnmergeableOperationException(this, previous); + } + + return merge(previous); + } + + /// Convert the operation to json format (Map). + /// + /// Will be used to be sent the operation to the server or to store the + /// operation in the cache. When [full] is true that should indicate that + /// the intention of converting to json is to store the operation + /// in the local cache + Map toJson({bool full = false}); + + /// construct a new value of [newValue] to be used in parse object. + /// + /// * If the [newValue] is [Iterable] will return [_ParseArray] + /// * If the [newValue] is [num] will return [_ParseNumber] + /// * If the [newValue] is [_ParseOperation] will try to merge the this + /// operation with the [previousValue] and return this operation merged + /// with the [previousValue] if possible. + /// * Otherwise will return the [newValue] as it is. + static Object? maybeMergeWithPrevious({ + required R newValue, + required Object? previousValue, + required ParseObject parent, + required String key, + }) { + if (newValue is Iterable) { + return _ParseArray(setMode: true)..estimatedArray = newValue.toList(); + } + + if (newValue is num) { + return _ParseNumber(newValue, setMode: true); + } + + if (newValue is _ParseOperation) { + return _handelOperation(newValue, previousValue, parent, key); + } + + return newValue; + } + + static Object _handelOperation( + R newValue, + Object? previousValue, + ParseObject parent, + String key, + ) { + if (newValue is _ParseNumberOperation) { + return _handelNumOperation(newValue, previousValue); + } + + if (newValue is _ParseArrayOperation) { + return _handelArrayOperation(newValue, previousValue); + } + + if (newValue is _ParseRelationOperation) { + return _handelRelationOperation(newValue, previousValue, parent, key); + } + + throw ParseOperationException( + 'operation ${newValue.runtimeType} not implemented'); + } + + static _ParseNumber _handelNumOperation( + _ParseNumberOperation numberOperation, + Object? previousValue, + ) { + if (previousValue is _ParseNumber) { + return previousValue.preformNumberOperation(numberOperation); + } + + if (previousValue == null) { + return _ParseNumber(0).preformNumberOperation(numberOperation); + } + + throw ParseOperationException( + 'wrong key, unable to preform numeric operation on' + ' the previous value: ${previousValue.runtimeType}'); + } + + static _ParseArray _handelArrayOperation( + _ParseArrayOperation arrayOperation, + Object? previousValue, + ) { + if (previousValue is _ParseArray) { + return previousValue.preformArrayOperation(arrayOperation); + } + + if (previousValue == null) { + return _ParseArray().preformArrayOperation(arrayOperation); + } + + throw ParseOperationException( + 'wrong key, unable to preform Array operation on' + ' the previous value: ${previousValue.runtimeType}'); + } + + static _ParseRelation _handelRelationOperation( + _ParseRelationOperation relationOperation, + Object? previousValue, + ParseObject parent, + String key, + ) { + if (previousValue is _ParseRelation) { + return previousValue.preformRelationOperation(relationOperation); + } + + if (previousValue == null) { + return _ParseRelation(parent: parent, key: key) + .preformRelationOperation(relationOperation); + } + + throw ParseOperationException( + 'wrong key, unable to preform Relation operation on' + ' the previous value: ${previousValue.runtimeType}'); + } + + /// Returns the estimated value of this operation. + @override + T getValue() { + if (value is Iterable) { + // return as new Iterable to prevent the user from mutating the internal list state + return (value as Iterable).cast() as T; + } + + return value; + } +} + +abstract class _ParseArrayOperation extends _ParseOperation { + _ParseArrayOperation(List value) : super(value) { + super.valueForApiRequest = []; + } + + @override + Map toJson({bool full = false}) { + if (full) { + return { + '__op': operationName, + 'objects': parseEncode(value, full: full), + 'valueForAPIRequest': parseEncode(valueForApiRequest, full: full), + }; + } + + return { + '__op': operationName, + 'objects': parseEncode(valueForApiRequest, full: full), + }; + } + + static _ParseArrayOperation? fromFullJson(Map json) { + final List objects = parseDecode(json['objects']); + final List? objectsForAPIRequest = parseDecode(json['valueForAPIRequest']); + + final _ParseArrayOperation arrayOperation; + switch (json['__op']) { + case 'Add': + arrayOperation = _ParseAddOperation(objects); + break; + case 'Remove': + arrayOperation = _ParseRemoveOperation(objects); + break; + case 'AddUnique': + arrayOperation = _ParseAddUniqueOperation(objects); + break; + default: + return null; + } + + arrayOperation.valueForApiRequest = objectsForAPIRequest ?? []; + + return arrayOperation; + } +} + +abstract class _ParseRelationOperation + extends _ParseOperation> { + _ParseRelationOperation(Set value) : super(value) { + super.valueForApiRequest = {}; + } + + static _ParseRelationOperation? fromFullJson(Map json) { + final Set objects = + Set.from(parseDecode(json['objects']) ?? {}); + + final Set? objectsForAPIRequest = + json['valueForAPIRequest'] == null + ? null + : Set.from(parseDecode(json['valueForAPIRequest'])); + + final _ParseRelationOperation relationOperation; + switch (json['__op']) { + case 'AddRelation': + relationOperation = _ParseAddRelationOperation(objects); + break; + case 'RemoveRelation': + relationOperation = _ParseRemoveRelationOperation(objects); + break; + + default: + return null; + } + + relationOperation.valueForApiRequest = objectsForAPIRequest ?? {}; + + return relationOperation; + } + + @override + Map toJson({bool full = false}) { + if (full) { + return { + '__op': operationName, + 'objects': parseEncode(value, full: full), + 'valueForAPIRequest': parseEncode(valueForApiRequest, full: full), + }; + } + return { + '__op': operationName, + 'objects': parseEncode(valueForApiRequest, full: full) + }; + } +} + +abstract class _ParseNumberOperation extends _ParseOperation { + _ParseNumberOperation(num value) : super(value) { + super.valueForApiRequest = value; + } + + @override + Map toJson({bool full = false}) { + if (full) { + return { + '__op': operationName, + 'amount': valueForApiRequest, + 'estimatedValue': value + }; + } + + return {'__op': operationName, 'amount': valueForApiRequest}; + } + + static _ParseNumberOperation? fromFullJson(Map json) { + final num estimatedValueFromJson = json['estimatedValue'] as num; + final num valueForApiRequestFromJson = json['amount'] as num; + + final _ParseNumberOperation parseNumberOperation; + switch (json['__op']) { + case 'Increment': + parseNumberOperation = _ParseIncrementOperation(estimatedValueFromJson); + break; + default: + return null; + } + + parseNumberOperation.valueForApiRequest = valueForApiRequestFromJson; + + return parseNumberOperation; + } +} diff --git a/packages/dart/lib/src/objects/parse_operation/parse_remove_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_remove_operation.dart new file mode 100644 index 000000000..6ac974c4b --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_remove_operation.dart @@ -0,0 +1,38 @@ +part of flutter_parse_sdk; + +/// An operation that removes every instance of an element from an array +class _ParseRemoveOperation extends _ParseArrayOperation { + _ParseRemoveOperation(List value) : super(value); + + @override + String get operationName => 'Remove'; + + @override + bool canMergeWith(Object other) { + return other is _ParseRemoveOperation || other is _ParseArray; + } + + @override + _ParseOperation merge(Object previous) { + final List previousValue; + + valueForApiRequest.addAll(value.toSet()); + + if (previous is _ParseArray) { + previousValue = previous.estimatedArray; + } else { + final previousRemove = (previous as _ParseRemoveOperation); + + previousValue = previousRemove.value; + + valueForApiRequest = { + ...valueForApiRequest, + ...previousRemove.valueForApiRequest, + }.toList(); + } + + value = [...previousValue]..removeWhere((e) => value.contains(e)); + + return this; + } +} diff --git a/packages/dart/lib/src/objects/parse_operation/parse_remove_relation_operation.dart b/packages/dart/lib/src/objects/parse_operation/parse_remove_relation_operation.dart new file mode 100644 index 000000000..725701ba3 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_operation/parse_remove_relation_operation.dart @@ -0,0 +1,45 @@ +part of flutter_parse_sdk; + +/// An operation that Removes objects from a [ParseRelation] +class _ParseRemoveRelationOperation extends _ParseRelationOperation { + _ParseRemoveRelationOperation(Set value) : super(value); + + @override + String get operationName => 'RemoveRelation'; + + @override + bool canMergeWith(Object other) { + return other is _ParseRemoveRelationOperation || other is _ParseRelation; + } + + @override + _ParseOperation> merge(Object previous) { + Set previousValue = {}; + + if (previous is _ParseRelation) { + previousValue = previous.knownObjects.toSet(); + } else { + final previousRemove = (previous as _ParseRemoveRelationOperation); + + previousValue = previousRemove.value.toSet(); + + valueForApiRequest.addAll(previousRemove.valueForApiRequest); + } + + valueForApiRequest.addAll(value); + + final parseObjectToRemoveByIds = + value.where((e) => e.objectId != null).map((e) => e.objectId!); + + value = previousValue + ..removeWhere((e) => + value.contains(e) || parseObjectToRemoveByIds.contains(e.objectId)); + + value = Set.from(removeDuplicateParseObjectByObjectId(value)); + + valueForApiRequest = + Set.from(removeDuplicateParseObjectByObjectId(valueForApiRequest)); + + return this; + } +} diff --git a/packages/dart/lib/src/objects/parse_relation.dart b/packages/dart/lib/src/objects/parse_relation.dart index 678414821..16d75f627 100644 --- a/packages/dart/lib/src/objects/parse_relation.dart +++ b/packages/dart/lib/src/objects/parse_relation.dart @@ -1,55 +1,273 @@ part of flutter_parse_sdk; -// ignore_for_file: always_specify_types -class ParseRelation { - ParseRelation({required ParseObject parent, required String key}) { - if (!parent.containsKey(key)) { - throw 'Invalid Relation key name'; - } - _targetClass = parent.get(key)!.getTargetClass; - _parent = parent; - _key = key; - _parentObjectId = parent.objectId!; +abstract class ParseRelation { + //The owning object of this ParseRelation + ParseObject getParent(); + + //The key of the relation in the parent object. i.e. the column name + String getKey(); + + factory ParseRelation({ + required ParseObject parent, + required String key, + }) { + return _ParseRelation(parent: parent, key: key); } - ParseRelation.fromJson(Map map) { - _knownObjects = parseDecode(map['objects']); - _targetClass = map['className']; + /// The className of the target objects. + @Deprecated('use the targetClass getter') + String get getTargetClass; + + /// The className of the target objects. + String? get targetClass; + + /// Will work only if the current target class is null, otherwise will throw + /// [ParseRelationException] with the message: + /// The target class can not be modified if it is already set + set setTargetClass(String targetClass); + + /// Gets a query that can be used to query the objects in this relation. + /// + /// Return a [QueryBuilder] that restricts the results to objects in this relation + QueryBuilder getQuery(); + + /// Add object to this relation + void add(T parseObject); + + /// Add objects to this relation. + void addAll(List parseObjects); + + /// Remove object from this relation + void remove(T parseObject); + + /// Remove objects from this relation + void removeAll(List parseObjects); + + factory ParseRelation.fromJson( + Map map, { + ParseObject? parent, + String? key, + }) { + return _ParseRelation.fromJson(map, parent: parent, key: key); } - //The owning object of this ParseRelation - ParseObject? _parent; - // The object Id of the parent. - String _parentObjectId = ''; - //The className of the target objects. + Map toJson({bool full = false}); +} + +class _ParseRelation + implements ParseRelation, _ParseSaveStateAwareChild { String? _targetClass; - //The key of the relation in the parent object. - String _key = ''; - //For offline caching, we keep track of every object we've known to be in the relation. - Set? _knownObjects = {}; + ParseObject? parent; + + String? key; + + // For offline caching, we keep track of every object + // we've known to be in the relation. + Set knownObjects = {}; + + _ParseRelationOperation? lastPreformedOperation; + + _ParseRelation({required this.parent, required this.key}); + + Set valueForApiRequest() { + return lastPreformedOperation?.valueForApiRequest ?? {}; + } + + @override + ParseObject getParent() { + return parent!; + } + + @override + String getKey() { + return key!; + } + + _ParseRelation preformRelationOperation( + _ParseRelationOperation relationOperation, + ) { + resolveTargetClassFromRelationObjets(relationOperation.value); + + relationOperation.mergeWithPrevious(lastPreformedOperation ?? this); + + lastPreformedOperation = relationOperation; + + knownObjects = lastPreformedOperation!.value.toSet() as Set; + + return this; + } + + @override QueryBuilder getQuery() { - return QueryBuilder(ParseCoreData.instance.createObject(_targetClass!)) - ..whereRelatedTo(_key, _parent!.parseClassName, _parentObjectId); + final parentClassName = parent!.parseClassName; + final parentObjectId = parent!.objectId; + + if (parentObjectId == null) { + throw ParseRelationException( + 'The parent objectId is null. Query based on a Relation require ObjectId'); + } + + final QueryBuilder queryBuilder; + + if (_targetClass == null) { + queryBuilder = QueryBuilder(ParseObject(parentClassName)) + ..setRedirectClassNameForKey(key!); + } else { + queryBuilder = QueryBuilder( + ParseCoreData.instance.createObject(_targetClass!), + ); + } + + return queryBuilder..whereRelatedTo(key!, parentClassName, parentObjectId); } - void add(T object) { - _targetClass = object.parseClassName; - _knownObjects!.add(object); - _parent!.addRelation(_key, _knownObjects!.toList()); + @override + void add(T parseObject) { + parent!.addRelation(key!, [parseObject]); } - void remove(T object) { - _targetClass = object.parseClassName; - _knownObjects!.remove(object); - _parent!.removeRelation(_key, _knownObjects!.toList()); + @override + void addAll(List parseObjects) { + parent!.addRelation(key!, parseObjects); } + @override + void remove(T parseObject) { + parent!.removeRelation(key!, [parseObject]); + } + + @override + void removeAll(List parseObjects) { + parent!.removeRelation(key!, parseObjects); + } + + @override String get getTargetClass => _targetClass ?? ''; - Map toJson() => { - '__type': keyRelation, - 'className': _targetClass, - 'objects': parseEncode(_knownObjects?.toList()) + @override + String? get targetClass => _targetClass; + + @override + set setTargetClass(String targetClass) { + assert(targetClass.isNotEmpty); + + _targetClass ??= targetClass; + + if (_targetClass != targetClass) { + throw ParseRelationException( + 'The target class can not be modified if it is already set'); + } + } + + _ParseRelation.fromJson( + Map json, { + ParseObject? parent, + String? key, + }) { + if (parent != null) { + this.parent = parent; + } + if (key != null) { + this.key = key; + } + + knownObjects = Set.from(parseDecode(json['objects']) ?? {}); + _targetClass = json['className']; + } + + _ParseRelation.fromFullJson(Map json) { + knownObjects = Set.from(parseDecode(json['objects'])); + _targetClass = json['targetClass']; + key = json['key']; + knownObjects = Set.from(parseDecode(json['objects']) ?? {}); + lastPreformedOperation = json['lastPreformedOperation'] == null + ? null + : _ParseRelationOperation.fromFullJson(json['lastPreformedOperation']); + } + + @override + Map toJson({bool full = false}) { + if (full) { + return { + 'className': 'ParseRelation', + 'targetClass': targetClass, + 'key': key, + 'objects': parseEncode(knownObjects, full: full), + 'lastPreformedOperation': lastPreformedOperation?.toJson(full: full) }; + } + + return lastPreformedOperation?.toJson(full: full) ?? {}; + } + + bool shouldIncludeInRequest() { + return lastPreformedOperation?.valueForApiRequest.isNotEmpty ?? false; + } + + void resolveTargetClassFromRelationObjets(Set relationObjects) { + var potentialTargetClass = _targetClass; + + for (final parseObject in relationObjects) { + potentialTargetClass = parseObject.parseClassName; + + if (_targetClass != null && potentialTargetClass != _targetClass) { + throw ParseRelationException( + 'Can not add more then one class for a relation. the current target ' + 'class $targetClass and the passed class $potentialTargetClass'); + } + } + + _targetClass = potentialTargetClass; + } + + _ParseRelationOperation? _lastPreformedOperationBeforeSaving; + List? _valueForApiRequestBeforeSaving; + + @override + void onSaved() { + if (_lastPreformedOperationBeforeSaving == lastPreformedOperation) { + // No operations were performed during the save process + lastPreformedOperation = null; + } else { + // remove the saved objects and keep the new added objects while saving + lastPreformedOperation?.valueForApiRequest + .removeAll(_valueForApiRequestBeforeSaving ?? []); + } + + _lastPreformedOperationBeforeSaving = null; + _valueForApiRequestBeforeSaving = null; + } + + @override + void onSaving() { + _lastPreformedOperationBeforeSaving = lastPreformedOperation; + _valueForApiRequestBeforeSaving = + lastPreformedOperation?.valueForApiRequest.toList(); + } + + @override + void onRevertSaving() { + _lastPreformedOperationBeforeSaving = null; + _valueForApiRequestBeforeSaving = null; + } + + @override + void onErrorSaving() { + _lastPreformedOperationBeforeSaving = null; + _valueForApiRequestBeforeSaving = null; + } + + @override + void onClearUnsaved() { + if (lastPreformedOperation != null) { + knownObjects.removeWhere( + (e) => lastPreformedOperation!.valueForApiRequest.contains(e), + ); + } + + lastPreformedOperation = null; + _lastPreformedOperationBeforeSaving = null; + _valueForApiRequestBeforeSaving = null; + } } diff --git a/packages/dart/lib/src/objects/parse_save_state_aware_child.dart b/packages/dart/lib/src/objects/parse_save_state_aware_child.dart new file mode 100644 index 000000000..32fe4a5a1 --- /dev/null +++ b/packages/dart/lib/src/objects/parse_save_state_aware_child.dart @@ -0,0 +1,72 @@ +part of flutter_parse_sdk; + +/// An interface used to notify a child about its parent save state. +/// +/// x x +/// │ │ +/// ┌────▼─────┐ ┌───────▼────────┐ +/// ┌────────┤ onSaving │ │ onClearUnsaved │ +/// │ └─────┬────┘ └────────────────┘ +/// │ │ +/// ┌───────▼───────┐ ┌────▼────┐ +/// │ onErrorSaving │ │ onSaved │ +/// └───────┬───────┘ └─────────┘ +/// │ +/// ┌───────▼────────┐ +/// │ onRevertSaving │ +/// └────────────────┘ +/// +/// Each Parse data type should implement this interface. +/// The parent object will notify any child that implements this interface about +/// the state of the saving operation in the parent object +/// (i.e. saving, error saving, saved, revert saving, clear unsaved) +/// so the child can react to the save state. For instance, +/// when the parent notifies the children about (clear unsaved), +/// every Parse data type should clear its internal state, +/// keep only the saved data, and dispose of any unsaved data. +/// Another example is when the parent notifies the children about (being saved), +/// which means that the parent has been saved successfully. In this case, +/// every child should move its internal data from the unsaved state to the saved state. +/// +/// +/// The following classes make use of this interface: +/// +/// * [_ParseArray], used to encapsulate a list and perform ParseArray operations on it +/// * [_ParseRelation], used to represent a Parse Relation and perform operations on the relation +/// * [_ParseNumber], used to encapsulate a num datatype and perform Parse operations on it. +abstract class _ParseSaveStateAwareChild { + /// called when the parent object has been saved successfully. + /// + /// its safe to move any unsaved data to saved state + void onSaved(); + + /// called when the parent object attempts to save itself. + /// + /// At this stage, you can copy any unsaved data to a temporary variable so + /// that you can move it to the saved state if the parent saves successfully. + /// You need to take into account any operations that could be performed + /// while the parent is being saved, and thus you should cache the current + /// unsaved data in a separate variable. Then, when the parent saves + /// successfully, you should move only the saved data to the saved state. + void onSaving(); + + /// called when the parent object fails to save itself. + /// + /// At this stage, you can dispose any temporary data that was created + /// during [onSaving] + void onErrorSaving(); + + /// called when the parent object fails to save itself during a patch operation. + /// + /// In this scenario, the parent is part of a save operation for another object. + /// This event will only be triggered after [onErrorSaving] if the parent + /// is part of a save operation for another object + void onRevertSaving(); + + /// called when the parent object needs to clear all unsaved data. + /// + /// At this stage, any unsaved data or operations should be discarded, + /// and the data should be reverted back to its original state + /// before any modifications were made + void onClearUnsaved(); +} diff --git a/packages/dart/lib/src/objects/response/parse_error_response.dart b/packages/dart/lib/src/objects/response/parse_error_response.dart index b9097c2ed..665de480b 100644 --- a/packages/dart/lib/src/objects/response/parse_error_response.dart +++ b/packages/dart/lib/src/objects/response/parse_error_response.dart @@ -4,9 +4,13 @@ part of flutter_parse_sdk; ParseResponse buildErrorResponse( ParseResponse response, ParseNetworkResponse apiResponse) { final Map responseData = json.decode(apiResponse.data); + response.error = ParseError( - code: responseData[keyCode] ?? ParseError.otherCause, - message: responseData[keyError].toString()); + code: responseData[keyCode] ?? ParseError.otherCause, + message: responseData[keyError].toString(), + ); + response.statusCode = responseData[keyCode] ?? ParseError.otherCause; + return response; } diff --git a/packages/dart/lib/src/objects/response/parse_response_builder.dart b/packages/dart/lib/src/objects/response/parse_response_builder.dart index 404091993..706cd1d28 100644 --- a/packages/dart/lib/src/objects/response/parse_response_builder.dart +++ b/packages/dart/lib/src/objects/response/parse_response_builder.dart @@ -151,7 +151,8 @@ class _ParseResponseBuilder { return object ..fromJson(map) .._unsavedChanges.clear() - .._unsavedChanges.addAll(unsaved); + .._unsavedChanges.addAll(unsaved) + .._notifyChildrenAboutSave(); } else { return null; } diff --git a/packages/dart/lib/src/utils/parse_decoder.dart b/packages/dart/lib/src/utils/parse_decoder.dart index 331f1173a..252793ee3 100644 --- a/packages/dart/lib/src/utils/parse_decoder.dart +++ b/packages/dart/lib/src/utils/parse_decoder.dart @@ -1,11 +1,7 @@ part of flutter_parse_sdk; -List _convertJSONArrayToList(List array) { - final List list = []; - for (final dynamic item in array) { - list.add(parseDecode(item)); - } - return list; +List _convertJSONArrayToList(List array) { + return array.map(parseDecode).toList(); } Map _convertJSONObjectToMap(Map object) { @@ -71,7 +67,6 @@ dynamic parseDecode(dynamic value) { return ParseGeoPoint( latitude: latitude.toDouble(), longitude: longitude.toDouble()); case 'Relation': - // ignore: always_specify_types return ParseRelation.fromJson(map); } } @@ -83,7 +78,19 @@ dynamic parseDecode(dynamic value) { final num latitude = map['latitude'] ?? 0.0; final num longitude = map['longitude'] ?? 0.0; return ParseGeoPoint( - latitude: latitude.toDouble(), longitude: longitude.toDouble()); + latitude: latitude.toDouble(), + longitude: longitude.toDouble(), + ); + + case 'ParseArray': + return _ParseArray.fromFullJson(map); + + case 'ParseNumber': + return _ParseNumber.fromFullJson(map); + + case 'ParseRelation': + return _ParseRelation.fromFullJson(map); + default: return ParseCoreData.instance .createObject(map['className']) diff --git a/packages/dart/lib/src/utils/parse_encoder.dart b/packages/dart/lib/src/utils/parse_encoder.dart index e8b2080f5..d712acb83 100644 --- a/packages/dart/lib/src/utils/parse_encoder.dart +++ b/packages/dart/lib/src/utils/parse_encoder.dart @@ -18,12 +18,24 @@ dynamic parseEncode(dynamic value, {bool full = false}) { return _encodeDate(value); } - if (value is List) { + if (value is Iterable) { return value.map((dynamic value) { return parseEncode(value, full: full); }).toList(); } + if (value is _ParseArray) { + return value.toJson(full: full); + } + + if (value is _ParseNumber) { + return value.toJson(full: full); + } + + if (value is _ParseOperation) { + return value.toJson(full: full); + } + if (value is Map) { value.forEach((dynamic k, dynamic v) { value[k] = parseEncode(v, full: full); @@ -31,18 +43,18 @@ dynamic parseEncode(dynamic value, {bool full = false}) { } if (value is ParseGeoPoint) { - return value; + return value.toJson(full: full); } if (value is ParseFileBase) { - return value; + return value.toJson(full: full); } if (value is ParseRelation) { - return value; + return value.toJson(full: full); } - if (value is ParseObject || value is ParseUser) { + if (value is ParseObject) { if (full) { return value.toJson(full: full); } else { diff --git a/packages/dart/lib/src/utils/parse_utils.dart b/packages/dart/lib/src/utils/parse_utils.dart index 16c5ed4ec..03bb37781 100644 --- a/packages/dart/lib/src/utils/parse_utils.dart +++ b/packages/dart/lib/src/utils/parse_utils.dart @@ -104,3 +104,26 @@ Future batchRequest( Stream _createStreamError(Object error) async* { throw error; } + +List removeDuplicateParseObjectByObjectId(Iterable iterable) { + final list = iterable.toList(); + + final foldedGroupedByObjectId = list + .whereType() + .where((e) => e.objectId != null) + .groupFoldBy( + (e) => e.objectId!, + (previous, element) => element, + ); + + list.removeWhere( + (e) { + return e is ParseObject && + foldedGroupedByObjectId.keys.contains(e.objectId); + }, + ); + + list.addAll(foldedGroupedByObjectId.values); + + return list; +} diff --git a/packages/dart/lib/src/utils/valuable.dart b/packages/dart/lib/src/utils/valuable.dart new file mode 100644 index 000000000..8c1c79e9a --- /dev/null +++ b/packages/dart/lib/src/utils/valuable.dart @@ -0,0 +1,15 @@ +part of flutter_parse_sdk; + +/// A unified interface used to expose the internal state of a private class. +/// +/// Use this interface to expose internal state to end users. +/// For example, [_ParseArray] implements this interface to expose +/// its [estimatedArray] property to end-users. +/// +/// Note that any state exposed through this interface will be directly +/// accessible to end users. To prevent unintended manipulation, return copies +/// of internal state rather than references. +abstract class _Valuable { + /// provide access to an internal value of a class + T getValue(); +} diff --git a/packages/dart/pubspec.yaml b/packages/dart/pubspec.yaml index 245e68bb0..931e3d9b4 100644 --- a/packages/dart/pubspec.yaml +++ b/packages/dart/pubspec.yaml @@ -1,6 +1,6 @@ name: parse_server_sdk description: The Dart SDK for Parse Platform (https://parseplatform.org) -version: 4.0.2 +version: 5.0.0 homepage: https://github.com/parse-community/Parse-SDK-Flutter environment: @@ -24,6 +24,7 @@ dependencies: mime_type: ^1.0.0 timezone: ^0.9.1 universal_io: ^2.2.0 + collection: ^1.17.1 dev_dependencies: lints: ^2.0.1 @@ -31,4 +32,3 @@ dev_dependencies: build_runner: ^2.3.3 mockito: ^5.3.2 test: ^1.23.1 - collection: ^1.17.1 diff --git a/packages/dart/test/parse_encoder_test.dart b/packages/dart/test/parse_encoder_test.dart deleted file mode 100644 index dc22fea38..000000000 --- a/packages/dart/test/parse_encoder_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:convert'; - -import 'package:parse_server_sdk/parse_server_sdk.dart'; -import 'package:test/test.dart'; - -void main() { - test( - 'should return expectedResult json when json has Nested map and list data.', - () async { - // arrange - await Parse().initialize( - 'appId', - 'https://example.com', - debug: true, - // to prevent automatic detection - fileDirectory: 'someDirectory', - // to prevent automatic detection - appName: 'appName', - // to prevent automatic detection - appPackageName: 'somePackageName', - // to prevent automatic detection - appVersion: 'someAppVersion', - ); - - // act - ParseObject parseObject2 = ParseObject("objectId2"); - parseObject2.objectId = "objectId2"; - - parseObject2 - .setAdd("dataParseObjectList", ["ListText1", "ListText2", "ListText3"]); - parseObject2.setAdd("dataParseObjectMap", { - 'KeyTestMap1': 'ValueTestMap1', - 'KeyTestMap2': 'ValueTestMap2', - 'KeyTestMap3': 'ValueTestMap3', - }); - - ParseObject parseObject1 = ParseObject("parseObject1"); - parseObject1.objectId = "objectId1"; - parseObject1.setAdd("dataParseObject2", parseObject2); - - dynamic actualResult = parseEncode(parseObject1, full: true); - - var objectDesiredOutput = { - "className": "parseObject1", - "objectId": "objectId1", - "dataParseObject2": { - "__op": "Add", - "objects": [ - { - "className": "objectId2", - "objectId": "objectId2", - "dataParseObjectList": { - "__op": "Add", - "objects": [ - ["ListText1", "ListText2", "ListText3"] - ], - }, - "dataParseObjectMap": { - "__op": "Add", - "objects": [ - { - "KeyTestMap1": "ValueTestMap1", - "KeyTestMap2": "ValueTestMap2", - "KeyTestMap3": "ValueTestMap3" - } - ] - } - } - ], - }, - }; - var objectJsonDesiredOutput = jsonEncode(objectDesiredOutput); - - // assert - expect(jsonEncode(actualResult), objectJsonDesiredOutput); - }); -} diff --git a/packages/dart/test/parse_query_test.dart b/packages/dart/test/src/network/parse_query_test.dart similarity index 95% rename from packages/dart/test/parse_query_test.dart rename to packages/dart/test/src/network/parse_query_test.dart index d6f225ab0..b6b8f4472 100644 --- a/packages/dart/test/parse_query_test.dart +++ b/packages/dart/test/src/network/parse_query_test.dart @@ -1,30 +1,24 @@ +import 'dart:convert'; + import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'package:test/test.dart'; -import 'dart:convert'; -import 'parse_query_test.mocks.dart'; + +import '../../parse_query_test.mocks.dart'; +import '../../test_utils.dart'; @GenerateMocks([ParseClient]) void main() { + setUpAll(() async { + await initializeParse(); + }); + group('queryBuilder', () { late MockParseClient client; - setUp(() async { - client = MockParseClient(); - await Parse().initialize( - 'appId', - 'https://example.com', - debug: true, - // to prevent automatic detection - fileDirectory: 'someDirectory', - // to prevent automatic detection - appName: 'appName', - // to prevent automatic detection - appPackageName: 'somePackageName', - // to prevent automatic detection - appVersion: 'someAppVersion', - ); + setUp(() { + client = MockParseClient(); }); test('whereRelatedTo', () async { @@ -398,5 +392,20 @@ void main() { expect(result.path, '/classes/TEST_SCHEMA'); expect(result.query, expectedQuery.query); }); + + test( + 'The resulting query should include "redirectClassNameForKey" as a query parameter', + () { + // arrange + final queryBuilder = QueryBuilder.name('Diet_Plans'); + + // act + queryBuilder.setRedirectClassNameForKey('Plan'); + + // assert + final query = queryBuilder.buildQuery(); + + expect(query, equals('where={}&redirectClassNameForKey=Plan')); + }); }); } diff --git a/packages/dart/test/src/objects/parse_base_test.dart b/packages/dart/test/src/objects/parse_base_test.dart new file mode 100644 index 000000000..82ba945a3 --- /dev/null +++ b/packages/dart/test/src/objects/parse_base_test.dart @@ -0,0 +1,170 @@ +import 'package:collection/collection.dart'; +import 'package:parse_server_sdk/parse_server_sdk.dart'; +import 'package:test/test.dart'; + +import '../../parse_query_test.mocks.dart'; +import '../../test_utils.dart'; + +main() { + setUpAll(() async { + await initializeParse(); + }); + + group('Parse_Base', () { + late MockParseClient client; + + late ParseObject dietPlansObject; + + setUp(() { + client = MockParseClient(); + + dietPlansObject = ParseObject("Diet_Plans", client: client); + }); + + group('isDirty', () { + test('should return true when calling isDirty on modified object', () { + // array + dietPlansObject.setAddUnique('arrayKey', 1); + final isDirtyArray = dietPlansObject.isDirty(key: 'arrayKey'); + expect(isDirtyArray, isTrue); + dietPlansObject.unset('arrayKey', offlineOnly: true); + + // number + dietPlansObject.setIncrement('myNumberKey', 2); + final isDirtyNumber = dietPlansObject.isDirty(key: 'myNumberKey'); + expect(isDirtyNumber, isTrue); + dietPlansObject.unset('myNumberKey', offlineOnly: true); + + // relation + dietPlansObject.removeRelation('relationKey', [ParseObject('class')]); + final isDirtyRelation = dietPlansObject.isDirty(key: 'relationKey'); + expect(isDirtyRelation, isTrue); + dietPlansObject.unset('relationKey', offlineOnly: true); + + // string + dietPlansObject.set('stringKey', 'some String'); + final isDirtyString = dietPlansObject.isDirty(key: 'stringKey'); + expect(isDirtyString, isTrue); + dietPlansObject.unset('stringKey', offlineOnly: true); + + // pointer + dietPlansObject.set( + 'pointerKey', + ParseObject('className')..set('someKey', 1), + ); + final isDirtyPointer = dietPlansObject.isDirty(key: 'pointerKey'); + expect(isDirtyPointer, isTrue); + dietPlansObject.unset('pointerKey', offlineOnly: true); + }); + + test('should return true when modifying a child(nested) ParseObject', () { + // arrange + dietPlansObject.fromJson({ + keyVarObjectId: "dDSAGER1", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z", + keyVarUpdatedAt: "2023-02-26T00:20:37.187Z", + "somePointer": { + "__type": "Object", + "className": "Plan", + "name": "plan1" + }, + }); + + // act + + // modifying nested child + dietPlansObject.get('somePointer').set('name', 'plan222'); + + final isDirtyDeepChildrenCheck = dietPlansObject.isDirty(); + + // assert + expect(isDirtyDeepChildrenCheck, isTrue); + }); + }); + + test( + 'should return true for containsValue() if the object contains the value', + () { + // arrange + dietPlansObject.set('someKey', 1); + + // act + final containsValue = dietPlansObject.containsValue(1); + + // assert + expect(containsValue, isTrue); + }); + + test( + 'should return true for containsKey() if the object contains the passed key', + () { + // arrange + dietPlansObject.set('someKey', 1); + + // act + final containsKey = dietPlansObject.containsKey('someKey'); + + // assert + expect(containsKey, isTrue); + }); + + test('test the [] operator', () { + // arrange + dietPlansObject['someKey'] = 1; + + // act + final value = dietPlansObject['someKey']; + + // assert + expect(value, equals(1)); + }); + + test('setACL() should set the ACL for the parse object', () { + // arrange + final acl = ParseACL(); + + // act + dietPlansObject.setACL(acl); + + // assert + expect(dietPlansObject.getACL(), equals(acl)); + }); + + test('fromJsonForManualObject() should put all the values in unsaved state', + () { + // arrange + final createdAt = DateTime.now(); + final updatedAt = DateTime.now(); + final manualJsonObject = { + keyVarCreatedAt: createdAt, + keyVarUpdatedAt: updatedAt, + "array": [1, 2, 3], + 'number': 2, + }; + + // act + dietPlansObject.fromJsonForManualObject(manualJsonObject); + + // assert + expect(dietPlansObject.isDirty(key: 'array'), isTrue); + expect(dietPlansObject.isDirty(key: 'number'), isTrue); + + expect(dietPlansObject.createdAt, equals(createdAt)); + expect(dietPlansObject.updatedAt, equals(updatedAt)); + + final valueForAPiRequest = dietPlansObject.toJson(forApiRQ: true); + final expectedValueForAPiRequest = { + "array": [1, 2, 3], + "number": 2 + }; + + expect( + DeepCollectionEquality().equals( + valueForAPiRequest, + expectedValueForAPiRequest, + ), + isTrue, + ); + }); + }); +} diff --git a/packages/dart/test/src/objects/parse_object/parse_object_array_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_array_test.dart index ffc6d9f64..07f636d80 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_array_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_array_test.dart @@ -1,9 +1,7 @@ -@Skip('get(keyArray) will return _Map' - 'which is the wrong type. it should be any subtype of Iterable' - 'see the issue #834') -// TODO: remove the skip when the issue fixed +import 'dart:convert'; import 'package:collection/collection.dart'; +import 'package:mockito/mockito.dart'; import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'package:test/test.dart'; @@ -11,6 +9,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group( 'Array', () { @@ -20,11 +22,9 @@ void main() { const keyArray = 'array'; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); }); @@ -304,7 +304,7 @@ void main() { // assert expect( () => dietPlansObject.setRemove(keyArray, 2), - throwsA(isA()), + throwsA(isA()), ); final array = dietPlansObject.get(keyArray); @@ -343,7 +343,7 @@ void main() { // assert expect( () => dietPlansObject.setAdd(keyArray, 5), - throwsA(isA()), + throwsA(isA()), ); final array = dietPlansObject.get(keyArray); @@ -378,6 +378,398 @@ void main() { excludeMergeableOperations: [dietPlansObject.setRemove], ); }); + + test( + 'Array should be in setMode when using "set" to add an array to the parse object ' + 'and any operation on the array should not create any conflict with the previous operation', + () { + // arrange + void operations() { + // act + dietPlansObject.set(keyArray, [1, 2]); + dietPlansObject.setAdd(keyArray, 3); + dietPlansObject.setAddUnique(keyArray, 3); + dietPlansObject.setAddUnique(keyArray, 4); + dietPlansObject.setRemove(keyArray, 1); + } + + // assert + expect(() => operations(), returnsNormally); + }); + + test( + 'The array internal state should be identical before and after ' + 'storing it in data store', () async { + // arrange + dietPlansObject.objectId = "someId"; + + dietPlansObject.set(keyArray, [1, 2]); + dietPlansObject.setAdd(keyArray, 3); + dietPlansObject.setAddUnique(keyArray, 3); + dietPlansObject.setAddUnique(keyArray, 4); + dietPlansObject.setRemove(keyArray, 1); + + final listBeforePin = dietPlansObject.get(keyArray); + final toJsonBeforePin = dietPlansObject.toJson(forApiRQ: true); + + // act + await dietPlansObject.pin(); + + final objectFromPin = await dietPlansObject.fromPin('someId'); + + // assert + final listAfterPin = objectFromPin.get(keyArray); + final toJsonAfterPin = objectFromPin.toJson(forApiRQ: true); + + expect( + DeepCollectionEquality().equals(listBeforePin, listAfterPin), + isTrue, + ); + + expect( + DeepCollectionEquality().equals(toJsonBeforePin, toJsonAfterPin), + isTrue, + ); + }); + + test( + 'The saved modified array internal state should be identical ' + 'before and after storing it in data store', () async { + // arrange + dietPlansObject.fromJson({ + keyArray: [1, 2], + "objectId": "someId" + }); // assume this coming from the server + + dietPlansObject.setAddUnique(keyArray, 3); + + final listBeforePin = dietPlansObject.get(keyArray); + final toJsonBeforePin = dietPlansObject.toJson(forApiRQ: true); + + // act + await dietPlansObject.pin(); + + final objectFromPin = await dietPlansObject.fromPin('someId'); + + // assert + final listAfterPin = objectFromPin.get(keyArray); + final toJsonAfterPin = objectFromPin.toJson(forApiRQ: true); + + expect( + DeepCollectionEquality().equals(listBeforePin, listAfterPin), + isTrue, + ); + + expect( + DeepCollectionEquality().equals(toJsonBeforePin, toJsonAfterPin), + isTrue, + ); + }); + + test( + 'The saved array should not be in setMode. i.e. any conflicting operation' + ' should not be allowed and throw an exception', () { + // arrange + dietPlansObject.fromJson({ + keyArray: [1, 2], + "objectId": "someId" + }); // assume this coming from the server + + // act + dietPlansObject.setAdd(keyArray, 3); + + // assert + op() => dietPlansObject.setRemove(keyArray, 3); + + expect(() => op(), throwsA(isA())); + }); + + test( + 'After the save() function runs successfully for an API request, ' + 'the ParseArray internal value for API request should be empty', + () async { + // arrange + const resultFromServer = { + keyVarObjectId: "DLde4rYA8C", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z" + }; + + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed('data'), + )).thenAnswer( + (_) async { + await Future.delayed(Duration(milliseconds: 500)); + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServer), + ); + }, + ); + + dietPlansObject.setAddAll(keyArray, [1, 2, 3]); + + final valueForApiReqBeforeSave = dietPlansObject.toJson(forApiRQ: true); + + final listValueBeforeSave = dietPlansObject.get(keyArray); + + // act + await dietPlansObject.save(); + + // assert + final valueForApiReqAfterSave = dietPlansObject.toJson(forApiRQ: true); + final listValueAfterSave = dietPlansObject.get(keyArray); + + final expectedValueForApiReqBeforeSave = { + keyArray: { + "__op": "Add", + "objects": [1, 2, 3] + } + }; + + expect( + DeepCollectionEquality().equals( + valueForApiReqBeforeSave, + expectedValueForApiReqBeforeSave, + ), + isTrue, + ); + + expect( + DeepCollectionEquality().equals( + listValueBeforeSave, + listValueAfterSave, + ), + isTrue, + ); + + expect(valueForApiReqAfterSave.isEmpty, isTrue); + }); + + test( + 'If an Add operation is performed during the save() function, the result' + ' of the operation should be present in the internal state of the ' + 'ParseArray as a value that has not been saved. The data that has ' + 'been saved should be moved to the saved state', + () async { + // arrange + const resultFromServer = { + keyVarObjectId: "DLde4rYA8C", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z" + }; + + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed('data'), + )).thenAnswer( + (_) async { + await Future.delayed(Duration(milliseconds: 100)); + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServer), + ); + }, + ); + + dietPlansObject.setAdd(keyArray, 1); + dietPlansObject.setAdd(keyArray, 2); + + final listBeforeSave = dietPlansObject.get(keyArray); + final valueForApiReqBeforeSave = + dietPlansObject.toJson(forApiRQ: true); + + // act + dietPlansObject.save(); + + // async gap, this could be anything in the app like a click of a button + await Future.delayed(Duration.zero); + + // Then suddenly the user added a value to the list + dietPlansObject.setAdd(keyArray, 3); + dietPlansObject.setAdd(keyArray, 4); + + // Await the save function to be done + await Future.delayed(Duration(milliseconds: 150)); + + // assert + expect( + DeepCollectionEquality().equals(listBeforeSave, [1, 2]), + isTrue, + ); + + final listAfterSave = dietPlansObject.get(keyArray); + expect( + DeepCollectionEquality().equals(listAfterSave, [1, 2, 3, 4]), + isTrue, + ); + + const expectedValueForApiReqBeforeSave = { + keyArray: { + "__op": "Add", + "objects": [1, 2] + } + }; + expect( + DeepCollectionEquality().equals( + valueForApiReqBeforeSave, + expectedValueForApiReqBeforeSave, + ), + isTrue, + ); + + final valueForApiReqAfterSave = + dietPlansObject.toJson(forApiRQ: true); + const expectedValueForApiReqAfterSave = { + keyArray: { + "__op": "Add", + "objects": [3, 4] + } + }; + expect( + DeepCollectionEquality().equals( + valueForApiReqAfterSave, + expectedValueForApiReqAfterSave, + ), + isTrue, + ); + }, + ); + + test( + 'If an Remove operation is performed during the save() function, the result' + ' of the operation should be present in the internal state of the ' + 'ParseArray as a value that has not been saved. The data that has ' + 'been saved should be moved to the saved state', + () async { + // arrange + const resultFromServer = { + keyVarObjectId: "DLde4rYA8C", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z" + }; + + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed('data'), + )).thenAnswer( + (_) async { + await Future.delayed(Duration(milliseconds: 100)); + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServer), + ); + }, + ); + + dietPlansObject.fromJson({ + keyArray: [1, 2, 3, 4] + }); + + final listBeforeSave = dietPlansObject.get(keyArray); + final valueForApiReqBeforeSave = + dietPlansObject.toJson(forApiRQ: true); + + // act + dietPlansObject.save(); + + // async gap, this could be anything in the app like a click of a button + await Future.delayed(Duration.zero); + + // Then suddenly the user remove a value from the list + dietPlansObject.setRemoveAll(keyArray, [3, 4]); + + // Await the save function to be done + await Future.delayed(Duration(milliseconds: 150)); + + // assert + expect(listBeforeSave, orderedEquals([1, 2, 3, 4])); + + final listAfterSave = dietPlansObject.get(keyArray); + expect( + listAfterSave, + orderedEquals([1, 2]), + ); + + expect( + valueForApiReqBeforeSave.isEmpty, + isTrue, + ); + + final valueForApiReqAfterSave = + dietPlansObject.toJson(forApiRQ: true); + const expectedValueForApiReqAfterSave = { + keyArray: { + "__op": "Remove", + "objects": [3, 4] + } + }; + expect( + DeepCollectionEquality().equals( + valueForApiReqAfterSave, + expectedValueForApiReqAfterSave, + ), + isTrue, + ); + }, + ); + + test( + 'When calling clearUnsavedChanges() the array should be reverted back' + ' to its original state before any modifications were made', () { + // arrange + dietPlansObject.fromJson({ + keyArray: [1, 2], + "objectId": "someId" + }); // assume this coming from the server + + dietPlansObject.setAdd(keyArray, 3); + + // act + dietPlansObject.clearUnsavedChanges(); + + // assert + final listValue = dietPlansObject.get(keyArray); + + expect(listValue, orderedEquals([1, 2])); + }); + + test( + 'The list value and the value for api request should be identical ' + 'before and after the save() failed to save the object', () async { + // arrange + + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenThrow(Exception('error')); + + dietPlansObject.setAddAll(keyArray, [1, 2]); + + final valueForApiReqBeforeErrorSave = + dietPlansObject.toJson(forApiRQ: true); + + // act + await dietPlansObject.save(); + + // assert + final listValue = dietPlansObject.get(keyArray); + + expect(listValue, orderedEquals([1, 2])); + + final valueForApiReqAfterErrorSave = + dietPlansObject.toJson(forApiRQ: true); + + expect( + DeepCollectionEquality().equals( + valueForApiReqAfterErrorSave, + valueForApiReqBeforeErrorSave, + ), + isTrue, + ); + }); }, ); } diff --git a/packages/dart/test/src/objects/parse_object/parse_object_create_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_create_test.dart index 436aadb72..ee4c205bd 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_create_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_create_test.dart @@ -8,6 +8,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('create()', () { late MockParseClient client; @@ -15,11 +19,9 @@ void main() { late String postPath; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - final user = ParseObject(keyClassUser)..objectId = "ELR124r8C"; dietPlansObject = ParseObject("Diet_Plans", client: client); dietPlansObject @@ -113,12 +115,15 @@ void main() { // arrange final postData = jsonEncode(dietPlansObject.toJson(forApiRQ: true)); - final error = Exception('error'); + final errorData = jsonEncode({keyCode: -1, keyError: "someError"}); + when(client.post( postPath, options: anyNamed("options"), data: postData, - )).thenThrow(error); + )).thenAnswer( + (_) async => ParseNetworkResponse(data: errorData, statusCode: -1), + ); // act ParseResponse response = await dietPlansObject.create(); @@ -134,7 +139,7 @@ void main() { expect(response.error, isNotNull); - expect(response.error!.exception, equals(error)); + expect(response.error!.message, equals('someError')); expect(response.error!.code, equals(ParseError.otherCause)); diff --git a/packages/dart/test/src/objects/parse_object/parse_object_delete_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_delete_test.dart index 2d7700944..397ab9ef5 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_delete_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_delete_test.dart @@ -8,16 +8,18 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('delete()', () { late MockParseClient client; late ParseObject dietPlansObject; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); }); @@ -29,8 +31,7 @@ void main() { dietPlansObject.set('Fat', 15); - final dietPlansObjectDataBeforeDeletion = - dietPlansObject.toJson(full: true); + final dietPlansObjectDataBeforeDeletion = dietPlansObject.toJson(); final deletePath = Uri.parse( '$serverUrl$keyEndPointClasses${dietPlansObject.parseClassName}/${dietPlansObject.objectId}', @@ -71,7 +72,7 @@ void main() { expect(identical(objectFromResponse, dietPlansObject), isTrue); final dietPlansObjectDataAfterDeletion = - (objectFromResponse as ParseObject).toJson(full: true); + (objectFromResponse as ParseObject).toJson(); expect( jsonEncode(dietPlansObjectDataAfterDeletion), @@ -146,8 +147,7 @@ void main() { dietPlansObject.set('Fat', 15); - final dietPlansObjectDataBeforeDeletion = - dietPlansObject.toJson(full: true); + final dietPlansObjectDataBeforeDeletion = dietPlansObject.toJson(); final deletePath = Uri.parse( '$serverUrl$keyEndPointClasses${dietPlansObject.parseClassName}/$id', @@ -188,7 +188,7 @@ void main() { expect(identical(objectFromResponse, dietPlansObject), isTrue); final dietPlansObjectDataAfterDeletion = - (objectFromResponse as ParseObject).toJson(full: true); + (objectFromResponse as ParseObject).toJson(); expect( jsonEncode(dietPlansObjectDataAfterDeletion), diff --git a/packages/dart/test/src/objects/parse_object/parse_object_distinct_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_distinct_test.dart index e65deaf25..d61384cdd 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_distinct_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_distinct_test.dart @@ -8,6 +8,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('distinct()', () { late MockParseClient client; @@ -19,11 +23,9 @@ void main() { // https://example.com/aggregate/Diet_Plans?distinct=Name late String getPath; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); final getURI = Uri.parse( diff --git a/packages/dart/test/src/objects/parse_object/parse_object_fetch_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_fetch_test.dart index 37b9d44b3..bd3863b1d 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_fetch_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_fetch_test.dart @@ -8,16 +8,18 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('fetch()', () { late MockParseClient client; late ParseObject dietPlansObject; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); }); diff --git a/packages/dart/test/src/objects/parse_object/parse_object_get_all_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_get_all_test.dart index 5b56c526f..a43747d5b 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_get_all_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_get_all_test.dart @@ -8,6 +8,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('getAll()', () { late MockParseClient client; @@ -15,11 +19,9 @@ void main() { late String getPath; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); getPath = Uri.parse( diff --git a/packages/dart/test/src/objects/parse_object/parse_object_get_object_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_get_object_test.dart index 000ce35ef..73c6adf24 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_get_object_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_get_object_test.dart @@ -8,6 +8,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('getObject()', () { late MockParseClient client; @@ -17,11 +21,9 @@ void main() { const objectId = "Mn1iJTkWTE"; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); dietPlansObject.objectId = objectId; diff --git a/packages/dart/test/src/objects/parse_object/parse_object_increment_decrement_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_increment_decrement_test.dart index 9b6c7e37c..eb7c5dcf6 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_increment_decrement_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_increment_decrement_test.dart @@ -1,8 +1,7 @@ -@Skip('get(key) will return _Map' - 'which is the wrong type. it should be any subtype of num' - 'see the issue #842') -// TODO: remove the skip when the issue fixed +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:mockito/mockito.dart'; import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'package:test/test.dart'; @@ -10,6 +9,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('Increment/Decrement', () { late MockParseClient client; @@ -20,8 +23,6 @@ void main() { setUp(() async { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); }); @@ -98,7 +99,6 @@ void main() { expect(fatValue, equals(7.5)); }, - skip: 'see #843', ); test( @@ -111,6 +111,30 @@ void main() { ); }); + test( + 'the amount that should be added to a value for the API should be incremental' + ' and independent from the estimated value (the increment operation result)', + () { + // arrange + dietPlansObject.fromJson({keyFat: 10}); + + // act + dietPlansObject.setIncrement(keyFat, 2.5); + dietPlansObject.setIncrement(keyFat, 2.5); + + // assert + expect( + dietPlansObject.toJson(forApiRQ: true)[keyFat]['amount'], + equals(5.0), + ); + + expect( + dietPlansObject.get(keyFat), + equals(15.0), + ); + }, + ); + test( 'Decrementing values using setDecrement() and then calling get(key) ' 'should return Instance of num that hold the result of decrementing ' @@ -185,7 +209,30 @@ void main() { expect(fatValue, equals(2)); }, - skip: 'see #843', + ); + + test( + 'the amount that should be subtracted from a value for the API should be incremental' + ' and independent from the estimated value (the decrement operation result)', + () { + // arrange + dietPlansObject.fromJson({keyFat: 10}); + + // act + dietPlansObject.setDecrement(keyFat, 2.5); + dietPlansObject.setDecrement(keyFat, 2.5); + + // assert + expect( + dietPlansObject.toJson(forApiRQ: true)[keyFat]['amount'], + equals(-5.0), + ); + + expect( + dietPlansObject.get(keyFat), + equals(5.0), + ); + }, ); test( @@ -217,5 +264,165 @@ void main() { excludeMergeableOperations: [dietPlansObject.setIncrement], ); }); + + test( + 'When calling clearUnsavedChanges() the number should be reverted back' + ' to its original state before any modifications were made', () { + // arrange + dietPlansObject.fromJson({ + 'myNumber': 5, + "objectId": "someId" + }); // assume this coming from the server + + dietPlansObject.setIncrement('myNumber', 5); + + // act + dietPlansObject.clearUnsavedChanges(); + + // assert + final number = dietPlansObject.get('myNumber'); + + expect(number, equals(5)); + }); + + test( + 'The number internal state should be identical ' + 'before and after storing it in data store', () async { + // arrange + dietPlansObject.fromJson({ + 'myNumber': 5, + "objectId": "someId" + }); // assume this coming from the server + + dietPlansObject.setIncrement('myNumber', 5); + + final numberBeforePin = dietPlansObject.get('myNumber'); + final toJsonBeforePin = dietPlansObject.toJson(forApiRQ: true); + + // act + await dietPlansObject.pin(); + + final objectFromPin = await dietPlansObject.fromPin('someId'); + + // assert + final numberAfterPin = objectFromPin.get('myNumber'); + final toJsonAfterPin = objectFromPin.toJson(forApiRQ: true); + + expect(numberBeforePin, equals(numberAfterPin)); + + expect( + DeepCollectionEquality().equals(toJsonBeforePin, toJsonAfterPin), + isTrue, + ); + }); + + test( + 'If an Increment/Decrement operation is performed during the save() ' + 'function, the result of the operation should be present in the internal ' + 'state of the ParseNumber as a value that has not been saved. The data ' + 'that has been saved should be moved to the saved state', + () async { + // arrange + const resultFromServer = { + keyVarObjectId: "DLde4rYA8C", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z" + }; + + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed('data'), + )).thenAnswer( + (_) async { + await Future.delayed(Duration(milliseconds: 100)); + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServer), + ); + }, + ); + + dietPlansObject.setIncrement('myNumber', 1); + + final numberBeforeSave = dietPlansObject.get('myNumber'); + final valueForApiReqBeforeSave = dietPlansObject.toJson(forApiRQ: true); + + // act + dietPlansObject.save(); + + // async gap, this could be anything in the app like a click of a button + await Future.delayed(Duration.zero); + + // Then suddenly the user increment the value + dietPlansObject.setIncrement('myNumber', 3); + + // Await the save function to be done + await Future.delayed(Duration(milliseconds: 150)); + + // assert + expect(numberBeforeSave, equals(1)); + + final numberAfterSave = dietPlansObject.get('myNumber'); + expect(numberAfterSave, equals(4)); + + const expectedValueForApiReqBeforeSave = { + "myNumber": {"__op": "Increment", "amount": 1} + }; + expect( + DeepCollectionEquality().equals( + valueForApiReqBeforeSave, + expectedValueForApiReqBeforeSave, + ), + isTrue, + ); + + final valueForApiReqAfterSave = dietPlansObject.toJson(forApiRQ: true); + const expectedValueForApiReqAfterSave = { + "myNumber": {"__op": "Increment", "amount": 3} + }; + expect( + DeepCollectionEquality().equals( + valueForApiReqAfterSave, + expectedValueForApiReqAfterSave, + ), + isTrue, + ); + }, + ); + + test( + 'The number value and the number value for api request should be identical ' + 'before and after the save() failed to save the object', () { + // arrange + + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenThrow(Exception('error')); + + dietPlansObject.setIncrement('myNumber', 1); + + final valueForApiReqBeforeErrorSave = + dietPlansObject.toJson(forApiRQ: true); + + // act + dietPlansObject.save(); + + // assert + final numberValue = dietPlansObject.get('myNumber'); + + expect(numberValue, equals(1)); + + final valueForApiReqAfterErrorSave = + dietPlansObject.toJson(forApiRQ: true); + expect( + DeepCollectionEquality().equals( + valueForApiReqAfterErrorSave, + valueForApiReqBeforeErrorSave, + ), + isTrue, + ); + }); }); } diff --git a/packages/dart/test/src/objects/parse_object/parse_object_query_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_query_test.dart index 4dc9219bb..0f067abe3 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_query_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_query_test.dart @@ -8,6 +8,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('query()', () { late MockParseClient client; @@ -21,11 +25,9 @@ void main() { //https://example.com/classes/Diet_Plans?where=%7B%22fat%22:%2015%7D late String getPath; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); final query = QueryBuilder(dietPlansObject)..whereEqualTo(keyFat, 15); diff --git a/packages/dart/test/src/objects/parse_object/parse_object_relation_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_relation_test.dart index 6345ad4b0..773cd58c8 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_relation_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_relation_test.dart @@ -1,50 +1,34 @@ +import 'dart:convert'; + import 'package:collection/collection.dart'; +import 'package:mockito/mockito.dart'; import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'package:test/test.dart'; +import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { - group('Relation add/edit/remove', () { + group('Relation', () { + setUpAll(() async { + await initializeParse(); + }); + late ParseObject dietPlansObject; + late MockParseClient client; late ParseUser user1; late ParseUser user2; - setUp(() async { - await initializeParse(); + setUp(() { + client = MockParseClient(); user1 = ParseUser.forQuery()..objectId = 'user1'; user2 = ParseUser.forQuery()..objectId = 'user2'; - const resultFromServer = { - "objectId": "O6BHlwV48Z", - "Name": "new name", - "Description": "Low fat diet.", - "Fat": 65, - "createdAt": "2023-02-26T13:23:03.073Z", - "updatedAt": "2023-03-01T03:38:16.390Z", - "usersRelation": {"__type": "Relation", "className": "_User"} - }; - - dietPlansObject = ParseObject('Diet_Plans')..fromJson(resultFromServer); + dietPlansObject = ParseObject('Diet_Plans', client: client); }); - test( - 'getRelation(): the targetClass should be _User', - () { - // act - final usersRelation = dietPlansObject.getRelation('usersRelation'); - - // assert - expect( - usersRelation.getTargetClass, - equals(keyClassUser), - reason: 'the target class should be _User', - ); - }, - ); - test('addRelation(): the relation should hold two objects ', () { // act dietPlansObject.addRelation('usersRelation', [user1, user2]); @@ -87,9 +71,6 @@ void main() { returnsNormally, ); }, - skip: 'getRelation() will throw Unhandled exception:' - 'type _Map is not a subtype ' - 'of type ParseRelation? in type cast. see the issue #696', ); test( @@ -104,9 +85,6 @@ void main() { returnsNormally, ); }, - skip: 'getRelation() will throw Unhandled exception:' - 'type _Map is not a subtype ' - 'of type ParseRelation? in type cast. see the issue #696', ); test('addRelation() operation should not be mergeable with any other', () { @@ -123,5 +101,537 @@ void main() { testingOn: dietPlansObject.removeRelation, ); }); + + test('getParent() should rerun the parent of the relation', () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + final parent = relation.getParent(); + + // assert + expect(parent, dietPlansObject); + }); + + test('getKey() should rerun the relation key', () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + final relationKey = relation.getKey(); + + // assert + expect(relationKey, equals('someRelationKey')); + }); + + test( + 'getTargetClass() should rerun null if the relation target class not known yet', + () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + final targetClass = relation.targetClass; + + // assert + expect(targetClass, isNull); + }); + + test( + 'getTargetClass() should rerun the target class for the relation if ' + 'the user adds an object from the relation', () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + relation.add(ParseObject('someClassNameAsTargetClass')); + final targetClass = relation.targetClass; + + // assert + expect(targetClass, equals('someClassNameAsTargetClass')); + }); + + test( + 'getTargetClass() should rerun the target class for the relation if ' + 'the user removes an object from the relation', () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + relation.remove(ParseObject('someClassNameAsTargetClass')); + final targetClass = relation.targetClass; + + // assert + expect(targetClass, equals('someClassNameAsTargetClass')); + }); + + test( + 'getTargetClass() should return the target class for a relation when' + ' the object is received from the server', () { + // arrange + dietPlansObject.fromJson({ + "someRelationKey": { + "__type": "Relation", + "className": "someClassNameAsTargetClass" + } + }); // assume this from the server + + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + final targetClass = relation.targetClass; + + // assert + expect(targetClass, equals('someClassNameAsTargetClass')); + }); + + test('getQuery() should throw exception if the parent objectId is null ', + () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + + // assert + expect(() => relation.getQuery(), throwsA(isA())); + }); + + test( + 'getQuery() should return QueryBuilder utilizing the ' + 'redirectClassNameForKey feature if the target class is null ', () { + // arrange + dietPlansObject.objectId = "someParentID"; + final relation = dietPlansObject.getRelation('someRelationKey'); + + // act + final query = relation.getQuery(); + + // assert + String expectedQuery = + r'where={"$relatedTo":{"object":{"__type":"Pointer","className":' + r'"Diet_Plans","objectId":"someParentID"},"key":"someRelationKey"}}' + r'&redirectClassNameForKey=someRelationKey'; + + expect(query.buildQuery(), equals(expectedQuery)); + }); + + test('getQuery() should return QueryBuilder with relatedTo constraint', () { + // arrange + dietPlansObject.objectId = "someParentID"; + final relation = dietPlansObject.getRelation('someRelationKey'); + relation.setTargetClass = 'someTargetClass'; + + // act + final query = relation.getQuery(); + + // assert + String expectedQuery = + r'where={"$relatedTo":{"object":{"__type":"Pointer","className":' + r'"Diet_Plans","objectId":"someParentID"},"key":"someRelationKey"}}'; + + expect(query.buildQuery(), equals(expectedQuery)); + }); + + test( + 'should throw an exception when trying to modify the target class if it is not null', + () { + // arrange + final relation = dietPlansObject.getRelation('someRelationKey'); + relation.add(ParseObject('someClassNameAsTargetClass')); + + // assert + expect( + () => relation.setTargetClass = "someOtherTargetClass", + throwsA(isA()), + ); + }); + + test( + 'When calling clearUnsavedChanges() the Relation should be reverted back' + ' to its original state before any modifications were made', () { + // arrange + + dietPlansObject.addRelation('someRelationKey', [user1, user1]); + + // act + dietPlansObject.clearUnsavedChanges(); + + // assert + final valueForApiReqAfterClearUnSaved = + dietPlansObject.toJson(forApiRQ: true); + + expect(valueForApiReqAfterClearUnSaved.isEmpty, isTrue); + + final relationValueForApiReq = + dietPlansObject.getRelation('someRelationKey').toJson(); + expect(relationValueForApiReq.isEmpty, isTrue); + }); + + test( + 'The Relation value and the value for api request should be identical ' + 'before and after the save() failed to save the object', () async { + // arrange + when(client.post( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenThrow(Exception('error')); + + dietPlansObject.addRelation('someRelationKey', [user1, user1]); + + final valueForApiReqBeforeErrorSave = + dietPlansObject.toJson(forApiRQ: true); + + final relationInternalStateBeforeErrorSave = + dietPlansObject.getRelation('someRelationKey').toJson(full: true); + + // act + await dietPlansObject.save(); + + // assert + final valueForApiReqAfterErrorSave = + dietPlansObject.toJson(forApiRQ: true); + expect( + DeepCollectionEquality().equals( + valueForApiReqAfterErrorSave, + valueForApiReqBeforeErrorSave, + ), + isTrue, + ); + + final relationInternalStateAfterErrorSave = + dietPlansObject.getRelation('someRelationKey').toJson(full: true); + + expect( + DeepCollectionEquality().equals( + relationInternalStateBeforeErrorSave, + relationInternalStateAfterErrorSave, + ), + isTrue, + ); + }); + + test( + 'After the save() function runs successfully for an API request, ' + 'the ParseRelation internal value for API request should be empty', + () async { + // arrange + + // batch arrange + const resultFromServerForBatch = [ + { + "success": { + keyVarObjectId: 'YAfSAWwXbL', + keyVarCreatedAt: "2023-03-10T12:23:45.678Z", + } + } + ]; + + final batchData = jsonEncode( + { + "requests": [ + { + 'method': 'PUT', + 'path': + '$keyEndPointClasses${user1.parseClassName}/${user1.objectId}', + 'body': user1.toJson(forApiRQ: true), + } + ] + }, + ); + + final batchPath = Uri.parse('$serverUrl/batch').toString(); + + when(client.post( + batchPath, + options: anyNamed("options"), + data: batchData, + )).thenAnswer( + (_) async { + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServerForBatch), + ); + }, + ); + + // post arrange + const resultFromServer = { + keyVarObjectId: "DLde4rYA8C", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z" + }; + + final postPath = Uri.parse( + '$serverUrl$keyEndPointClasses${dietPlansObject.parseClassName}', + ).toString(); + + when(client.post( + postPath, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServer), + ), + ); + + dietPlansObject.addRelation('someRelationKey', [user1]); + + // act + await dietPlansObject.save(); + + // assert + final relationValueForApiReq = + dietPlansObject.getRelation('someRelationKey').toJson(); + expect(relationValueForApiReq.isEmpty, isTrue); + }); + + test( + 'If a Relation operation is performed during the save() function, the result' + ' of the operation should be present in the internal state of the ' + 'ParseRelation as a value that has not been saved. The data that has ' + 'been saved should not be in value for API request', () async { + // arrange + + // batch arrange + const resultFromServerForBatch = [ + { + "success": { + keyVarUpdatedAt: "2023-03-10T12:23:45.678Z", + } + } + ]; + + final batchData = jsonEncode( + { + "requests": [ + { + 'method': 'PUT', + 'path': + '$keyEndPointClasses${user1.parseClassName}/${user1.objectId}', + 'body': user1.toJson(forApiRQ: true), + } + ] + }, + ); + + final batchPath = Uri.parse('$serverUrl/batch').toString(); + + when(client.post( + batchPath, + options: anyNamed("options"), + data: batchData, + )).thenAnswer( + (_) async { + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServerForBatch), + ); + }, + ); + + // post arrange + const resultFromServer = { + keyVarObjectId: "DLde4rYA8C", + keyVarCreatedAt: "2023-02-26T00:20:37.187Z" + }; + + final postPath = Uri.parse( + '$serverUrl$keyEndPointClasses${dietPlansObject.parseClassName}', + ).toString(); + + when(client.post( + postPath, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenAnswer( + (_) async { + await Future.delayed(Duration(milliseconds: 100)); + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServer), + ); + }, + ); + + dietPlansObject.addRelation('someRelationKey', [user1]); + + // act + dietPlansObject.save(); + + // async gap, this could be anything in the app like a click of a button + await Future.delayed(Duration.zero); + + // Then suddenly the user adds a object to the relation + dietPlansObject.addRelation('someRelationKey', [user2]); + + // Await the save function to be done + await Future.delayed(Duration(milliseconds: 150)); + + // assert + final relationValueForApiReq = + dietPlansObject.getRelation('someRelationKey').toJson(); + + final expectedValueAfterSave = { + '__op': 'AddRelation', + 'objects': parseEncode([user2]) + }; + + expect( + DeepCollectionEquality().equals( + relationValueForApiReq, + expectedValueAfterSave, + ), + isTrue, + ); + }); + + test( + 'ParseRelation value for api request should be identical ' + 'before and after the save() failed to save the object', () async { + // arrange + + // batch arrange + const resultFromServerForBatch = [ + { + "success": { + keyVarUpdatedAt: "2023-03-10T12:23:45.678Z", + } + } + ]; + + final batchData = jsonEncode( + { + "requests": [ + { + 'method': 'PUT', + 'path': + '$keyEndPointClasses${user1.parseClassName}/${user1.objectId}', + 'body': user1.toJson(forApiRQ: true), + } + ] + }, + ); + + final batchPath = Uri.parse('$serverUrl/batch').toString(); + + when(client.post( + batchPath, + options: anyNamed("options"), + data: batchData, + )).thenAnswer( + (_) async { + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode(resultFromServerForBatch), + ); + }, + ); + + // post arrange + final postPath = Uri.parse( + '$serverUrl$keyEndPointClasses${dietPlansObject.parseClassName}', + ).toString(); + + when(client.post( + postPath, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenThrow(Exception('error')); + + dietPlansObject.addRelation('someRelationKey', [user1]); + + final relationValueForApiReqBeforeErrorSave = + dietPlansObject.getRelation('someRelationKey').toJson(); + + // act + await dietPlansObject.save(); + + // assert + final relationValueForApiReqAfterErrorSave = + dietPlansObject.getRelation('someRelationKey').toJson(); + + expect( + DeepCollectionEquality().equals( + relationValueForApiReqBeforeErrorSave, + relationValueForApiReqAfterErrorSave, + ), + isTrue); + }); + + test( + 'The Relation internal state should be identical before and after ' + 'storing it in data store', () async { + // arrange + dietPlansObject.objectId = "someId"; + + final ParseRelation relation = + dietPlansObject.getRelation('someRelationKey'); + relation.remove(ParseObject('someClassName')); + + final toJsonBeforePin = relation.toJson(full: true); + + // act + await dietPlansObject.pin(); + + final ParseObject objectFromPin = await dietPlansObject.fromPin('someId'); + + // assert + final toJsonAfterPin = + objectFromPin.getRelation('someRelationKey').toJson(full: true); + + expect( + DeepCollectionEquality().equals(toJsonBeforePin, toJsonAfterPin), + isTrue, + ); + }); + + test( + 'should throw an exception if the user adds/removes a parse object' + ' with different target class', () { + // arrange + final ParseRelation relation = + dietPlansObject.getRelation('someRelationKey'); + + relation.remove(ParseObject('someClassName')..objectId = "123"); + relation.remove(ParseObject('someClassName')..objectId = '456'); + + // act + // assert + expect( + () => relation.remove(ParseObject('otherClassName')), + throwsA(isA()), + ); + }); + + test( + 'If the value for API request is empty in ParseRelation then the' + ' ParseRelation should not be part of the end map for' + ' API request of an object', () { + // arrange + + // this will create and store an empty relation if no relation associated + // with this key + dietPlansObject.getRelation('someRelationKey'); + + // act + + final valueFroApiRequest = dietPlansObject.toJson(forApiRQ: true); + + // assert + expect(valueFroApiRequest.isEmpty, isTrue); + }); + + test( + 'Should throw exception when getRelation() called on key' + ' holds value other than Relation or null', () { + // arrange + dietPlansObject.set('someRelationKey', 'some String'); + + // act + getRelation() => dietPlansObject.getRelation('someRelationKey'); + + // assert + expect(() => getRelation(), throwsA(isA())); + }); }); } diff --git a/packages/dart/test/src/objects/parse_object/parse_object_save_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_save_test.dart index ffd4b9980..9738d143f 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_save_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_save_test.dart @@ -8,16 +8,18 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('save()', () { late MockParseClient client; late ParseObject dietPlansObject; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); }); @@ -176,7 +178,7 @@ void main() { test( 'save should updated an object online and store and updated any object ' - 'added via relation ', () async { + 'added via relation', () async { // arrange dietPlansObject.objectId = "NNAfSGGHHbL"; diff --git a/packages/dart/test/src/objects/parse_object/parse_object_unset_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_unset_test.dart index f752baaf2..0e893707b 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_unset_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_unset_test.dart @@ -8,17 +8,19 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('unset()', () { late MockParseClient client; late ParseObject dietPlansObject; const keyFat = 'fat'; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); }); @@ -26,10 +28,6 @@ void main() { // arrange dietPlansObject.set(keyFat, 2); - final putPath = Uri.parse( - '$serverUrl$keyEndPointClasses${dietPlansObject.parseClassName}/${dietPlansObject.objectId}', - ).toString(); - // act final ParseResponse parseResponse = await dietPlansObject.unset(keyFat, offlineOnly: true); @@ -39,12 +37,6 @@ void main() { expect(dietPlansObject.get(keyFat), isNull); - verifyNever(client.put( - putPath, - options: anyNamed("options"), - data: anyNamed('data'), - )); - verifyZeroInteractions(client); }); @@ -117,5 +109,90 @@ void main() { verifyZeroInteractions(client); }); + + test( + 'unset() should keep the the key and its value if the request was unsuccessful', + () async { + // arrange + + dietPlansObject.objectId = "O6BHlwV48Z"; + dietPlansObject.set(keyFat, 2); + + final errorData = jsonEncode({keyCode: -1, keyError: "someError"}); + + when(client.put( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: -1, + data: errorData, + ), + ); + + // act + final parseResponse = + await dietPlansObject.unset(keyFat, offlineOnly: false); + + // assert + expect(parseResponse.success, isFalse); + + expect(parseResponse.error, isNotNull); + + expect(parseResponse.error!.message, equals('someError')); + + expect(parseResponse.error!.code, equals(-1)); + + expect(dietPlansObject.get(keyFat), equals(2)); + + verify(client.put( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).called(1); + + verifyNoMoreInteractions(client); + }); + + test( + 'unset() should keep the the key and its value if the request throws an exception', + () async { + // arrange + + dietPlansObject.objectId = "O6BHlwV48Z"; + dietPlansObject.set(keyFat, 2); + + final error = Exception('error'); + + when(client.put( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).thenThrow(error); + + // act + final parseResponse = + await dietPlansObject.unset(keyFat, offlineOnly: false); + + // assert + expect(parseResponse.success, isFalse); + + expect(parseResponse.error, isNotNull); + + expect(parseResponse.error!.exception, equals(error)); + + expect(parseResponse.error!.code, equals(-1)); + + expect(dietPlansObject.get(keyFat), equals(2)); + + verify(client.put( + any, + options: anyNamed("options"), + data: anyNamed("data"), + )).called(1); + + verifyNoMoreInteractions(client); + }); }); } diff --git a/packages/dart/test/src/objects/parse_object/parse_object_update_test.dart b/packages/dart/test/src/objects/parse_object/parse_object_update_test.dart index de2a680db..ca8e9085e 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_update_test.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_update_test.dart @@ -8,6 +8,10 @@ import '../../../parse_query_test.mocks.dart'; import '../../../test_utils.dart'; void main() { + setUpAll(() async { + await initializeParse(); + }); + group('update()', () { late MockParseClient client; @@ -21,11 +25,9 @@ void main() { late String putPath; - setUp(() async { + setUp(() { client = MockParseClient(); - await initializeParse(); - dietPlansObject = ParseObject("Diet_Plans", client: client); dietPlansObject ..objectId = "DLde4rYA8C" @@ -151,6 +153,53 @@ void main() { verifyNoMoreInteractions(client); }); + test( + 'update() should return error form the server and the' + ' updated values should remain the same', () async { + // arrange + + final putData = jsonEncode(dietPlansObject.toJson(forApiRQ: true)); + final errorData = jsonEncode({keyCode: -1, keyError: "someError"}); + + when(client.put( + putPath, + options: anyNamed("options"), + data: putData, + )).thenAnswer( + (_) async => ParseNetworkResponse(data: errorData, statusCode: -1), + ); + + // act + ParseResponse response = await dietPlansObject.update(); + + // assert + expect(response.success, isFalse); + + expect(response.result, isNull); + + expect(response.count, isZero); + + expect(response.results, isNull); + + expect(response.error, isNotNull); + + expect(response.error!.message, equals('someError')); + + expect(response.error!.code, equals(ParseError.otherCause)); + + // even if the update failed, the updated values should remain the same + expect(dietPlansObject.get(keyName), equals(newNameValue)); + expect(dietPlansObject.get(keyFat), equals(newFatValue)); + + verify(client.put( + putPath, + options: anyNamed("options"), + data: putData, + )).called(1); + + verifyNoMoreInteractions(client); + }); + test('should throw AssertionError if objectId is null', () { dietPlansObject.objectId = null; diff --git a/packages/dart/test/src/objects/response/parse_response_utils_test.dart b/packages/dart/test/src/objects/response/parse_response_utils_test.dart index d5f69b8d3..759ca6826 100644 --- a/packages/dart/test/src/objects/response/parse_response_utils_test.dart +++ b/packages/dart/test/src/objects/response/parse_response_utils_test.dart @@ -6,11 +6,11 @@ import 'package:test/test.dart'; import '../../../test_utils.dart'; void main() { - group('handleResponse()', () { - setUp(() async { - await initializeParse(); - }); + setUpAll(() async { + await initializeParse(); + }); + group('handleResponse()', () { group('when batch', () { test( 'should return a ParseResponse holds a list of created/updated ParseObjects', diff --git a/packages/dart/test/src/utils/parse_encoder_test.dart b/packages/dart/test/src/utils/parse_encoder_test.dart new file mode 100644 index 000000000..afc442d48 --- /dev/null +++ b/packages/dart/test/src/utils/parse_encoder_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:parse_server_sdk/parse_server_sdk.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +void main() { + setUpAll(() async { + await initializeParse(); + }); + + group('full encode', () { + test('should return the expected json encode ', () async { + // arrange + final dietPlansObject = ParseObject("Diet_Plans"); + final plan = ParseObject("plan")..set('somePlanKey', 'some value'); + dietPlansObject.set('pointer_val', plan); + dietPlansObject.set('int_val', 2); + dietPlansObject.set('string_val', 'some String'); + dietPlansObject.set('double_val', 2.5); + dietPlansObject.setIncrement('int_val', 2); + dietPlansObject.setDecrement('double_val', 2); + dietPlansObject.set('array_1_val', [1, 2, 3]); + dietPlansObject.set('array_2_val', [1, 2, 3]); + dietPlansObject.set('array_3_val', [1, 2, 3]); + dietPlansObject.setAdd('array_1_val', 3); + dietPlansObject.setAddUnique('array_2_val', 3); + dietPlansObject.setAddUnique('array_2_val', 4); + dietPlansObject.setRemove('array_3_val', 3); + final relation = dietPlansObject.getRelation('relation_val'); + relation.add(ParseObject('object_in_relation2')..objectId = 'GDIJPWW'); + + // act + final encodeResult = parseEncode(dietPlansObject, full: true); + + // assert + + const expectedValue = { + "className": "Diet_Plans", + "pointer_val": {"className": "plan", "somePlanKey": "some value"}, + "int_val": { + "className": "ParseNumber", + "estimateNumber": 4, + "savedNumber": 0.0, + "setMode": true, + "lastPreformedOperation": { + "__op": "Increment", + "amount": 4.0, + "estimatedValue": 4 + } + }, + "string_val": "some String", + "double_val": { + "className": "ParseNumber", + "estimateNumber": 0.5, + "savedNumber": 0.0, + "setMode": true, + "lastPreformedOperation": { + "__op": "Increment", + "amount": 0.5, + "estimatedValue": 0.5 + } + }, + "array_1_val": { + "className": "ParseArray", + "estimatedArray": [1, 2, 3, 3], + "savedArray": [], + "lastPreformedOperation": null + }, + "array_2_val": { + "className": "ParseArray", + "estimatedArray": [1, 2, 3, 4], + "savedArray": [], + "lastPreformedOperation": null + }, + "array_3_val": { + "className": "ParseArray", + "estimatedArray": [1, 2], + "savedArray": [], + "lastPreformedOperation": null + }, + "relation_val": { + "className": "ParseRelation", + "targetClass": "object_in_relation2", + "key": "relation_val", + "objects": [ + {"className": "object_in_relation2", "objectId": "GDIJPWW"} + ], + "lastPreformedOperation": { + "__op": "AddRelation", + "objects": [ + {"className": "object_in_relation2", "objectId": "GDIJPWW"} + ], + "valueForAPIRequest": [ + {"className": "object_in_relation2", "objectId": "GDIJPWW"} + ] + } + } + }; + + expect(jsonEncode(encodeResult), equals(jsonEncode(expectedValue))); + }); + }); +} diff --git a/packages/dart/test/test_utils.dart b/packages/dart/test/test_utils.dart index 2d7adfec8..2f138551a 100644 --- a/packages/dart/test/test_utils.dart +++ b/packages/dart/test/test_utils.dart @@ -119,7 +119,9 @@ void testUnmergeableOperationShouldThrow({ expect( () => Function.apply(testingOn, testingOnValue), - throwsA(isA()), + throwsA(isA()), + reason: 'Calling {{${functionRef.toString()}}} ' + 'then {{${testingOn.toString()}}} should throw ParseOperationException', ); } }