diff --git a/lib/data/database_repository.dart b/lib/data/database_repository.dart new file mode 100644 index 00000000..ba59c244 --- /dev/null +++ b/lib/data/database_repository.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:sembast/sembast.dart'; + +/// Handles the local persistence to the database +abstract class DatabaseRepository { + /// Adds an [object] to the [store], using a [serializer] + /// + /// If there is already an object with the same [KeyStorable.id], the default behavior is to merge all of its fields. + /// [shouldMerge] should be `false` if pre-existing fields should not be merged. + Future put({ + required T object, + required JsonSerializer serializer, + required DatabaseStore store, + bool shouldMerge = true, + }); + + /// Deletes the value with [key] from the [store] + Future removeObject({required String key, required DatabaseStore store}); + + /// Retrieves an object with [key] from the [store] + /// + /// Returns `null` if the key doesn't exist + Future getObject({ + required String key, + required JsonSerializer serializer, + required DatabaseStore store, + }); + + /// Retrieves all objects within [store] + Future> getAll({ + required JsonSerializer serializer, + required DatabaseStore store, + }); + + /// Retrieves a stream of all the [store] objects, triggered whenever any update occurs to this [store] + Future>> listenAll({ + required JsonSerializer serializer, + required DatabaseStore store, + }); +} + +enum DatabaseStore { + decks, + cards, + executions, + resources, +} + +/// Middleware that should be responsible of parsing a type [T] to/from a JSON representation +abstract class JsonSerializer { + T fromMap(Map json); + Map mapOf(T object); +} + +/// Base class that adds a key [id] to allow its implementation to be stored/identified in any database +abstract class KeyStorable extends Equatable { + const KeyStorable({required this.id}); + final String id; +} + +// +// DatabaseRepository implementation using `sembast` +// +class DatabaseRepositoryImpl implements DatabaseRepository { + DatabaseRepositoryImpl(this._db); + + // `sembast` database instance + final Database _db; + + @override + Future put({ + required T object, + required JsonSerializer serializer, + required DatabaseStore store, + bool shouldMerge = true, + }) async { + final storeMap = stringMapStoreFactory.store(store.key); + final deserializedObject = serializer.mapOf(object); + + await storeMap.record(object.id).put(_db, deserializedObject, merge: shouldMerge); + } + + @override + Future removeObject({required String key, required DatabaseStore store}) async { + final storeMap = stringMapStoreFactory.store(store.key); + await storeMap.record(key).delete(_db); + } + + @override + Future getObject({ + required String key, + required JsonSerializer serializer, + required DatabaseStore store, + }) async { + final storeMap = stringMapStoreFactory.store(store.key); + final rawObject = await storeMap.record(key).get(_db); + + if (rawObject != null) { + return serializer.fromMap(rawObject); + } + + return null; + } + + @override + Future> getAll({ + required JsonSerializer serializer, + required DatabaseStore store, + }) async { + final storeMap = stringMapStoreFactory.store(store.key); + + final allRecords = await storeMap.find(_db); + + return allRecords.map((record) => serializer.fromMap(record.value)).toList(); + } + + @override + Future>> listenAll({ + required JsonSerializer serializer, + required DatabaseStore store, + }) async { + final storeMap = stringMapStoreFactory.store(store.key); + + final transformer = _snapshotSerializerTransformer(serializer); + return storeMap.query().onSnapshots(_db).transform(transformer); + } + + /// Transforms a list of `sembast` snapshot records into a list of objects parsed by [serializer] + StreamTransformer>>, List> + _snapshotSerializerTransformer(JsonSerializer serializer) { + return StreamTransformer.fromHandlers( + handleData: (snapshots, sink) { + final transformedRecords = snapshots.map((record) => serializer.fromMap(record.value)).toList(); + sink.add(transformedRecords); + }, + ); + } +} + +extension StoreKeys on DatabaseStore { + @visibleForTesting + String get key { + switch (this) { + case DatabaseStore.decks: + return 'decks'; + case DatabaseStore.cards: + return 'cards'; + case DatabaseStore.executions: + return 'card_executions'; + case DatabaseStore.resources: + return 'resources'; + } + } +} diff --git a/lib/data/sembast_database.dart b/lib/data/sembast_database.dart new file mode 100644 index 00000000..101c52bb --- /dev/null +++ b/lib/data/sembast_database.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; + +const _schemaVersion = 1; +const _dbName = 'memo_sembast.db'; + +/// Opens this application's [Database], creating a new one if nonexistent +Future openDatabase() async { + final dir = await getApplicationDocumentsDirectory(); + // Make sure that the application documents directory exists + await dir.create(recursive: true); + + final dbPath = path.join(dir.path, _dbName); + + return databaseFactoryIo.openDatabase(dbPath, version: _schemaVersion, onVersionChanged: applyMigrations); +} + +@visibleForTesting +Future applyMigrations(Database db, int oldVersion, int newVersion) async { + // Call the necessary migrations in order +} + +// +// Migrations +// + +// Example: +// Future migrateToVersion2(Database db) async { +// final store = stringMapStoreFactory.store('storeThatNeedsMigration'); +// final updatableItemsFinder = Finder(filter: Filter.equals('myUpdatedField', 1)); +// await store.update(db, { 'myUpdatedField': 2 }, finder: updatableItemsFinder); +// } diff --git a/pubspec.lock b/pubspec.lock index 3f05e28e..174fcc77 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,27 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "19.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" async: dependency: transitive description: @@ -29,6 +50,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" clock: dependency: transitive description: @@ -43,6 +71,34 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" fake_async: dependency: transitive description: @@ -50,6 +106,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" flutter: dependency: "direct main" description: flutter @@ -60,6 +130,48 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" matcher: dependency: transitive description: @@ -68,24 +180,178 @@ packages: source: hosted version: "0.12.10" meta: - dependency: transitive + dependency: "direct main" description: name: meta url: "https://pub.dartlang.org" source: hosted version: "1.3.0" - path: + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1" + node_preamble: dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.13" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path: + dependency: "direct main" description: name: path url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + sembast: + dependency: "direct main" + description: + name: sembast + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+4" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" source_span: dependency: transitive description: @@ -121,6 +387,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" term_glyph: dependency: transitive description: @@ -128,6 +401,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + test: + dependency: transitive + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.5" test_api: dependency: transitive description: @@ -135,6 +415,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.19" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.15" typed_data: dependency: transitive description: @@ -142,6 +429,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" vector_math: dependency: transitive description: @@ -149,5 +443,55 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0+1" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" sdks: dart: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" diff --git a/pubspec.yaml b/pubspec.yaml index 6de57216..96e77f38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,11 +14,34 @@ dependencies: flutter: sdk: flutter + ### + # Core + ### + meta: ^1.3.0 + equatable: ^2.0.0 + path: ^1.8.0 + path_provider: ^2.0.0 + + ### + # Database & Storage + ### + sembast: ^3.0.0 + uuid: ^3.0.0 + dev_dependencies: - strict: ^1.0.0 flutter_test: sdk: flutter + ### + # Lint + ### + strict: ^1.0.0 + + ### + # Testing + ### + mocktail: ^0.1.0 + flutter: # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/data/database_repository_test.dart b/test/data/database_repository_test.dart new file mode 100644 index 00000000..05fb1c38 --- /dev/null +++ b/test/data/database_repository_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:memo/data/database_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_memory.dart'; + +class MockSerializer extends Mock implements JsonSerializer {} + +// ignore: avoid_implementing_value_types +class MockSerializable extends Mock implements KeyStorable {} + +void main() { + final serializer = MockSerializer(); + final serializable = MockSerializable(); + + final fakeRawSerializable = {'fake': 'fake'}; + const fakeSerializableId = 'a-fake-id'; + const fakeStore = DatabaseStore.cards; + final fakeRecord = stringMapStoreFactory.store(fakeStore.key).record(fakeSerializableId); + + late Database memorySembast; + late DatabaseRepository db; + + setUpAll(() { + registerFallbackValue(MockSerializable()); + }); + + setUp(() async { + await databaseFactoryMemory.deleteDatabase('test.db'); + memorySembast = await databaseFactoryMemory.openDatabase('test.db'); + db = DatabaseRepositoryImpl(memorySembast); + + reset(serializer); + reset(serializable); + + when(() => serializer.mapOf(any())).thenReturn(fakeRawSerializable); + when(() => serializer.fromMap(any())).thenReturn(serializable); + when(() => serializable.id).thenReturn(fakeSerializableId); + }); + + test('DatabaseRepositoryImpl should put a new object', () async { + expect(await fakeRecord.get(memorySembast), null); + + await db.put(object: serializable, serializer: serializer, store: fakeStore); + + expect(await fakeRecord.get(memorySembast), fakeRawSerializable); + verify(() => serializer.mapOf(serializable)).called(1); + }); + + test('DatabaseRepositoryImpl should update an existing object', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + + final fakeUpdatedRawSerializable = {'fake': 'fakeUpdated', 'newFake': 'fake'}; + when(() => serializer.mapOf(any())).thenReturn(fakeUpdatedRawSerializable); + + await db.put(object: serializable, serializer: serializer, store: fakeStore); + + expect(await fakeRecord.get(memorySembast), fakeUpdatedRawSerializable); + verify(() => serializer.mapOf(serializable)).called(1); + }); + + test('DatabaseRepositoryImpl should remove pre-existing fields in an update without merge', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + + final fakeUpdatedRawSerializable = {'newFake': 'fake'}; + when(() => serializer.mapOf(any())).thenReturn(fakeUpdatedRawSerializable); + + await db.put(object: serializable, serializer: serializer, store: fakeStore, shouldMerge: false); + + expect(await fakeRecord.get(memorySembast), fakeUpdatedRawSerializable); + verify(() => serializer.mapOf(serializable)).called(1); + }); + + test('DatabaseRepositoryImpl should maintain pre-existing fields in an update with merge', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + + final fakeUpdatedRawSerializable = {'newFake': 'fake'}; + when(() => serializer.mapOf(any())).thenReturn(fakeUpdatedRawSerializable); + + await db.put(object: serializable, serializer: serializer, store: fakeStore); + + fakeUpdatedRawSerializable.addAll(fakeRawSerializable); + expect(await fakeRecord.get(memorySembast), fakeUpdatedRawSerializable); + verify(() => serializer.mapOf(serializable)).called(1); + }); + + test('DatabaseRepositoryImpl should remove an existing object', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + + expect(await fakeRecord.get(memorySembast), fakeRawSerializable); + await db.removeObject(key: fakeSerializableId, store: fakeStore); + expect(await fakeRecord.get(memorySembast), null); + }); + + test('DatabaseRepositoryImpl should do nothing when removing a nonexistent object', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + + await db.removeObject(key: fakeSerializableId, store: fakeStore); + expect(await fakeRecord.get(memorySembast), null); + }); + + test('DatabaseRepositoryImpl should retrieve a single existing object', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + + final object = await db.getObject( + key: fakeSerializableId, + serializer: serializer, + store: fakeStore, + ); + expect(object, isNotNull); + }); + + test('DatabaseRepositoryImpl should get null when retrieving a single nonexistent object', () async { + final object = await db.getObject( + key: fakeSerializableId, + serializer: serializer, + store: fakeStore, + ); + expect(object, null); + }); + + test('DatabaseRepositoryImpl should retrieve multiple objects', () async { + await fakeRecord.put(memorySembast, fakeRawSerializable); + await stringMapStoreFactory.store(fakeStore.key).record('2').put(memorySembast, fakeRawSerializable); + + final objects = await db.getAll(serializer: serializer, store: fakeStore); + expect(objects.length, 2); + }); + + test('DatabaseRepositoryImpl should retrieve an empty list if there is no objects in the store', () async { + final objects = await db.getAll(serializer: serializer, store: fakeStore); + expect(objects.isEmpty, true); + }); + + // TODO(matuella): Find a way to test the listenAll +} diff --git a/test/data/sembast_database_test.dart b/test/data/sembast_database_test.dart new file mode 100644 index 00000000..75a12c43 --- /dev/null +++ b/test/data/sembast_database_test.dart @@ -0,0 +1,3 @@ +void main() { + // `applyMigrations` tests should go here when new versions are added +}