diff --git a/lib/kiota_abstractions.dart b/lib/kiota_abstractions.dart index 521cf22..f072e84 100644 --- a/lib/kiota_abstractions.dart +++ b/lib/kiota_abstractions.dart @@ -49,3 +49,12 @@ part 'src/serialization/serialization_writer_factory.dart'; part 'src/serialization/serialization_writer_factory_registry.dart'; part 'src/time_only.dart'; part 'src/serialization/serialization_writer_proxy_factory.dart'; +part 'src/store/backed_model.dart'; +part 'src/store/backing_store.dart'; +part 'src/store/backing_store_factory.dart'; +part 'src/store/backing_store_factory_singleton.dart'; +part 'src/store/backing_store_parse_node_factory.dart'; +part 'src/store/backing_store_serialization_writer_proxy_factory.dart'; +part 'src/store/backing_store_subscription_callback.dart'; +part 'src/store/in_memory_backing_store.dart'; +part 'src/store/in_memory_backing_store_factory.dart'; diff --git a/lib/src/store/backed_model.dart b/lib/src/store/backed_model.dart new file mode 100644 index 0000000..bb8d8cd --- /dev/null +++ b/lib/src/store/backed_model.dart @@ -0,0 +1,7 @@ +part of '../../kiota_abstractions.dart'; + +/// Defines the contract for a model that is backed by a store. +abstract class BackedModel { + /// Gets the store that is backing the model. + BackingStore? get backingStore; +} diff --git a/lib/src/store/backing_store.dart b/lib/src/store/backing_store.dart new file mode 100644 index 0000000..85d2744 --- /dev/null +++ b/lib/src/store/backing_store.dart @@ -0,0 +1,46 @@ +part of '../../kiota_abstractions.dart'; + +/// Stores model information in a different location than the object properties. +/// +/// Implementations can provide dirty tracking and caching capabilities or +/// integration with 3rd party stores. +abstract class BackingStore { + /// Gets a value from the backing store based on its key. Returns `null` if + /// the value hasn't changed and [returnOnlyChangedValues] is `true`. + T? get(String key); + + /// Sets or updates the stored value for the given key. + /// + /// Will trigger subscriptions callbacks. + void set(String key, T value); + + /// Iterates all the values stored in the backing store. Values will be + /// filtered if [returnOnlyChangedValues] is `true`. + Iterable> iterate(); + + /// Iterates the keys for all values that changed to `null`. + Iterable iterateKeysForValuesChangedToNull(); + + /// Creates a subscription to any data change happening, optionally specifying + /// a [subscriptionId] to be able to unsubscribe later. + /// + /// The given [callback] is invoked on data changes. + String subscribe( + BackingStoreSubscriptionCallback callback, [ + String? subscriptionId, + ]); + + /// Unsubscribes a subscription by its [subscriptionId]. + void unsubscribe(String subscriptionId); + + /// Clears all the stored values. Doesn't trigger any subscription callbacks. + void clear(); + + /// Whether to return only values that have changed since the initialization + /// of the object when calling [get] and [iterate] methods. + abstract bool returnOnlyChangedValues; + + /// Whether the initialization of the object and/or the initial + /// deserialization has been competed to track whether objects have changed. + abstract bool initializationCompleted; +} diff --git a/lib/src/store/backing_store_factory.dart b/lib/src/store/backing_store_factory.dart new file mode 100644 index 0000000..5aa3366 --- /dev/null +++ b/lib/src/store/backing_store_factory.dart @@ -0,0 +1,7 @@ +part of '../../kiota_abstractions.dart'; + +/// Defines the contract for a factory that creates a [BackingStore]. +abstract class BackingStoreFactory { + /// Creates a new instance of the [BackingStore]. + BackingStore createBackingStore(); +} diff --git a/lib/src/store/backing_store_factory_singleton.dart b/lib/src/store/backing_store_factory_singleton.dart new file mode 100644 index 0000000..f69b1cc --- /dev/null +++ b/lib/src/store/backing_store_factory_singleton.dart @@ -0,0 +1,9 @@ +part of '../../kiota_abstractions.dart'; + +/// This class is used to register the backing store factory. +class BackingStoreFactorySingleton { + static final BackingStoreFactory _instance = InMemoryBackingStoreFactory(); + + /// The backing store factory singleton instance. + static BackingStoreFactory get instance => _instance; +} diff --git a/lib/src/store/backing_store_parse_node_factory.dart b/lib/src/store/backing_store_parse_node_factory.dart new file mode 100644 index 0000000..2d18b1e --- /dev/null +++ b/lib/src/store/backing_store_parse_node_factory.dart @@ -0,0 +1,28 @@ +part of '../../kiota_abstractions.dart'; + +/// Proxy implementation of [ParseNodeFactory] that allows for the +/// [BackingStore] that automatically sets the state of the [BackingStore] +/// when deserializing. +class BackingStoreParseNodeFactory extends ParseNodeProxyFactory { + /// Creates a new instance of the [BackingStoreParseNodeFactory] class. + BackingStoreParseNodeFactory({ + required super.concrete, + }) : super( + onBefore: (parsable) { + if (parsable is BackedModel) { + final model = parsable as BackedModel; + if (model.backingStore != null) { + model.backingStore!.initializationCompleted = false; + } + } + }, + onAfter: (parsable) { + if (parsable is BackedModel) { + final model = parsable as BackedModel; + if (model.backingStore != null) { + model.backingStore!.initializationCompleted = true; + } + } + }, + ); +} diff --git a/lib/src/store/backing_store_serialization_writer_proxy_factory.dart b/lib/src/store/backing_store_serialization_writer_proxy_factory.dart new file mode 100644 index 0000000..71a4b86 --- /dev/null +++ b/lib/src/store/backing_store_serialization_writer_proxy_factory.dart @@ -0,0 +1,42 @@ +part of '../../kiota_abstractions.dart'; + +/// Proxy implementation of [SerializationWriterFactory] for the [BackingStore] +/// that automatically sets the state of the backing store when serializing. +class BackingStoreSerializationWriterProxyFactory + extends SerializationWriterProxyFactory { + /// Creates a new instance of [BackingStoreSerializationWriterProxyFactory] + /// with the provided concrete factory. + BackingStoreSerializationWriterProxyFactory({ + required super.concrete, + }) : super( + onBefore: (p) { + if (p is BackedModel) { + final model = p as BackedModel; + if (model.backingStore != null) { + model.backingStore!.returnOnlyChangedValues = true; + } + } + }, + onAfter: (p) { + if (p is BackedModel) { + final model = p as BackedModel; + if (model.backingStore != null) { + model.backingStore!.returnOnlyChangedValues = false; + model.backingStore!.initializationCompleted = true; + } + } + }, + onStart: (p, writer) { + if (p is BackedModel) { + final model = p as BackedModel; + if (model.backingStore != null) { + model.backingStore! + .iterateKeysForValuesChangedToNull() + .forEach((element) { + writer.writeNullValue(element); + }); + } + } + }, + ); +} diff --git a/lib/src/store/backing_store_subscription_callback.dart b/lib/src/store/backing_store_subscription_callback.dart new file mode 100644 index 0000000..ed6bda6 --- /dev/null +++ b/lib/src/store/backing_store_subscription_callback.dart @@ -0,0 +1,9 @@ +part of '../../kiota_abstractions.dart'; + +/// Defines the contract for a callback that is invoked when a value in the +/// [BackingStore] changes. +typedef BackingStoreSubscriptionCallback = void Function( + String dataKey, + Object? previousValue, + Object? newValue, +); diff --git a/lib/src/store/in_memory_backing_store.dart b/lib/src/store/in_memory_backing_store.dart new file mode 100644 index 0000000..03f72a6 --- /dev/null +++ b/lib/src/store/in_memory_backing_store.dart @@ -0,0 +1,175 @@ +part of '../../kiota_abstractions.dart'; + +class InMemoryBackingStore implements BackingStore { + final Map _store = {}; + final Map _subscriptions = {}; + + bool _initializationCompleted = true; + + @override + bool get initializationCompleted => _initializationCompleted; + + @override + set initializationCompleted(bool value) { + _initializationCompleted = value; + + for (final key in _store.keys) { + final tuple = _store[key]!; + final obj = tuple.$2; + + if (obj is BackedModel) { + obj.backingStore?.initializationCompleted = value; + } + + _ensureCollectionPropertyIsConsistent(key, obj); + + _store[key] = (!value, obj); + } + } + + void _ensureCollectionPropertyIsConsistent(String key, Object? value) { + // check if we put in a collection annotated with the size + if (value is (Iterable, int)) { + value.$1.whereType().forEach((model) { + model.backingStore?.iterate().forEach((item) { + // Call get() on nested properties so that this method may be called + // recursively to ensure collections are consistent + model.backingStore?.get(item.key); + }); + }); + + // (and the size has changed since we last updated) + if (value.$1.length != value.$2) { + // ensure the store is notified the collection property has changed + set(key, value.$1); + } + } else if (value is BackedModel) { + value.backingStore?.iterate().forEach((item) { + // Call get() on nested properties so that this method may be called + // recursively to ensure collections are consistent + value.backingStore?.get(item.key); + }); + } + } + + @override + bool returnOnlyChangedValues = false; + + @override + void clear() => _store.clear(); + + @override + T? get(String key) { + if (key.isEmpty) { + throw ArgumentError('The key cannot be empty.'); + } + + if (!_store.containsKey(key)) { + return null; + } + + final tuple = _store[key]!; + final changed = tuple.$1; + var obj = tuple.$2; + + _ensureCollectionPropertyIsConsistent(key, obj); + + if (obj is (Iterable, int)) { + obj = obj.$1; + } + + return changed || !returnOnlyChangedValues ? obj as T? : null; + } + + @override + Iterable> iterate() sync* { + for (final key in _store.keys) { + final tuple = _store[key]!; + final changed = tuple.$1; + final obj = tuple.$2; + + if (returnOnlyChangedValues) { + _ensureCollectionPropertyIsConsistent(key, obj); + } + + if (changed || !returnOnlyChangedValues) { + yield MapEntry(key, obj); + } + } + } + + @override + Iterable iterateKeysForValuesChangedToNull() sync* { + for (final key in _store.keys) { + final tuple = _store[key]!; + final changed = tuple.$1; + final obj = tuple.$2; + + if (changed && obj == null) { + yield key; + } + } + } + + @override + void set(String key, T value) { + if (key.isEmpty) { + throw ArgumentError('The key cannot be empty.'); + } + + (bool, Object?) valueToAdd = (initializationCompleted, value); + if (value is Iterable) { + valueToAdd = (initializationCompleted, (value, value.length)); + } + + (bool, Object?)? oldValue; + if (_store.containsKey(key)) { + oldValue = _store[key]; + } else if (value is BackedModel) { + value.backingStore?.subscribe( + (dataKey, previousValue, newValue) { + // all its properties are dirty as the model has been touched + value.backingStore!.initializationCompleted = false; + + set(key, value); + }, + key, + ); + } + + _store[key] = valueToAdd; + + if (value is Iterable) { + value.whereType().forEach((model) { + model.backingStore?.initializationCompleted = false; + model.backingStore?.subscribe( + (dataKey, previousValue, newValue) { + set(key, value); + }, + key, + ); + }); + } + + for (final subscription in _subscriptions.values) { + subscription(key, oldValue?.$2, value); + } + } + + @override + String subscribe( + BackingStoreSubscriptionCallback callback, [ + String? subscriptionId, + ]) { + subscriptionId ??= const Uuid().v4(); + + _subscriptions[subscriptionId] = callback; + + return subscriptionId; + } + + @override + void unsubscribe(String subscriptionId) { + _subscriptions.remove(subscriptionId); + } +} diff --git a/lib/src/store/in_memory_backing_store_factory.dart b/lib/src/store/in_memory_backing_store_factory.dart new file mode 100644 index 0000000..6e8e405 --- /dev/null +++ b/lib/src/store/in_memory_backing_store_factory.dart @@ -0,0 +1,7 @@ +part of '../../kiota_abstractions.dart'; + +/// This class is used to create instances of [InMemoryBackingStore]. +class InMemoryBackingStoreFactory implements BackingStoreFactory { + @override + BackingStore createBackingStore() => InMemoryBackingStore(); +} diff --git a/test/store/in_memory_backing_store_test.dart b/test/store/in_memory_backing_store_test.dart new file mode 100644 index 0000000..2f36ad3 --- /dev/null +++ b/test/store/in_memory_backing_store_test.dart @@ -0,0 +1,127 @@ +import 'package:kiota_abstractions/kiota_abstractions.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'in_memory_backing_store_test.mocks.dart'; + +@GenerateMocks([BackedModel]) +void main() { + group('InMemoryBackingStore', () { + test('stores values by key', () { + final store = InMemoryBackingStore()..set('key', 'value'); + + expect(store.get('key'), 'value'); + }); + + test('clears all values', () { + final store = InMemoryBackingStore() + ..set('key', 'value') + ..set('key2', 'value2') + ..clear(); + + expect(store.get('key'), null); + }); + + test('iterates over all values', () { + final store = InMemoryBackingStore(); + + expect(store.iterate(), isEmpty); + + store + ..set('key', 'value') + ..set('key2', 'value2'); + + final entries = store.iterate().toList(); + + expect(entries, hasLength(2)); + expect( + entries.map((e) => e.key), + containsAll(['key', 'key2']), + ); + expect( + entries.map((e) => e.value), + containsAll(['value', 'value2']), + ); + }); + + test('iterates over all values that have changed to null', () { + final store = InMemoryBackingStore() + ..set('name', 'Peter Pan') + ..set('email', 'peterpan@neverland.com') + ..set('phone', null); + + final changedToNullEntries = + store.iterateKeysForValuesChangedToNull().toList(); + final entries = store.iterate().toList(); + + expect(entries, hasLength(3)); + expect(changedToNullEntries, hasLength(1)); + expect(changedToNullEntries, contains('phone')); + }); + + test('prevents duplicates in store', () { + final store = InMemoryBackingStore(); + + expect(store.iterate(), isEmpty); + + store + ..set('key', 'value') + ..set('key', 'value2'); + + expect(store.iterate(), hasLength(1)); + expect(store.get('key'), 'value2'); + }); + + test('propagates initialization completed to backed models', () { + final aModel = MockBackedModel(); + final aStore = InMemoryBackingStore(); + + when(aModel.backingStore).thenReturn(aStore); + + final bStore = InMemoryBackingStore(); + + expect(aStore.initializationCompleted, isTrue); + expect(bStore.initializationCompleted, isTrue); + + bStore + ..set('aModel', aModel) + ..initializationCompleted = false; + + expect(aStore.initializationCompleted, isFalse); + expect(bStore.initializationCompleted, isFalse); + + bStore.initializationCompleted = true; + + expect(aStore.initializationCompleted, isTrue); + expect(bStore.initializationCompleted, isTrue); + + final cModel = MockBackedModel(); + final cStore = InMemoryBackingStore(); + when(cModel.backingStore).thenReturn(cStore); + + bStore.set('cModel', cModel); + }); + + test('subscriptions get notified', () + { + final store = InMemoryBackingStore(); + + String? key; + Object? oldValue; + Object? newValue; + final subscriptionId = store.subscribe((k, a, b) { + key = k; + oldValue = a; + newValue = b; + }); + + store.set('name', 'Peter'); + + expect(key, 'name'); + expect(oldValue, null); + expect(newValue, 'Peter'); + expect(subscriptionId, isNotNull); + }); + }); +} diff --git a/test/store/in_memory_backing_store_test.mocks.dart b/test/store/in_memory_backing_store_test.mocks.dart new file mode 100644 index 0000000..812e098 --- /dev/null +++ b/test/store/in_memory_backing_store_test.mocks.dart @@ -0,0 +1,29 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in kiota_abstractions/test/in_memory_backing_store_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:kiota_abstractions/kiota_abstractions.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [BackedModel]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBackedModel extends _i1.Mock implements _i2.BackedModel { + MockBackedModel() { + _i1.throwOnMissingStub(this); + } +}