diff --git a/README.md b/README.md index fc74960e..a48900b3 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This package is still in an early phase and the API will likely change. 1. [How to use this library](#how-to-use-this-library) 1. [Querying](#querying) 1. [Persisting Data Changes](#persisting-data-changes) +1. [Streams](#streams) 1. [Transactions](#transactions) 1. [Entities](#entities) 1. [Foreign Keys](#foreign-keys) @@ -133,7 +134,7 @@ For further examples take a look at the [example](https://github.com/vitusortner Method signatures turn into query methods by adding the `@Query()` annotation with the query in parenthesis to them. Be patient about the correctness of your SQL statements. They are only partly validated while generating the code. -These queries have to return either a `Future` of an entity or `void`. +These queries have to return either a `Future` or a `Stream` of an entity or `void`. Returning `Future` comes in handy whenever you want to delete the full content of a table. ````dart @@ -146,6 +147,9 @@ Future findPersonByIdAndName(int id, String name); @Query('SELECT * FROM Person') Future> findAllPersons(); // select multiple items +@Query('SELECT * FROM Person') +Stream> findAllPersonsAsStream(); // stream return + @Query('DELETE FROM Person') Future deleteAllPersons(); // query without returning an entity ```` @@ -193,6 +197,24 @@ Future updatePersons(List person); Future deletePersons(List person); ``` +## Streams +As already mentioned, queries can not only return a value once when called, but also a continuous stream of query results. +The returned stream keeps you in sync with the changes happening to the database table. +This feature plays really well with the `StreamBuilder` widget. +```dart +// definition +@Query('SELECT * FROM Person') +Stream> findAllPersonsAsStream(); + +// usage +StreamBuilder>( + stream: database.findAllPersonsAsStream(), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + // do something with the values here + }, +); +``` + ## Transactions Whenever you want to perform some operations in a transaction you have to add the `@transaction` annotation to the method. It's also required to add the `async` modifier. These methods can only return `Future`. diff --git a/floor/lib/src/adapter/deletion_adapter.dart b/floor/lib/src/adapter/deletion_adapter.dart index 2ca3a28e..18db66a4 100644 --- a/floor/lib/src/adapter/deletion_adapter.dart +++ b/floor/lib/src/adapter/deletion_adapter.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:sqflite/sqflite.dart'; class DeletionAdapter { @@ -5,13 +7,15 @@ class DeletionAdapter { final String _entityName; final String _primaryKeyColumnName; final Map Function(T) _valueMapper; + final StreamController _changeListener; DeletionAdapter( final DatabaseExecutor database, final String entityName, final String primaryKeyColumnName, - final Map Function(T) valueMapper, - ) : assert(database != null), + final Map Function(T) valueMapper, [ + final StreamController changeListener, + ]) : assert(database != null), assert(entityName != null), assert(entityName.isNotEmpty), assert(primaryKeyColumnName != null), @@ -20,7 +24,8 @@ class DeletionAdapter { _database = database, _entityName = entityName, _primaryKeyColumnName = primaryKeyColumnName, - _valueMapper = valueMapper; + _valueMapper = valueMapper, + _changeListener = changeListener; Future delete(final T item) async { await _delete(item); @@ -28,10 +33,7 @@ class DeletionAdapter { Future deleteList(final List items) async { if (items.isEmpty) return; - - final batch = _database.batch(); - _deleteList(batch, items); - await batch.commit(noResult: true); + await _deleteList(items); } Future deleteAndReturnChangedRows(final T item) { @@ -40,25 +42,25 @@ class DeletionAdapter { Future deleteListAndReturnChangedRows(final List items) async { if (items.isEmpty) return 0; - - final batch = _database.batch(); - _deleteList(batch, items); - return (await batch.commit(noResult: false)) - .cast() - .reduce((sum, element) => sum + element); + return _deleteList(items); } - Future _delete(final T item) { + Future _delete(final T item) async { final int primaryKey = _valueMapper(item)[_primaryKeyColumnName]; - return _database.delete( + final result = await _database.delete( _entityName, where: '$_primaryKeyColumnName = ?', whereArgs: [primaryKey], ); + if (_changeListener != null && result != 0) { + _changeListener.add(_entityName); + } + return result; } - void _deleteList(final Batch batch, final List items) { + Future _deleteList(final List items) async { + final batch = _database.batch(); for (final item in items) { final int primaryKey = _valueMapper(item)[_primaryKeyColumnName]; @@ -68,5 +70,12 @@ class DeletionAdapter { whereArgs: [primaryKey], ); } + final result = (await batch.commit(noResult: false)).cast(); + if (_changeListener != null && result.isNotEmpty) { + _changeListener.add(_entityName); + } + return result.isNotEmpty + ? result.reduce((sum, element) => sum + element) + : 0; } } diff --git a/floor/lib/src/adapter/insertion_adapter.dart b/floor/lib/src/adapter/insertion_adapter.dart index 2fbcc7dd..c560e24e 100644 --- a/floor/lib/src/adapter/insertion_adapter.dart +++ b/floor/lib/src/adapter/insertion_adapter.dart @@ -1,21 +1,26 @@ +import 'dart:async'; + import 'package:sqflite/sqflite.dart'; class InsertionAdapter { final DatabaseExecutor _database; final String _entityName; final Map Function(T) _valueMapper; + final StreamController _changeListener; InsertionAdapter( final DatabaseExecutor database, final String entityName, - final Map Function(T) valueMapper, - ) : assert(database != null), + final Map Function(T) valueMapper, [ + final StreamController changeListener, + ]) : assert(database != null), assert(entityName != null), assert(entityName.isNotEmpty), assert(valueMapper != null), _database = database, _entityName = entityName, - _valueMapper = valueMapper; + _valueMapper = valueMapper, + _changeListener = changeListener; Future insert( final T item, @@ -29,10 +34,7 @@ class InsertionAdapter { final ConflictAlgorithm conflictAlgorithm, ) async { if (items.isEmpty) return; - - final batch = _database.batch(); - _insertList(batch, items, conflictAlgorithm); - await batch.commit(noResult: true); + await _insertList(items, conflictAlgorithm); } Future insertAndReturnId( @@ -47,25 +49,29 @@ class InsertionAdapter { final ConflictAlgorithm conflictAlgorithm, ) async { if (items.isEmpty) return []; - - final batch = _database.batch(); - _insertList(batch, items, conflictAlgorithm); - return (await batch.commit(noResult: false)).cast(); + return _insertList(items, conflictAlgorithm); } - Future _insert(final T item, final ConflictAlgorithm conflictAlgorithm) { - return _database.insert( + Future _insert( + final T item, + final ConflictAlgorithm conflictAlgorithm, + ) async { + final result = await _database.insert( _entityName, _valueMapper(item), conflictAlgorithm: conflictAlgorithm, ); + if (_changeListener != null && result != null) { + _changeListener.add(_entityName); + } + return result; } - void _insertList( - final Batch batch, + Future> _insertList( final List items, final ConflictAlgorithm conflictAlgorithm, - ) { + ) async { + final batch = _database.batch(); for (final item in items) { batch.insert( _entityName, @@ -73,5 +79,10 @@ class InsertionAdapter { conflictAlgorithm: conflictAlgorithm, ); } + final result = (await batch.commit(noResult: false)).cast(); + if (_changeListener != null && result.isNotEmpty) { + _changeListener.add(_entityName); + } + return result; } } diff --git a/floor/lib/src/adapter/query_adapter.dart b/floor/lib/src/adapter/query_adapter.dart index bb4b3a43..c71157ec 100644 --- a/floor/lib/src/adapter/query_adapter.dart +++ b/floor/lib/src/adapter/query_adapter.dart @@ -1,12 +1,18 @@ +import 'dart:async'; + import 'package:sqflite/sqflite.dart'; /// This class knows how to execute database queries. class QueryAdapter { final DatabaseExecutor _database; + final StreamController _changeListener; - QueryAdapter(final DatabaseExecutor database) - : assert(database != null), - _database = database; + QueryAdapter( + final DatabaseExecutor database, [ + final StreamController changeListener, + ]) : assert(database != null), + _database = database, + _changeListener = changeListener; Future query( final String sql, @@ -34,4 +40,68 @@ class QueryAdapter { Future queryNoReturn(final String sql) async { await _database.rawQuery(sql); } + + Stream queryStream( + final String sql, + final String entityName, + final T Function(Map) mapper, + ) { + assert(_changeListener != null); + + final controller = StreamController(); + + () async { + final result = await query(sql, mapper); + if (result != null) { + controller.add(result); + } + }(); + + final subscription = _changeListener.stream + .where((listener) => listener == entityName) + .listen((listener) async { + final result = await query(sql, mapper); + if (result != null) { + controller.add(result); + } + }, onDone: () { + controller.close(); + }); + + controller.onCancel = () { + subscription.cancel(); + }; + + return controller.stream; + } + + Stream> queryListStream( + final String sql, + final String entityName, + final T Function(Map) mapper, + ) { + assert(_changeListener != null); + + final controller = StreamController>(); + + () async { + final result = await queryList(sql, mapper); + controller.add(result); + }(); + + final subscription = _changeListener.stream + .where((listener) => listener == entityName) + .listen((listener) async { + final result = await queryList(sql, mapper); + controller.add(result); + }, onDone: () { + controller.close(); + }); + + controller.onCancel = () { + subscription.cancel(); + }; + + return controller.stream; + } } diff --git a/floor/lib/src/adapter/update_adapter.dart b/floor/lib/src/adapter/update_adapter.dart index f8446196..e88aa43f 100644 --- a/floor/lib/src/adapter/update_adapter.dart +++ b/floor/lib/src/adapter/update_adapter.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:sqflite/sqflite.dart'; class UpdateAdapter { @@ -5,13 +7,15 @@ class UpdateAdapter { final String _entityName; final String _primaryKeyColumnName; final Map Function(T) _valueMapper; + final StreamController _changeListener; UpdateAdapter( final DatabaseExecutor database, final String entityName, final String primaryKeyColumnName, - final Map Function(T) valueMapper, - ) : assert(database != null), + final Map Function(T) valueMapper, [ + final StreamController changeListener, + ]) : assert(database != null), assert(entityName != null), assert(entityName.isNotEmpty), assert(primaryKeyColumnName != null), @@ -20,7 +24,8 @@ class UpdateAdapter { _database = database, _entityName = entityName, _valueMapper = valueMapper, - _primaryKeyColumnName = primaryKeyColumnName; + _primaryKeyColumnName = primaryKeyColumnName, + _changeListener = changeListener; Future update( final T item, @@ -34,10 +39,7 @@ class UpdateAdapter { final ConflictAlgorithm conflictAlgorithm, ) async { if (items.isEmpty) return; - - final batch = _database.batch(); - _updateList(batch, items, conflictAlgorithm); - await batch.commit(noResult: true); + await _updateList(items, conflictAlgorithm); } Future updateAndReturnChangedRows( @@ -52,32 +54,34 @@ class UpdateAdapter { final ConflictAlgorithm conflictAlgorithm, ) async { if (items.isEmpty) return 0; - - final batch = _database.batch(); - _updateList(batch, items, conflictAlgorithm); - return (await batch.commit(noResult: false)) - .cast() - .reduce((sum, element) => sum + element); + return _updateList(items, conflictAlgorithm); } - Future _update(final T item, final ConflictAlgorithm conflictAlgorithm) { + Future _update( + final T item, + final ConflictAlgorithm conflictAlgorithm, + ) async { final values = _valueMapper(item); final int primaryKey = values[_primaryKeyColumnName]; - return _database.update( + final result = await _database.update( _entityName, values, where: '$_primaryKeyColumnName = ?', whereArgs: [primaryKey], conflictAlgorithm: conflictAlgorithm, ); + if (_changeListener != null && result != 0) { + _changeListener.add(_entityName); + } + return result; } - void _updateList( - final Batch batch, + Future _updateList( final List items, final ConflictAlgorithm conflictAlgorithm, - ) { + ) async { + final batch = _database.batch(); for (final item in items) { final values = _valueMapper(item); final int primaryKey = values[_primaryKeyColumnName]; @@ -90,5 +94,12 @@ class UpdateAdapter { conflictAlgorithm: conflictAlgorithm, ); } + final result = (await batch.commit(noResult: false)).cast(); + if (_changeListener != null && result.isNotEmpty) { + _changeListener.add(_entityName); + } + return result.isNotEmpty + ? result.reduce((sum, element) => sum + element) + : 0; } } diff --git a/floor/lib/src/database.dart b/floor/lib/src/database.dart index 755555cc..ae2d7410 100644 --- a/floor/lib/src/database.dart +++ b/floor/lib/src/database.dart @@ -1,9 +1,16 @@ +import 'dart:async'; + import 'package:floor/floor.dart'; +import 'package:meta/meta.dart'; import 'package:sqflite/sqflite.dart' as sqflite; /// Extend this class to enable database functionality. abstract class FloorDatabase { + @protected + final changeListener = StreamController.broadcast(); + /// Use this whenever you want need direct access to the sqflite database. + @protected sqflite.DatabaseExecutor database; // TODO remove this @@ -12,6 +19,8 @@ abstract class FloorDatabase { /// Closes the database. Future close() async { + await changeListener.close(); + final immutableDatabase = database; if (immutableDatabase is sqflite.Database && (immutableDatabase?.isOpen ?? false)) { diff --git a/floor/pubspec.yaml b/floor/pubspec.yaml index c1572d8b..7ceb2828 100644 --- a/floor/pubspec.yaml +++ b/floor/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: sqflite: ^1.1.1 floor_annotation: path: ../floor_annotation/ + +dev_dependencies: mockito: ^4.0.0 flutter_test: sdk: flutter diff --git a/floor/test/adapter/deletion_adapter.dart b/floor/test/adapter/deletion_adapter_test.dart similarity index 95% rename from floor/test/adapter/deletion_adapter.dart rename to floor/test/adapter/deletion_adapter_test.dart index 7634b10f..c3b2fe91 100644 --- a/floor/test/adapter/deletion_adapter.dart +++ b/floor/test/adapter/deletion_adapter_test.dart @@ -5,7 +5,6 @@ import 'package:mockito/mockito.dart'; import '../util/mocks.dart'; import '../util/person.dart'; - void main() { final mockDatabaseExecutor = MockDatabaseExecutor(); final mockDatabaseBatch = MockDatabaseBatch(); @@ -44,6 +43,8 @@ void main() { final person2 = Person(2, 'Frank'); final persons = [person1, person2]; when(mockDatabaseExecutor.batch()).thenReturn(mockDatabaseBatch); + when(mockDatabaseBatch.commit(noResult: false)) + .thenAnswer((_) => Future(() => [1, 1])); await underTest.deleteList(persons); @@ -59,7 +60,7 @@ void main() { where: '$primaryKeyColumnName = ?', whereArgs: [person2.id], ), - mockDatabaseBatch.commit(noResult: true), + mockDatabaseBatch.commit(noResult: false), ]); }); diff --git a/floor/test/adapter/insertion_adapter_test.dart b/floor/test/adapter/insertion_adapter_test.dart index 398fa9e7..66f2fc06 100644 --- a/floor/test/adapter/insertion_adapter_test.dart +++ b/floor/test/adapter/insertion_adapter_test.dart @@ -6,7 +6,6 @@ import 'package:sqflite/sqflite.dart'; import '../util/mocks.dart'; import '../util/person.dart'; - void main() { final mockDatabaseExecutor = MockDatabaseExecutor(); final mockDatabaseBatch = MockDatabaseBatch(); @@ -16,85 +15,207 @@ void main() { {'id': person.id, 'name': person.name}; const conflictAlgorithm = ConflictAlgorithm.ignore; - final underTest = InsertionAdapter( - mockDatabaseExecutor, - entityName, - valueMapper, - ); - tearDown(() { clearInteractions(mockDatabaseExecutor); + clearInteractions(mockDatabaseBatch); + reset(mockDatabaseExecutor); + reset(mockDatabaseBatch); }); - group('insertion without return', () { - test('insert item', () async { - final person = Person(1, 'Simon'); + group('insertion without stream listening', () { + final underTest = InsertionAdapter( + mockDatabaseExecutor, + entityName, + valueMapper, + ); - await underTest.insert(person, conflictAlgorithm); + group('insertion without return', () { + test('insert item', () async { + final person = Person(1, 'Simon'); - final values = {'id': person.id, 'name': person.name}; - verify(mockDatabaseExecutor.insert( - entityName, - values, - conflictAlgorithm: conflictAlgorithm, - )); + await underTest.insert(person, conflictAlgorithm); + + final values = {'id': person.id, 'name': person.name}; + verify(mockDatabaseExecutor.insert( + entityName, + values, + conflictAlgorithm: conflictAlgorithm, + )); + }); + + test('insert list', () async { + final person1 = Person(1, 'Simon'); + final person2 = Person(2, 'Frank'); + final persons = [person1, person2]; + final primaryKeys = persons.map((person) => person.id).toList(); + when(mockDatabaseExecutor.batch()).thenReturn(mockDatabaseBatch); + when(mockDatabaseBatch.commit(noResult: false)) + .thenAnswer((_) => Future(() => primaryKeys)); + + await underTest.insertList(persons, conflictAlgorithm); + + final values1 = { + 'id': person1.id, + 'name': person1.name + }; + final values2 = { + 'id': person2.id, + 'name': person2.name + }; + verifyInOrder([ + mockDatabaseExecutor.batch(), + mockDatabaseBatch.insert( + entityName, + values1, + conflictAlgorithm: conflictAlgorithm, + ), + mockDatabaseBatch.insert( + entityName, + values2, + conflictAlgorithm: conflictAlgorithm, + ), + mockDatabaseBatch.commit(noResult: false), + ]); + }); + + test('insert empty list', () async { + await underTest.insertList([], conflictAlgorithm); + + verifyZeroInteractions(mockDatabaseExecutor); + }); }); - test('insert list', () async { - final person1 = Person(1, 'Simon'); - final person2 = Person(2, 'Frank'); - final persons = [person1, person2]; - when(mockDatabaseExecutor.batch()).thenReturn(mockDatabaseBatch); + group('insertion with return', () { + test('insert item and return primary key', () async { + final person = Person(1, 'Simon'); + final values = {'id': person.id, 'name': person.name}; + when(mockDatabaseExecutor.insert( + entityName, + values, + conflictAlgorithm: conflictAlgorithm, + )).thenAnswer((_) => Future(() => person.id)); - await underTest.insertList(persons, conflictAlgorithm); + final actual = + await underTest.insertAndReturnId(person, conflictAlgorithm); - final values1 = {'id': person1.id, 'name': person1.name}; - final values2 = {'id': person2.id, 'name': person2.name}; - verifyInOrder([ - mockDatabaseExecutor.batch(), - mockDatabaseBatch.insert( + verify(mockDatabaseExecutor.insert( entityName, - values1, + values, conflictAlgorithm: conflictAlgorithm, - ), - mockDatabaseBatch.insert( + )); + expect(actual, equals(person.id)); + }); + + test('insert item but transaction failed (returns null)', () async { + final person = Person(1, 'Simon'); + final values = {'id': person.id, 'name': person.name}; + when(mockDatabaseExecutor.insert( entityName, - values2, + values, conflictAlgorithm: conflictAlgorithm, - ), - mockDatabaseBatch.commit(noResult: true), - ]); - }); + )).thenAnswer((_) => Future(() => null)); - test('insert empty list', () async { - await underTest.insertList([], conflictAlgorithm); + final actual = + await underTest.insertAndReturnId(person, conflictAlgorithm); - verifyZeroInteractions(mockDatabaseExecutor); + verify(mockDatabaseExecutor.insert( + entityName, + values, + conflictAlgorithm: conflictAlgorithm, + )); + expect(actual, isNull); + }); + + test('insert items and return primary keys', () async { + final person1 = Person(1, 'Simon'); + final person2 = Person(2, 'Frank'); + final persons = [person1, person2]; + final primaryKeys = persons.map((person) => person.id).toList(); + when(mockDatabaseExecutor.batch()).thenReturn(mockDatabaseBatch); + when(mockDatabaseBatch.commit(noResult: false)) + .thenAnswer((_) => Future(() => primaryKeys)); + + final actual = + await underTest.insertListAndReturnIds(persons, conflictAlgorithm); + + final values1 = { + 'id': person1.id, + 'name': person1.name + }; + final values2 = { + 'id': person2.id, + 'name': person2.name + }; + verifyInOrder([ + mockDatabaseExecutor.batch(), + mockDatabaseBatch.insert( + entityName, + values1, + conflictAlgorithm: conflictAlgorithm, + ), + mockDatabaseBatch.insert( + entityName, + values2, + conflictAlgorithm: conflictAlgorithm, + ), + mockDatabaseBatch.commit(noResult: false), + ]); + expect(actual, equals(primaryKeys)); + }); + + test('insert empty list', () async { + final actual = + await underTest.insertListAndReturnIds([], conflictAlgorithm); + + verifyZeroInteractions(mockDatabaseExecutor); + expect(actual, equals([])); + }); }); }); - group('insertion with return', () { - test('insert item and return primary key', () async { + group('insertion while stream is listening', () { + // ignore: close_sinks + final mockStreamController = MockStreamController(); + + final underTest = InsertionAdapter( + mockDatabaseExecutor, + entityName, + valueMapper, + mockStreamController, + ); + + tearDown(() { + clearInteractions(mockStreamController); + reset(mockStreamController); + }); + + test('insert item', () async { final person = Person(1, 'Simon'); - final values = {'id': person.id, 'name': person.name}; when(mockDatabaseExecutor.insert( - entityName, - values, + any, + any, conflictAlgorithm: conflictAlgorithm, )).thenAnswer((_) => Future(() => person.id)); - final actual = - await underTest.insertAndReturnId(person, conflictAlgorithm); + await underTest.insert(person, conflictAlgorithm); + + verify(mockStreamController.add(entityName)); + }); - verify(mockDatabaseExecutor.insert( - entityName, - values, + test('insert item but transaction failed (returns null)', () async { + final person = Person(1, 'Simon'); + when(mockDatabaseExecutor.insert( + any, + any, conflictAlgorithm: conflictAlgorithm, - )); - expect(actual, equals(person.id)); + )).thenAnswer((_) => Future(() => null)); + + await underTest.insert(person, conflictAlgorithm); + + verifyZeroInteractions(mockStreamController); }); - test('insert items and return primary keys', () async { + test('insert list', () async { final person1 = Person(1, 'Simon'); final person2 = Person(2, 'Frank'); final persons = [person1, person2]; @@ -103,34 +224,28 @@ void main() { when(mockDatabaseBatch.commit(noResult: false)) .thenAnswer((_) => Future(() => primaryKeys)); - final actual = - await underTest.insertListAndReturnIds(persons, conflictAlgorithm); + await underTest.insertList(persons, conflictAlgorithm); - final values1 = {'id': person1.id, 'name': person1.name}; - final values2 = {'id': person2.id, 'name': person2.name}; - verifyInOrder([ - mockDatabaseExecutor.batch(), - mockDatabaseBatch.insert( - entityName, - values1, - conflictAlgorithm: conflictAlgorithm, - ), - mockDatabaseBatch.insert( - entityName, - values2, - conflictAlgorithm: conflictAlgorithm, - ), - mockDatabaseBatch.commit(noResult: false), - ]); - expect(actual, equals(primaryKeys)); + verify(mockStreamController.add(entityName)); + }); + + test('do not notify when nothing changed', () async { + final person1 = Person(1, 'Simon'); + final person2 = Person(2, 'Frank'); + final persons = [person1, person2]; + when(mockDatabaseExecutor.batch()).thenReturn(mockDatabaseBatch); + when(mockDatabaseBatch.commit(noResult: false)) + .thenAnswer((_) => Future(() => [])); + + await underTest.insertList(persons, conflictAlgorithm); + + verifyZeroInteractions(mockStreamController); }); test('insert empty list', () async { - final actual = - await underTest.insertListAndReturnIds([], conflictAlgorithm); + await underTest.insertList([], conflictAlgorithm); - verifyZeroInteractions(mockDatabaseExecutor); - expect(actual, equals([])); + verifyZeroInteractions(mockStreamController); }); }); } diff --git a/floor/test/adapter/query_adapter_test.dart b/floor/test/adapter/query_adapter_test.dart index 191e087a..b1e04462 100644 --- a/floor/test/adapter/query_adapter_test.dart +++ b/floor/test/adapter/query_adapter_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:floor/src/adapter/query_adapter.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -11,53 +13,145 @@ void main() { const sql = 'abcd'; final mapper = (Map row) => Person(row['id'], row['name']); - final underTest = QueryAdapter(mockDatabaseExecutor); - tearDown(() { clearInteractions(mockDatabaseExecutor); }); - group('query item', () { - test('returns item', () async { + group('queries (no stream)', () { + final underTest = QueryAdapter(mockDatabaseExecutor); + + group('query item', () { + test('returns item', () async { + final person = Person(1, 'Frank'); + final queryResult = Future(() => [ + {'id': person.id, 'name': person.name} + ]); + when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); + + final actual = await underTest.query(sql, mapper); + + expect(actual, equals(person)); + verify(mockDatabaseExecutor.rawQuery(sql)); + }); + + test('null when query returns nothing', () async { + final queryResult = Future(() => >[]); + when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); + + final actual = await underTest.query(sql, mapper); + + expect(actual, isNull); + verify(mockDatabaseExecutor.rawQuery(sql)); + }); + + test('exception because query returns multiple items', () async { + final person = Person(1, 'Frank'); + final queryResult = Future(() => [ + {'id': person.id, 'name': person.name}, + {'id': 2, 'name': 'Peter'}, + ]); + when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); + + final actual = () => underTest.query(sql, mapper); + + expect(actual, throwsStateError); + verify(mockDatabaseExecutor.rawQuery(sql)); + }); + }); + + group('query list', () { + test('returns items', () async { + final person = Person(1, 'Frank'); + final person2 = Person(2, 'Peter'); + final queryResult = Future(() => [ + {'id': person.id, 'name': person.name}, + {'id': person2.id, 'name': person2.name}, + ]); + when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); + + final actual = await underTest.queryList(sql, mapper); + + expect(actual, equals([person, person2])); + verify(mockDatabaseExecutor.rawQuery(sql)); + }); + + test('returns emtpy list when query returns nothing', () async { + final queryResult = Future(() => >[]); + when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); + + final actual = await underTest.queryList(sql, mapper); + + expect(actual, isEmpty); + verify(mockDatabaseExecutor.rawQuery(sql)); + }); + }); + + group('query no return', () { + test('executes query', () async { + await underTest.queryNoReturn(sql); + + verify(mockDatabaseExecutor.rawQuery(sql)); + }); + }); + }); + + group('stream queries', () { + // ignore: close_sinks + StreamController streamController; + const entityName = 'person'; + + QueryAdapter underTest; + + setUp(() { + streamController = StreamController(); + underTest = QueryAdapter(mockDatabaseExecutor, streamController); + }); + + tearDown(() { + streamController.close(); + streamController = null; + }); + + test('query item and emit persistent item', () { final person = Person(1, 'Frank'); final queryResult = Future(() => [ {'id': person.id, 'name': person.name} ]); when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); - final actual = await underTest.query(sql, mapper); + final actual = underTest.queryStream(sql, entityName, mapper); - expect(actual, equals(person)); - verify(mockDatabaseExecutor.rawQuery(sql)); + expect(actual, emits(person)); }); - test('null when query returns nothing', () async { - final queryResult = Future(() => >[]); + test('query item and emit persistent item and new', () { + final person = Person(1, 'Frank'); + final queryResult = Future(() => [ + {'id': person.id, 'name': person.name} + ]); when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); - final actual = await underTest.query(sql, mapper); + final actual = underTest.queryStream(sql, entityName, mapper); + streamController.add(entityName); - expect(actual, isNull); - verify(mockDatabaseExecutor.rawQuery(sql)); + expect(actual, emitsInOrder([person, person])); }); - test('exception because query returns multiple items', () async { + test('query items and emit persistent items', () async { final person = Person(1, 'Frank'); + final person2 = Person(2, 'Peter'); final queryResult = Future(() => [ {'id': person.id, 'name': person.name}, - {'id': 2, 'name': 'Peter'}, + {'id': person2.id, 'name': person2.name}, ]); when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); - final actual = () => underTest.query(sql, mapper); + final actual = underTest.queryListStream(sql, entityName, mapper); - expect(actual, throwsStateError); - verify(mockDatabaseExecutor.rawQuery(sql)); + expect(actual, emits([person, person2])); }); - }); - group('query list', () { - test('returns items', () async { + test('query items and emit persistent items and new items', () async { final person = Person(1, 'Frank'); final person2 = Person(2, 'Peter'); final queryResult = Future(() => [ @@ -66,28 +160,16 @@ void main() { ]); when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); - final actual = await underTest.queryList(sql, mapper); - - expect(actual, equals([person, person2])); - verify(mockDatabaseExecutor.rawQuery(sql)); - }); - - test('returns emtpy list when query returns nothing', () async { - final queryResult = Future(() => >[]); - when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); - - final actual = await underTest.queryList(sql, mapper); - - expect(actual, isEmpty); - verify(mockDatabaseExecutor.rawQuery(sql)); - }); - }); - - group('query no return', () { - test('executes query', () async { - await underTest.queryNoReturn(sql); + final actual = underTest.queryListStream(sql, entityName, mapper); + streamController.add(entityName); - verify(mockDatabaseExecutor.rawQuery(sql)); + expect( + actual, + emitsInOrder(>[ + [person, person2], + [person, person2] + ]), + ); }); }); } diff --git a/floor/test/adapter/update_adapter_test.dart b/floor/test/adapter/update_adapter_test.dart index 61e890de..76b34fbe 100644 --- a/floor/test/adapter/update_adapter_test.dart +++ b/floor/test/adapter/update_adapter_test.dart @@ -25,6 +25,9 @@ void main() { tearDown(() { clearInteractions(mockDatabaseExecutor); + clearInteractions(mockDatabaseBatch); + reset(mockDatabaseExecutor); + reset(mockDatabaseBatch); }); group('update without return', () { @@ -48,6 +51,8 @@ void main() { final person2 = Person(2, 'Frank'); final persons = [person1, person2]; when(mockDatabaseExecutor.batch()).thenReturn(mockDatabaseBatch); + when(mockDatabaseBatch.commit(noResult: false)) + .thenAnswer((_) => Future(() => [1, 1])); await underTest.updateList(persons, conflictAlgorithm); @@ -69,7 +74,7 @@ void main() { whereArgs: [person2.id], conflictAlgorithm: conflictAlgorithm, ), - mockDatabaseBatch.commit(noResult: true), + mockDatabaseBatch.commit(noResult: false), ]); }); diff --git a/floor/test/util/mocks.dart b/floor/test/util/mocks.dart index 63fdb75b..042f0bc1 100644 --- a/floor/test/util/mocks.dart +++ b/floor/test/util/mocks.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:mockito/mockito.dart'; import 'package:sqflite/sqflite.dart'; @@ -6,3 +8,5 @@ class MockDatabaseExecutor extends Mock implements DatabaseExecutor {} class MockDatabaseBatch extends Mock implements Batch {} class MockSqfliteDatabase extends Mock implements Database {} + +class MockStreamController extends Mock implements StreamController {} diff --git a/floor_annotation/lib/src/primary_key.dart b/floor_annotation/lib/src/primary_key.dart index 7bc81b0c..80f8edf7 100644 --- a/floor_annotation/lib/src/primary_key.dart +++ b/floor_annotation/lib/src/primary_key.dart @@ -6,3 +6,6 @@ class PrimaryKey { /// Defaults [autoGenerate] to false. const PrimaryKey({this.autoGenerate = false}); } + +/// Marks a field in an [Entity] as the primary key. +const primaryKey = PrimaryKey(); diff --git a/floor_generator/lib/misc/type_utils.dart b/floor_generator/lib/misc/type_utils.dart index 3b35d450..f82a8e20 100644 --- a/floor_generator/lib/misc/type_utils.dart +++ b/floor_generator/lib/misc/type_utils.dart @@ -32,6 +32,10 @@ bool isSupportedType(final DartType type) { _isDartCore(type); } +bool isStream(final DartType type) { + return type.name == 'Stream'; +} + bool isEntityAnnotation(final ElementAnnotation annotation) { return _getAnnotationName(annotation) == Annotation.ENTITY; } @@ -72,6 +76,10 @@ DartType flattenList(final DartType type) { return (type as ParameterizedType).typeArguments.first; } +DartType flattenStream(final DartType type) { + return (type as ParameterizedType).typeArguments.first; +} + bool _isDartCore(final DartType type) { return type.element.library.isDartCore; } diff --git a/floor_generator/lib/model/database.dart b/floor_generator/lib/model/database.dart index 13b72ee9..f25abfae 100644 --- a/floor_generator/lib/model/database.dart +++ b/floor_generator/lib/model/database.dart @@ -33,8 +33,10 @@ class Database { List get methods => clazz.methods; + List _queryMethodsCache; + List get queryMethods { - return methods + return _queryMethodsCache ??= methods .where((method) => method.metadata.any(isQueryAnnotation)) .map((method) => QueryMethod(method)) .toList(); @@ -75,4 +77,13 @@ class Database { .map((entity) => Entity(entity)) .toList(); } + + List _streamEntities; + + List getStreamEntities(final LibraryReader library) { + return _streamEntities ??= queryMethods + .where((method) => method.returnsStream) + .map((method) => method.getEntity(library)) + .toList(); + } } diff --git a/floor_generator/lib/model/entity.dart b/floor_generator/lib/model/entity.dart index 5c3447b5..b3912358 100644 --- a/floor_generator/lib/model/entity.dart +++ b/floor_generator/lib/model/entity.dart @@ -63,9 +63,7 @@ class Entity { String _createTableStatementsCache; String getCreateTableStatement(final LibraryReader library) { - if (_createTableStatementsCache != null) { - return _createTableStatementsCache; - } + if (_createTableStatementsCache != null) return _createTableStatementsCache; final databaseDefinition = columns.map((column) => column.definition).toList(); @@ -83,9 +81,7 @@ class Entity { String _constructorCache; String getConstructor(final LibraryReader library) { - if (_constructorCache != null) { - return _constructorCache; - } + if (_constructorCache != null) return _constructorCache; final columnNames = columns.map((column) => column.name).toList(); final constructorParameters = clazz.constructors.first.parameters; @@ -126,9 +122,7 @@ class Entity { String _valueMappingCache; String getValueMapping(final LibraryReader library) { - if (_valueMappingCache != null) { - return _valueMappingCache; - } + if (_valueMappingCache != null) return _valueMappingCache; final columnNames = columns.map((column) => column.name).toList(); final constructorParameters = clazz.constructors.first.parameters; diff --git a/floor_generator/lib/model/query_method.dart b/floor_generator/lib/model/query_method.dart index 8a0ba2eb..0c6b4fe6 100644 --- a/floor_generator/lib/model/query_method.dart +++ b/floor_generator/lib/model/query_method.dart @@ -49,8 +49,13 @@ class QueryMethod { /// E.g. /// Future -> T, /// Future> -> T + /// + /// Stream -> T + /// Stream> -> T DartType get flattenedReturnType { - final type = method.returnType.flattenFutures(method.context.typeSystem); + final type = returnsStream + ? flattenStream(method.returnType) + : method.returnType.flattenFutures(method.context.typeSystem); if (returnsList) { return flattenList(type); } @@ -60,24 +65,33 @@ class QueryMethod { List get parameters => method.parameters; bool get returnsList { - final type = method.returnType.flattenFutures(method.context.typeSystem); + final type = returnsStream + ? flattenStream(method.returnType) + : method.returnType.flattenFutures(method.context.typeSystem); + return isList(type); } bool get returnsVoid { - return method.returnType.flattenFutures(method.context.typeSystem).isVoid; + final type = returnsStream + ? flattenStream(method.returnType) + : method.returnType.flattenFutures(method.context.typeSystem); + + return type.isVoid; } + bool get returnsStream => isStream(method.returnType); + + Entity _entityCache; + Entity getEntity(final LibraryReader library) { + if (_entityCache != null) return _entityCache; + final entity = _getEntities(library).firstWhere( (entity) => entity.displayName == flattenedReturnType.displayName, orElse: () => null); // doesn't return an entity - if (entity != null) { - return Entity(entity); - } else { - return null; - } + return _entityCache ??= entity != null ? Entity(entity) : null; } bool returnsEntity(final LibraryReader library) { diff --git a/floor_generator/lib/writer/adapter/deletion_adapters_writer.dart b/floor_generator/lib/writer/adapter/deletion_adapters_writer.dart index 7d7d1cfb..b988117e 100644 --- a/floor_generator/lib/writer/adapter/deletion_adapters_writer.dart +++ b/floor_generator/lib/writer/adapter/deletion_adapters_writer.dart @@ -1,13 +1,20 @@ import 'package:code_builder/code_builder.dart'; import 'package:floor_generator/model/delete_method.dart'; +import 'package:floor_generator/model/entity.dart'; import 'package:source_gen/source_gen.dart'; class DeletionAdaptersWriter { final LibraryReader library; final ClassBuilder builder; final List deleteMethods; + final List streamEntities; - DeletionAdaptersWriter(this.library, this.builder, this.deleteMethods); + DeletionAdaptersWriter( + this.library, + this.builder, + this.deleteMethods, + this.streamEntities, + ); void write() { final deleteEntities = deleteMethods @@ -30,12 +37,15 @@ class DeletionAdaptersWriter { final valueMapper = '(${entity.clazz.displayName} item) => ${entity.getValueMapping(library)}'; + final requiresChangeListener = + streamEntities.any((streamEntity) => streamEntity == entity); + final getAdapter = Method((builder) => builder ..type = MethodType.getter ..name = '_${entityName}DeletionAdapter' ..returns = type ..body = Code(''' - return $cacheName ??= DeletionAdapter(database, '$entityName', '${entity.primaryKeyColumn.name}', $valueMapper); + return $cacheName ??= DeletionAdapter(database, '$entityName', '${entity.primaryKeyColumn.name}', $valueMapper${requiresChangeListener ? ', changeListener' : ''}); ''')); builder..methods.add(getAdapter); diff --git a/floor_generator/lib/writer/adapter/insertion_adapters_writer.dart b/floor_generator/lib/writer/adapter/insertion_adapters_writer.dart index fee7bc30..7e2caaa0 100644 --- a/floor_generator/lib/writer/adapter/insertion_adapters_writer.dart +++ b/floor_generator/lib/writer/adapter/insertion_adapters_writer.dart @@ -1,4 +1,5 @@ import 'package:code_builder/code_builder.dart'; +import 'package:floor_generator/model/entity.dart'; import 'package:floor_generator/model/insert_method.dart'; import 'package:source_gen/source_gen.dart'; @@ -6,8 +7,14 @@ class InsertionAdaptersWriter { final LibraryReader library; final ClassBuilder builder; final List insertMethods; + final List streamEntities; - InsertionAdaptersWriter(this.library, this.builder, this.insertMethods); + InsertionAdaptersWriter( + this.library, + this.builder, + this.insertMethods, + this.streamEntities, + ); void write() { final insertEntities = insertMethods @@ -30,12 +37,15 @@ class InsertionAdaptersWriter { final valueMapper = '(${entity.clazz.displayName} item) => ${entity.getValueMapping(library)}'; + final requiresChangeListener = + streamEntities.any((streamEntity) => streamEntity == entity); + final getAdapter = Method((builder) => builder ..type = MethodType.getter ..name = '_${entityName}InsertionAdapter' ..returns = type ..body = Code(''' - return $cacheName ??= InsertionAdapter(database, '$entityName', $valueMapper); + return $cacheName ??= InsertionAdapter(database, '$entityName', $valueMapper${requiresChangeListener ? ', changeListener' : ''}); ''')); builder..methods.add(getAdapter); diff --git a/floor_generator/lib/writer/adapter/query_adapter_writer.dart b/floor_generator/lib/writer/adapter/query_adapter_writer.dart index d57e507a..27029777 100644 --- a/floor_generator/lib/writer/adapter/query_adapter_writer.dart +++ b/floor_generator/lib/writer/adapter/query_adapter_writer.dart @@ -6,8 +6,14 @@ class QueryAdapterWriter { final LibraryReader library; final ClassBuilder builder; final List queryMethods; + final bool requiresChangeListener; - QueryAdapterWriter(this.library, this.builder, this.queryMethods); + QueryAdapterWriter( + this.library, + this.builder, + this.queryMethods, + this.requiresChangeListener, + ); void write() { final queryMappers = queryMethods @@ -35,7 +41,8 @@ class QueryAdapterWriter { ..returns = refer('QueryAdapter') ..type = MethodType.getter ..lambda = true - ..body = const Code('$cacheName ??= QueryAdapter(database)')); + ..body = Code( + "$cacheName ??= QueryAdapter(database${requiresChangeListener ? ', changeListener' : ''})")); builder..fields.addAll(queryMappers); builder..fields.add(queryAdapterSingleton); diff --git a/floor_generator/lib/writer/adapter/update_adapters_writer.dart b/floor_generator/lib/writer/adapter/update_adapters_writer.dart index 90e4c91c..8eb0bcd6 100644 --- a/floor_generator/lib/writer/adapter/update_adapters_writer.dart +++ b/floor_generator/lib/writer/adapter/update_adapters_writer.dart @@ -1,4 +1,5 @@ import 'package:code_builder/code_builder.dart'; +import 'package:floor_generator/model/entity.dart'; import 'package:floor_generator/model/update_method.dart'; import 'package:source_gen/source_gen.dart'; @@ -6,8 +7,14 @@ class UpdateAdaptersWriter { final LibraryReader library; final ClassBuilder builder; final List updateMethods; + final List streamEntities; - UpdateAdaptersWriter(this.library, this.builder, this.updateMethods); + UpdateAdaptersWriter( + this.library, + this.builder, + this.updateMethods, + this.streamEntities, + ); void write() { final updateEntities = updateMethods @@ -30,12 +37,15 @@ class UpdateAdaptersWriter { final valueMapper = '(${entity.clazz.displayName} item) => ${entity.getValueMapping(library)}'; + final requiresChangeListener = + streamEntities.any((streamEntity) => streamEntity == entity); + final getAdapter = Method((builder) => builder ..type = MethodType.getter ..name = '_${entityName}UpdateAdapter' ..returns = type ..body = Code(''' - return $cacheName ??= UpdateAdapter(database, '$entityName', '${entity.primaryKeyColumn.name}', $valueMapper); + return $cacheName ??= UpdateAdapter(database, '$entityName', '${entity.primaryKeyColumn.name}', $valueMapper${requiresChangeListener ? ', changeListener' : ''}); ''')); builder..methods.add(getAdapter); diff --git a/floor_generator/lib/writer/database_writer.dart b/floor_generator/lib/writer/database_writer.dart index f50d3df3..c5bd03f1 100644 --- a/floor_generator/lib/writer/database_writer.dart +++ b/floor_generator/lib/writer/database_writer.dart @@ -95,24 +95,34 @@ class DatabaseWriter implements Writer { ..name = '_\$$databaseName' ..extend = refer(databaseName); + final streamEntities = database.getStreamEntities(library); + final queryMethods = database.queryMethods; if (queryMethods.isNotEmpty) { - QueryAdapterWriter(library, builder, queryMethods).write(); + QueryAdapterWriter( + library, + builder, + queryMethods, + streamEntities.isNotEmpty, + ).write(); } final insertMethods = database.insertMethods; if (insertMethods.isNotEmpty) { - InsertionAdaptersWriter(library, builder, insertMethods).write(); + InsertionAdaptersWriter(library, builder, insertMethods, streamEntities) + .write(); } final updateMethods = database.updateMethods; if (updateMethods.isNotEmpty) { - UpdateAdaptersWriter(library, builder, updateMethods).write(); + UpdateAdaptersWriter(library, builder, updateMethods, streamEntities) + .write(); } final deleteMethods = database.deleteMethods; if (deleteMethods.isNotEmpty) { - DeletionAdaptersWriter(library, builder, deleteMethods).write(); + DeletionAdaptersWriter(library, builder, deleteMethods, streamEntities) + .write(); } builder diff --git a/floor_generator/lib/writer/query_method_writer.dart b/floor_generator/lib/writer/query_method_writer.dart index 99514c5e..d2c330f8 100644 --- a/floor_generator/lib/writer/query_method_writer.dart +++ b/floor_generator/lib/writer/query_method_writer.dart @@ -17,7 +17,7 @@ class QueryMethodWriter implements Writer { } Method _generateQueryMethod() { - _assertReturnsFuture(); + _assertReturnsFutureOrStream(); _assertQueryParameters(); final builder = MethodBuilder() @@ -27,7 +27,7 @@ class QueryMethodWriter implements Writer { ..requiredParameters.addAll(_generateMethodParameters()) ..body = Code(_generateMethodBody()); - if (queryMethod.returnsVoid) { + if (!queryMethod.returnsStream || queryMethod.returnsVoid) { builder..modifier = MethodModifier.async; } @@ -57,14 +57,28 @@ class QueryMethodWriter implements Writer { _assertReturnsEntity(); final mapper = '_${queryMethod.getEntity(library).name}Mapper'; + if (queryMethod.returnsStream) { + return _generateStreamQuery(mapper); + } else { + return _generateQuery(mapper); + } + } + + String _generateQuery(final String mapper) { + if (queryMethod.returnsList) { + return "return _queryAdapter.queryList('${queryMethod.query}', $mapper);"; + } else { + return "return _queryAdapter.query('${queryMethod.query}', $mapper);"; + } + } + + String _generateStreamQuery(final String mapper) { + final entityName = queryMethod.getEntity(library).name; + if (queryMethod.returnsList) { - return ''' - return _queryAdapter.queryList('${queryMethod.query}', $mapper); - '''; + return "return _queryAdapter.queryListStream('${queryMethod.query}', '$entityName', $mapper);"; } else { - return ''' - return _queryAdapter.query('${queryMethod.query}', $mapper); - '''; + return "return _queryAdapter.queryStream('${queryMethod.query}', '$entityName', $mapper);"; } } @@ -88,10 +102,11 @@ class QueryMethodWriter implements Writer { } } - void _assertReturnsFuture() { - if (!queryMethod.rawReturnType.isDartAsyncFuture) { + void _assertReturnsFutureOrStream() { + if (!queryMethod.rawReturnType.isDartAsyncFuture && + !queryMethod.returnsStream) { throw InvalidGenerationSourceError( - 'All queries have to return a Future.', + 'All queries have to return a Future or Stream.', element: queryMethod.method, ); } diff --git a/floor_test/test/database.dart b/floor_test/test/database.dart index 6063aa88..f42342a7 100644 --- a/floor_test/test/database.dart +++ b/floor_test/test/database.dart @@ -18,9 +18,15 @@ abstract class TestDatabase extends FloorDatabase { @Query('SELECT * FROM person') Future> findAllPersons(); + @Query('SELECT * FROM person') + Stream> findAllPersonsAsStream(); + @Query('SELECT * FROM person WHERE id = :id') Future findPersonById(int id); + @Query('SELECT * FROM person WHERE id = :id') + Stream findPersonByIdAsStream(int id); + @Query('SELECT * FROM person WHERE id = :id AND custom_name = :name') Future findPersonByIdAndName(int id, String name); diff --git a/floor_test/test/database_test.dart b/floor_test/test/database_test.dart index b703e741..4c776acf 100644 --- a/floor_test/test/database_test.dart +++ b/floor_test/test/database_test.dart @@ -217,6 +217,113 @@ void main() { expect(actual, isEmpty); }); }); + + group('stream queries', () { + test('initially emit persistent data', () async { + final person = Person(1, 'Simon'); + await database.insertPerson(person); + + final actual = database.findAllPersonsAsStream(); + + expect(actual, emits([person])); + }); + + group('insert change', () { + test('find person by id as stream', () async { + final person = Person(1, 'Simon'); + + final actual = database.findPersonByIdAsStream(person.id); + + await database.insertPerson(person); + expect(actual, emits(person)); + }); + + test('find all persons as stream', () async { + final persons = [Person(1, 'Simon'), Person(2, 'Frank')]; + + final actual = database.findAllPersonsAsStream(); + + await database.insertPersons(persons); + expect( + actual, + emitsInOrder(>[[], persons]), + ); + }); + + test('initially emits persistent data then new', () async { + final persons = [Person(1, 'Simon'), Person(2, 'Frank')]; + final persons2 = [Person(3, 'Paul'), Person(4, 'George')]; + await database.insertPersons(persons); + + final actual = database.findAllPersonsAsStream(); + + await database.insertPersons(persons2); + expect( + actual, + emitsInOrder(>[persons, persons + persons2]), + ); + }); + }); + + group('update change', () { + test('update item', () async { + final person = Person(1, 'Simon'); + await database.insertPerson(person); + + final actual = database.findAllPersonsAsStream(); + + final updatedPerson = Person(person.id, 'Frank'); + await database.updatePerson(updatedPerson); + expect( + actual, + emitsInOrder(>[ + [person], + [updatedPerson] + ]), + ); + }); + + test('update items', () async { + final persons = [Person(1, 'Simon'), Person(2, 'Frank')]; + final updatedPersons = + persons.map((person) => Person(person.id, 'Nick')).toList(); + await database.insertPersons(persons); + + final actual = database.findAllPersonsAsStream(); + + await database.updatePersons(updatedPersons); + expect(actual, emitsInOrder(>[persons, updatedPersons])); + }); + }); + + group('deletion change', () { + test('delete item', () async { + final person = Person(1, 'Simon'); + await database.insertPerson(person); + + final actual = database.findAllPersonsAsStream(); + + await database.deletePerson(person); + expect( + actual, + emitsInOrder(>[ + [person], + [] + ]), + ); + }); + + test('delete items', () async { + final persons = [Person(1, 'Simon'), Person(2, 'Frank')]; + await database.insertPersons(persons); + + final actual = database.findAllPersonsAsStream(); + + await database.deletePersons(persons); + expect(actual, emitsInOrder(>[persons, []])); + }); + }); + }); }); } diff --git a/floor_test/test/model/person.dart b/floor_test/test/model/person.dart index 1b53646f..0321ebdc 100644 --- a/floor_test/test/model/person.dart +++ b/floor_test/test/model/person.dart @@ -2,7 +2,7 @@ part of '../database.dart'; @Entity(tableName: 'person') class Person { - @PrimaryKey() + @primaryKey final int id; @ColumnInfo(name: 'custom_name', nullable: false)