diff --git a/README.md b/README.md index 9fe605db..cc8764a5 100644 --- a/README.md +++ b/README.md @@ -396,11 +396,6 @@ You can then query the view via a DAO function like an entity. It is possible for DatabaseViews to inherit common fields from a base class, just like in entities. -#### Limitations -- It is now possible to return a `Stream` object from a DAO method which queries a database view. But it will fire on **any** - `@update`, `@insert`, `@delete` events in the whole database, which can get quite taxing on the runtime. Please add it only if you know what you are doing! - This is mostly due to the complexity of detecting which entities are involved in a database view. - ## Data Access Objects These components are responsible for managing access to the underlying SQLite database and are defined as abstract classes with method signatures and query statements. DAO classes can use inherited methods by implementing and extending classes while also using mixins. @@ -421,9 +416,8 @@ abstract class PersonDao { ### Queries 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` or a `Stream` of an entity or `void`. +Your SQL queries will be validated completely while generating the code. +These queries have to return either a `Future` or a `Stream` of an entity, a view, a primitive value or `void`. Returning `Future` comes in handy whenever you want to delete the full content of a table, for instance. Some query method examples can be seen in the following. @@ -460,6 +454,13 @@ final name = '%foo%'; await dao.findPersonsWithNamesLike(name); ``` +#### Limitations +- Supplying more than one query (separated by a semicolon) will not work. + Please use transactions or successive calls to DAO-Methods instead. +- The underlying frameworks only support up to 999 different parameters. This + seems like a big number, but each element of a list parameter counts towards it + and the length of these lists could change at runtime. + ### Data Changes Use the `@insert`, `@update` and `@delete` annotations for inserting and changing persistent data. All these methods accept single or multiple entity instances. @@ -520,11 +521,6 @@ StreamBuilder>( ``` #### Limitations -- Only methods annotated with `@insert`, `@update` and `@delete` trigger `Stream` emissions. - Inserting data by using the `@Query()` annotation doesn't. -- It is now possible to return a `Stream` if the function queries a database view. But it will fire on **any** - `@update`, `@insert`, `@delete` events in the whole database, which can get quite taxing on the runtime. Please add it only if you know what you are doing! - This is mostly due to the complexity of detecting which entities are involved in a database view. - Functions returning a stream of single items such as `Stream` do not emit when there is no query result. ### Transactions diff --git a/example/lib/database.g.dart b/example/lib/database.g.dart index 0ed1f257..bfcb0ebe 100644 --- a/example/lib/database.g.dart +++ b/example/lib/database.g.dart @@ -135,8 +135,8 @@ class _$TaskDao extends TaskDao { @override Future findTaskById(int id) async { - return _queryAdapter.query('SELECT * FROM task WHERE id = ?', - arguments: [id], mapper: _taskMapper); + return _queryAdapter.query('SELECT * FROM task WHERE id = ?1', + mapper: _taskMapper, arguments: [id]); } @override @@ -147,7 +147,7 @@ class _$TaskDao extends TaskDao { @override Stream> findAllTasksAsStream() { return _queryAdapter.queryListStream('SELECT * FROM task', - queryableName: 'Task', isView: false, mapper: _taskMapper); + mapper: _taskMapper, dependencies: {'Task'}); } @override diff --git a/floor/README.md b/floor/README.md index 9fe605db..cc8764a5 100644 --- a/floor/README.md +++ b/floor/README.md @@ -396,11 +396,6 @@ You can then query the view via a DAO function like an entity. It is possible for DatabaseViews to inherit common fields from a base class, just like in entities. -#### Limitations -- It is now possible to return a `Stream` object from a DAO method which queries a database view. But it will fire on **any** - `@update`, `@insert`, `@delete` events in the whole database, which can get quite taxing on the runtime. Please add it only if you know what you are doing! - This is mostly due to the complexity of detecting which entities are involved in a database view. - ## Data Access Objects These components are responsible for managing access to the underlying SQLite database and are defined as abstract classes with method signatures and query statements. DAO classes can use inherited methods by implementing and extending classes while also using mixins. @@ -421,9 +416,8 @@ abstract class PersonDao { ### Queries 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` or a `Stream` of an entity or `void`. +Your SQL queries will be validated completely while generating the code. +These queries have to return either a `Future` or a `Stream` of an entity, a view, a primitive value or `void`. Returning `Future` comes in handy whenever you want to delete the full content of a table, for instance. Some query method examples can be seen in the following. @@ -460,6 +454,13 @@ final name = '%foo%'; await dao.findPersonsWithNamesLike(name); ``` +#### Limitations +- Supplying more than one query (separated by a semicolon) will not work. + Please use transactions or successive calls to DAO-Methods instead. +- The underlying frameworks only support up to 999 different parameters. This + seems like a big number, but each element of a list parameter counts towards it + and the length of these lists could change at runtime. + ### Data Changes Use the `@insert`, `@update` and `@delete` annotations for inserting and changing persistent data. All these methods accept single or multiple entity instances. @@ -520,11 +521,6 @@ StreamBuilder>( ``` #### Limitations -- Only methods annotated with `@insert`, `@update` and `@delete` trigger `Stream` emissions. - Inserting data by using the `@Query()` annotation doesn't. -- It is now possible to return a `Stream` if the function queries a database view. But it will fire on **any** - `@update`, `@insert`, `@delete` events in the whole database, which can get quite taxing on the runtime. Please add it only if you know what you are doing! - This is mostly due to the complexity of detecting which entities are involved in a database view. - Functions returning a stream of single items such as `Stream` do not emit when there is no query result. ### Transactions diff --git a/floor/lib/src/adapter/query_adapter.dart b/floor/lib/src/adapter/query_adapter.dart index bf38ecfa..042cedd4 100644 --- a/floor/lib/src/adapter/query_adapter.dart +++ b/floor/lib/src/adapter/query_adapter.dart @@ -39,25 +39,32 @@ class QueryAdapter { @required final T Function(Map) mapper, }) async { final rows = await _database.rawQuery(sql, arguments); - return rows.map((row) => mapper(row)).toList(); + + return rows.map(mapper).toList(); } + /// Executes a SQLite query that does not return any values. + /// It will also trigger the [_changeListener] of affected entities if this + /// query is expected to change something. Future queryNoReturn( final String sql, { final List arguments, + final Set changedEntities, }) async { - // TODO #94 differentiate between different query kinds (select, update, delete, insert) - // this enables to notify the observers - // also requires extracting the table name :( await _database.rawQuery(sql, arguments); + + if (_changeListener != null && + changedEntities != null && + changedEntities.isNotEmpty) { + changedEntities.forEach(_changeListener.add); + } } /// Executes a SQLite query that returns a stream of single query results. Stream queryStream( final String sql, { final List arguments, - @required final String queryableName, - @required final bool isView, + @required final Set dependencies, @required final T Function(Map) mapper, }) { assert(_changeListener != null); @@ -71,14 +78,12 @@ class QueryAdapter { controller.onListen = () async => executeQueryAndNotifyController(); - // listen on all updates if the stream is on a view, only listen to the - // name of the table if the stream is on a entity. + // listen on all updates where the updated table + // is one of the dependencies of this query. final subscription = _changeListener.stream - .where((updatedTable) => updatedTable == queryableName || isView) - .listen( - (_) async => executeQueryAndNotifyController(), - onDone: () => controller.close(), - ); + .where(dependencies.contains) + .listen((_) async => executeQueryAndNotifyController(), + onDone: () => controller.close()); controller.onCancel = () => subscription.cancel(); @@ -89,8 +94,7 @@ class QueryAdapter { Stream> queryListStream( final String sql, { final List arguments, - @required final String queryableName, - @required final bool isView, + @required final Set dependencies, @required final T Function(Map) mapper, }) { assert(_changeListener != null); @@ -104,13 +108,12 @@ class QueryAdapter { controller.onListen = () async => executeQueryAndNotifyController(); - // Views listen on all events, Entities only on events that changed the same entity. + // listen on all updates where the updated table + // is one of the dependencies of this query. final subscription = _changeListener.stream - .where((updatedTable) => isView || updatedTable == queryableName) - .listen( - (_) async => executeQueryAndNotifyController(), - onDone: () => controller.close(), - ); + .where(dependencies.contains) + .listen((_) async => executeQueryAndNotifyController(), + onDone: () => controller.close()); controller.onCancel = () => subscription.cancel(); diff --git a/floor/test/adapter/query_adapter_test.dart b/floor/test/adapter/query_adapter_test.dart index fc211496..9de12f02 100644 --- a/floor/test/adapter/query_adapter_test.dart +++ b/floor/test/adapter/query_adapter_test.dart @@ -134,6 +134,33 @@ void main() { verify(mockDatabaseExecutor.rawQuery(sql, arguments)); }); + + test('executes query with update', () async { + final streamController = StreamController(); + const entityName = 'person'; + + final underTest = QueryAdapter(mockDatabaseExecutor, streamController); + + final arguments = [123]; + await underTest.queryNoReturn(sql, + arguments: arguments, changedEntities: {entityName}); + + expect(streamController.stream, emits(entityName)); + verify(mockDatabaseExecutor.rawQuery(sql, arguments)); + + await streamController.close(); + }); + + test('executes query with update with no _changeListener present', + () async { + const entityName = 'person'; + + final arguments = [123]; + await underTest.queryNoReturn(sql, + arguments: arguments, changedEntities: {entityName}); + + verify(mockDatabaseExecutor.rawQuery(sql, arguments)); + }); }); }); @@ -162,7 +189,7 @@ void main() { when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); final actual = underTest.queryStream(sql, - queryableName: entityName, isView: false, mapper: mapper); + dependencies: {entityName}, mapper: mapper); expect(actual, emits(person)); }); @@ -179,8 +206,7 @@ void main() { final actual = underTest.queryStream( sql, arguments: arguments, - queryableName: entityName, - isView: false, + dependencies: {entityName}, mapper: mapper, ); @@ -195,7 +221,7 @@ void main() { when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); final actual = underTest.queryStream(sql, - queryableName: entityName, isView: false, mapper: mapper); + dependencies: {entityName}, mapper: mapper); streamController.add(entityName); expect(actual, emitsInOrder([person, person])); @@ -211,7 +237,7 @@ void main() { when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); final actual = underTest.queryListStream(sql, - queryableName: entityName, isView: false, mapper: mapper); + dependencies: {entityName}, mapper: mapper); expect(actual, emits([person, person2])); }); @@ -230,8 +256,7 @@ void main() { final actual = underTest.queryListStream( sql, arguments: arguments, - queryableName: entityName, - isView: false, + dependencies: {entityName}, mapper: mapper, ); @@ -248,7 +273,7 @@ void main() { when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); final actual = underTest.queryListStream(sql, - queryableName: entityName, isView: false, mapper: mapper); + dependencies: {entityName}, mapper: mapper); streamController.add(entityName); expect( @@ -262,6 +287,7 @@ void main() { test('query stream from view with same and different triggering entity', () async { + const otherEntity = 'otherEntity'; final person = Person(1, 'Frank'); final person2 = Person(2, 'Peter'); final queryResult = Future(() => [ @@ -271,7 +297,7 @@ void main() { when(mockDatabaseExecutor.rawQuery(sql)).thenAnswer((_) => queryResult); final actual = underTest.queryListStream(sql, - queryableName: entityName, isView: true, mapper: mapper); + dependencies: {entityName, otherEntity}, mapper: mapper); expect( actual, emitsInOrder(>[ @@ -282,7 +308,36 @@ void main() { ); streamController.add(entityName); - streamController.add('otherEntity'); + streamController.add(otherEntity); + }); + + test('unrelated update should not update stream', () async { + const otherEntity = 'otherEntity'; + 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 = underTest.queryListStream(sql, + dependencies: {entityName}, mapper: mapper); + //first, emit the result + expect( + actual, + emitsInOrder(>[ + [person, person2], + ])); + + //after emitting the first result, no other updates were made + expect(actual.skip(1), emitsDone); + + streamController.add(otherEntity); + streamController.add(otherEntity); + + await Future.delayed(const Duration(milliseconds: 100)); + await streamController.close(); }); }); } diff --git a/floor/test/integration/boolean_conversions/bool_test.dart b/floor/test/integration/boolean_conversions/bool_test.dart index 80bd3155..78a556d3 100644 --- a/floor/test/integration/boolean_conversions/bool_test.dart +++ b/floor/test/integration/boolean_conversions/bool_test.dart @@ -64,6 +64,24 @@ void main() { final actual = await boolDao.findWithNullable(false); expect(actual, equals(obj)); }); + + test('return nullables', () async { + await boolDao + .insertBoolC(BooleanClass(false, nullable: null, nonnullable: true)); + await boolDao + .insertBoolC(BooleanClass(true, nullable: false, nonnullable: true)); + + expect(await boolDao.getNullables(), equals([null, false])); + }); + + test('return nonNullables', () async { + await boolDao + .insertBoolC(BooleanClass(false, nullable: null, nonnullable: true)); + await boolDao + .insertBoolC(BooleanClass(true, nullable: false, nonnullable: false)); + + expect(await boolDao.getNonNullables(), equals([true, false])); + }); }); } @@ -108,6 +126,12 @@ abstract class BoolDao { @Query('SELECT * FROM BooleanClass where nonnullable = :val') Future findWithNonNullable(bool val); + @Query('SELECT nonnullable FROM BooleanClass') + Future> getNonNullables(); + + @Query('SELECT nullable FROM BooleanClass') + Future> getNullables(); + @Query('SELECT * FROM BooleanClass where nullable = :val') Future findWithNullable(bool val); diff --git a/floor/test/integration/sqlparser_test/sqlparser_test.dart b/floor/test/integration/sqlparser_test/sqlparser_test.dart new file mode 100644 index 00000000..f8aa5d5c --- /dev/null +++ b/floor/test/integration/sqlparser_test/sqlparser_test.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:floor/floor.dart'; +import 'package:floor_annotation/floor_annotation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; + +import '../model/person.dart'; + +part 'sqlparser_test.g.dart'; + +void main() { + TestDatabase database; + DeepDao deepDao; + CaseDao caseDao; + + setUp(() async { + database = await $FloorTestDatabase.inMemoryDatabaseBuilder().build(); + deepDao = database.deepDao; + caseDao = database.caseDao; + }); + + tearDown(() async { + await database.close(); + }); + + test('Stream works with wrong case', () async { + final person1 = Person(1, 'Baba'); + final person2 = Person(2, 'Me'); + + final actual = caseDao.allPersons(); + expect( + actual, + emitsInOrder(>[ + [], + [], + [person1], + [person1], + [person1, person2], + [person1, person2], + ])); + // delay execution to make sure that the stream is updated in between + // (and not multiple times afterwards) + await Future.delayed(const Duration(milliseconds: 100)); + await caseDao.updateName(); + + await Future.delayed(const Duration(milliseconds: 100)); + await caseDao.insertPerson(person1); + + await Future.delayed(const Duration(milliseconds: 100)); + await caseDao.updateName(); + + await Future.delayed(const Duration(milliseconds: 100)); + await caseDao.insertPerson(person2); + + await Future.delayed(const Duration(milliseconds: 100)); + await caseDao.updateName(); + }); + + group('Function type derivation tests', () { + test('try IN with null list', () async { + final list = ['a', 'b', 'c', 'd', 'e', 'f', null]; + + final aInList = await deepDao.isXinList('a', list); + expect(aInList, equals(true)); + + final yInList = await deepDao.isXinList('y', list); + expect(yInList, equals(null)); + + final nullInList = await deepDao.isXinList(null, list); + expect(nullInList, equals(null)); + }); + + test('try IN with non-null list', () async { + final list = ['a', 'b', 'c', 'd', 'e', 'f']; + + final aInList = await deepDao.isXinList('a', list); + expect(aInList, equals(true)); + + final yInList = await deepDao.isXinList('y', list); + expect(yInList, equals(false)); + + final nullInList = await deepDao.isXinList(null, list); + expect(nullInList, equals(null)); + }); + + test('try plus-null', () async { + expect(await deepDao.plusString(null), equals(null)); + }); + + test('try plus-22', () async { + expect(await deepDao.plusString('22'), equals(66)); + }); + + test('try plus-8h', () async { + expect(await deepDao.plusString('8h'), equals(24)); + }); + + test('try plus-strnull', () async { + expect(await deepDao.plusString('null'), equals(0)); + }); + + test('try plus-IN', () async { + expect(await deepDao.plusString('IN'), equals(0)); + }); + + test('try plus-abc', () async { + expect(await deepDao.plusString('abc'), equals(0)); + }); + }); +} + +@Database(version: 1, entities: [Person]) +abstract class TestDatabase extends FloorDatabase { + DeepDao get deepDao; + CaseDao get caseDao; +} + +@dao +abstract class DeepDao { + @Query('SELECT :x IN (:list)') + Future isXinList(String x, List list); + + @Query('SELECT :x + :x + :x') + Future plusString(String x); + + @insert + Future insertPerson(Person person); +} + +@dao +abstract class CaseDao { + @Query('SELECT * FROM pErson') + Stream> allPersons(); + + @Query('UPDATE peRson SET custom_name=\'what\' WHERE id=-3') + Future updateName(); + + @insert + Future insertPerson(Person p); +} diff --git a/floor/test/integration/stream_query_test.dart b/floor/test/integration/stream_query_test.dart index 594af82f..cb5a024d 100644 --- a/floor/test/integration/stream_query_test.dart +++ b/floor/test/integration/stream_query_test.dart @@ -137,16 +137,20 @@ void main() { actual, emitsInOrder(>[ [], // initial state, + [], // after inserting person1, + [], // after inserting person2, [dog1], // after inserting dog1 [dog1], // after inserting dog2 - //[], // after removing person1. Does not work because - // ForeignKey-relations are not considered yet (#321) + [], // after removing person1. ])); await personDao.insertPerson(person1); + await Future.delayed(const Duration(milliseconds: 100)); await personDao.insertPerson(person2); + await Future.delayed(const Duration(milliseconds: 100)); await database.dogDao.insertDog(dog1); + await Future.delayed(const Duration(milliseconds: 100)); await database.dogDao.insertDog(dog2); diff --git a/floor_generator/lib/generator.dart b/floor_generator/lib/generator.dart index 60eb8adc..c3e1a534 100644 --- a/floor_generator/lib/generator.dart +++ b/floor_generator/lib/generator.dart @@ -26,9 +26,8 @@ class FloorGenerator extends GeneratorForAnnotation { final daoGetters = database.daoGetters; final databaseClass = DatabaseWriter(database).write(); - final daoClasses = daoGetters.map((daoGetter) => DaoWriter( - daoGetter.dao, database.streamEntities, database.hasViewStreams) - .write()); + final daoClasses = daoGetters.map((daoGetter) => + DaoWriter(daoGetter.dao, database.streamEntities).write()); final library = Library((builder) => builder ..body.add(FloorWriter(database.name).write()) diff --git a/floor_generator/lib/misc/change_method_processor_helper.dart b/floor_generator/lib/misc/change_method_processor_helper.dart index 4b96c12c..d08be264 100644 --- a/floor_generator/lib/misc/change_method_processor_helper.dart +++ b/floor_generator/lib/misc/change_method_processor_helper.dart @@ -50,8 +50,7 @@ class ChangeMethodProcessorHelper { Entity getEntity(@nonNull final DartType flattenedParameterType) { return _entities.firstWhere( (entity) => - entity.classElement.displayName == - flattenedParameterType.getDisplayString(), + entity.className == flattenedParameterType.getDisplayString(), orElse: () => throw InvalidGenerationSourceError( 'You are trying to change an object which is not an entity.', element: _methodElement)); diff --git a/floor_generator/lib/misc/string_utils.dart b/floor_generator/lib/misc/string_utils.dart index 99e07a89..7dc98fac 100644 --- a/floor_generator/lib/misc/string_utils.dart +++ b/floor_generator/lib/misc/string_utils.dart @@ -1,6 +1,27 @@ +import 'package:floor_generator/misc/annotations.dart'; +import 'package:strings/strings.dart'; + extension StringUtils on String { /// Makes the first letter of the supplied string [value] lowercase. + @nonNull String decapitalize() { return '${this[0].toLowerCase()}${substring(1)}'; } + + /// Makes the first letter of the supplied string [value] uppercase. + @nonNull + String capitalize() { + return '${this[0].toUpperCase()}${substring(1)}'; + } + + /// Converts this string to a literal for + /// embedding it into source code strings. + @nonNull + String toLiteral() { + if (this == null) { + return 'null'; + } else { + return "'${escape(this)}'"; + } + } } diff --git a/floor_generator/lib/misc/type_utils.dart b/floor_generator/lib/misc/type_utils.dart index ae7e8f43..20ed3a54 100644 --- a/floor_generator/lib/misc/type_utils.dart +++ b/floor_generator/lib/misc/type_utils.dart @@ -4,7 +4,9 @@ import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/misc/constants.dart'; import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart' show BasicType; extension SupportedTypeChecker on DartType { @nonNull @@ -63,3 +65,10 @@ final _doubleTypeChecker = _typeChecker(double); final _uint8ListTypeChecker = _typeChecker(Uint8List); final _streamTypeChecker = _typeChecker(Stream); + +const sqlToBasicType = { + SqlType.blob: BasicType.blob, + SqlType.integer: BasicType.int, + SqlType.real: BasicType.real, + SqlType.text: BasicType.text, +}; diff --git a/floor_generator/lib/processor/dao_processor.dart b/floor_generator/lib/processor/dao_processor.dart index 68f793f2..e51adcab 100644 --- a/floor_generator/lib/processor/dao_processor.dart +++ b/floor_generator/lib/processor/dao_processor.dart @@ -5,6 +5,7 @@ import 'package:floor_generator/misc/type_utils.dart'; import 'package:floor_generator/processor/deletion_method_processor.dart'; import 'package:floor_generator/processor/insertion_method_processor.dart'; import 'package:floor_generator/processor/processor.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; import 'package:floor_generator/processor/query_method_processor.dart'; import 'package:floor_generator/processor/transaction_method_processor.dart'; import 'package:floor_generator/processor/update_method_processor.dart'; @@ -23,6 +24,7 @@ class DaoProcessor extends Processor { final String _databaseName; final List _entities; final List _views; + final AnalyzerEngine _analyzerEngine; DaoProcessor( final ClassElement classElement, @@ -30,16 +32,19 @@ class DaoProcessor extends Processor { final String databaseName, final List entities, final List views, + final AnalyzerEngine analyzerEngine, ) : assert(classElement != null), assert(daoGetterName != null), assert(databaseName != null), assert(entities != null), assert(views != null), + assert(analyzerEngine != null), _classElement = classElement, _daoGetterName = daoGetterName, _databaseName = databaseName, _entities = entities, - _views = views; + _views = views, + _analyzerEngine = analyzerEngine; @override Dao process() { @@ -49,36 +54,23 @@ class DaoProcessor extends Processor { ..._classElement.allSupertypes.expand((type) => type.methods) ]; - final queryMethods = _getQueryMethods(methods); - final insertionMethods = _getInsertionMethods(methods); - final updateMethods = _getUpdateMethods(methods); - final deletionMethods = _getDeletionMethods(methods); - final transactionMethods = _getTransactionMethods(methods); - - final streamQueryables = queryMethods - .where((method) => method.returnsStream) - .map((method) => method.queryable); - final streamEntities = streamQueryables.whereType().toSet(); - final streamViews = streamQueryables.whereType().toSet(); - return Dao( _classElement, name, - queryMethods, - insertionMethods, - updateMethods, - deletionMethods, - transactionMethods, - streamEntities, - streamViews, + _getQueryMethods(methods), + _getInsertionMethods(methods), + _getUpdateMethods(methods), + _getDeletionMethods(methods), + _getTransactionMethods(methods), ); } List _getQueryMethods(final List methods) { return methods .where((method) => method.hasAnnotation(annotations.Query)) - .map((method) => - QueryMethodProcessor(method, [..._entities, ..._views]).process()) + .map((method) => QueryMethodProcessor( + method, [..._entities, ..._views], _analyzerEngine) + .process()) .toList(); } diff --git a/floor_generator/lib/processor/database_processor.dart b/floor_generator/lib/processor/database_processor.dart index 77d34696..fcd91b4f 100644 --- a/floor_generator/lib/processor/database_processor.dart +++ b/floor_generator/lib/processor/database_processor.dart @@ -8,6 +8,7 @@ import 'package:floor_generator/processor/dao_processor.dart'; import 'package:floor_generator/processor/entity_processor.dart'; import 'package:floor_generator/processor/error/database_processor_error.dart'; import 'package:floor_generator/processor/processor.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; import 'package:floor_generator/processor/view_processor.dart'; import 'package:floor_generator/value_object/dao_getter.dart'; import 'package:floor_generator/value_object/database.dart'; @@ -27,12 +28,21 @@ class DatabaseProcessor extends Processor { @nonNull @override Database process() { + final analyzerEngine = AnalyzerEngine(); final databaseName = _classElement.displayName; final entities = _getEntities(_classElement); - final views = _getViews(_classElement); - final daoGetters = _getDaoGetters(databaseName, entities, views); + entities.forEach(analyzerEngine.registerEntity); + final views = _getViews(_classElement, analyzerEngine); + final daoGetters = + _getDaoGetters(databaseName, entities, views, analyzerEngine); final version = _getDatabaseVersion(); + final streamEntities = daoGetters + .expand((dg) => dg.dao.queryMethods) + .where((method) => method.returnType.isStream) + .expand((queryMethod) => queryMethod.query.dependencies) + .toSet(); + return Database( _classElement, databaseName, @@ -40,6 +50,7 @@ class DatabaseProcessor extends Processor { views, daoGetters, version, + streamEntities, ); } @@ -61,6 +72,7 @@ class DatabaseProcessor extends Processor { final String databaseName, final List entities, final List views, + final AnalyzerEngine analyzerEngine, ) { return _classElement.fields.where(_isDao).map((field) { final classElement = field.type.element as ClassElement; @@ -72,6 +84,7 @@ class DatabaseProcessor extends Processor { databaseName, entities, views, + analyzerEngine, ).process(); return DaoGetter(field, name, dao); @@ -110,7 +123,8 @@ class DatabaseProcessor extends Processor { } @nonNull - List _getViews(final ClassElement databaseClassElement) { + List _getViews(final ClassElement databaseClassElement, + final AnalyzerEngine analyzerEngine) { return _classElement .getAnnotation(annotations.Database) .getField(AnnotationField.databaseViews) @@ -118,7 +132,8 @@ class DatabaseProcessor extends Processor { ?.map((object) => object.toTypeValue().element) ?.whereType() ?.where(_isView) - ?.map((classElement) => ViewProcessor(classElement).process()) + ?.map((classElement) => + ViewProcessor(classElement, analyzerEngine).process()) ?.toList(); } diff --git a/floor_generator/lib/processor/entity_processor.dart b/floor_generator/lib/processor/entity_processor.dart index 7ed4756e..e61b1be2 100644 --- a/floor_generator/lib/processor/entity_processor.dart +++ b/floor_generator/lib/processor/entity_processor.dart @@ -33,7 +33,7 @@ class EntityProcessor extends QueryableProcessor { } return Entity( - classElement, + classElement.displayName, name, fields, _getPrimaryKey(fields), diff --git a/floor_generator/lib/processor/error/query_method_processor_error.dart b/floor_generator/lib/processor/error/query_method_processor_error.dart index 30680a22..97bddb52 100644 --- a/floor_generator/lib/processor/error/query_method_processor_error.dart +++ b/floor_generator/lib/processor/error/query_method_processor_error.dart @@ -10,24 +10,46 @@ class QueryMethodProcessorError { InvalidGenerationSourceError get noQueryDefined { return InvalidGenerationSourceError( - "You didn't define a query.", + "You didn't define a query or the query was not a string literal.", todo: 'Define a query by adding SQL to the @Query() annotation.', element: _methodElement, ); } - InvalidGenerationSourceError get queryArgumentsAndMethodParametersDoNotMatch { + InvalidGenerationSourceError get doesNotReturnFutureNorStream { return InvalidGenerationSourceError( - 'SQL query arguments and method parameters have to match.', - todo: 'Make sure to supply one parameter per SQL query argument.', + 'All query methods have to return a Future or Stream.', + todo: 'Define the return type as Future or Stream.', element: _methodElement, ); } - InvalidGenerationSourceError get doesNotReturnFutureNorStream { + InvalidGenerationSourceError get doesNotReturnQueryableOrPrimitive { + //TODO Typeconverters return InvalidGenerationSourceError( - 'All queries have to return a Future or Stream.', - todo: 'Define the return type as Future or Stream.', + 'The inner return type of a query method can only return a type which ' + 'was defined as a @DatabaseView or an @Entity or a primitive type ' + '(void, int, double, String or Uint8List)', + todo: 'Define the return type in a way that contains no types other than ' + 'primitive types and Entities/DatabaseViews.', + element: _methodElement, + ); + } + + InvalidGenerationSourceError get voidReturnCannotBeList { + return InvalidGenerationSourceError( + 'The given query cannot return a List.', + todo: + "Set the return type to Future or don't use void for the return type.", + element: _methodElement, + ); + } + + InvalidGenerationSourceError get voidReturnCannotBeStream { + return InvalidGenerationSourceError( + 'The given query cannot return a Stream.', + todo: + "Set the return type to Future or don't use void for the return type.", element: _methodElement, ); } diff --git a/floor_generator/lib/processor/error/query_processor_error.dart b/floor_generator/lib/processor/error/query_processor_error.dart new file mode 100644 index 00000000..d15e4d0d --- /dev/null +++ b/floor_generator/lib/processor/error/query_processor_error.dart @@ -0,0 +1,81 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart'; + +class QueryProcessorError { + final MethodElement _methodElement; + + QueryProcessorError(final MethodElement methodElement) + : assert(methodElement != null), + _methodElement = methodElement; + + InvalidGenerationSourceError fromParsingError(ParsingError error) { + return InvalidGenerationSourceError( + 'The query contained parser errors: ${error.toString()}', + element: _methodElement, + ); + } + + InvalidGenerationSourceError fromAnalysisError(AnalysisError error) { + return InvalidGenerationSourceError( + 'The query contained analyzer errors: ${error.toString()}', + element: _methodElement, + ); + } + + InvalidGenerationSourceError queryParameterMissingInMethod( + ColonNamedVariable variable) { + return InvalidGenerationSourceError( + 'The named variable in the statement of the `@Query` annotation should exist in the method parameters.\n' + '${variable.span.highlight()}', + todo: + 'Please add a method parameter for the variable `${variable.name}` with the name `${variable.name.substring(1)}`.', + element: _methodElement); + } + + InvalidGenerationSourceError methodParameterMissingInQuery( + ParameterElement parameter) { + return InvalidGenerationSourceError( + 'The method parameter should be referenced in statement of `@Query` annotation', + todo: + 'Please reference this parameter with `:${parameter.displayName}` or remove it from the parameters.', + element: parameter); + } + + InvalidGenerationSourceError shouldNotHaveNumberedVars(NumberedVariable e) { + return InvalidGenerationSourceError( + 'Statements used in floor should only have named parameters with colons.\n${e.span.highlight()}', + todo: + 'Please use a named variable (`:name`) instead of numbered variables (`?` or `?3`).', + element: _methodElement); + } + + InvalidGenerationSourceError unexpectedNamedVariableInTransformedQuery( + ColonNamedVariable v) { + final builder = StringBuffer() + ..writeln( + 'The named variable `${v.name}` should not be in the transformed query string! This is a bug in floor.') + ..writeln(v.span.highlight()); + return InvalidGenerationSourceError(builder.toString(), + todo: 'Please report the bug and include some context.', + element: _methodElement); + } + + InvalidGenerationSourceError listParameterMissingParentheses( + ColonNamedVariable v) { + return InvalidGenerationSourceError( + 'The named variable `${v.name}` referencing a list parameter should be enclosed by parentheses.\n' + '${v.span.highlight()}', + todo: 'Please replace `${v.name}` with `(${v.name})`', + element: _methodElement); + } + + InvalidGenerationSourceError unsupportedParameterType( + VariableElement parameter, DartType type) { + return InvalidGenerationSourceError( + 'Parameter type `$type` is not supported.', + element: parameter, + ); + } +} diff --git a/floor_generator/lib/processor/error/type_checker_error.dart b/floor_generator/lib/processor/error/type_checker_error.dart new file mode 100644 index 00000000..00270446 --- /dev/null +++ b/floor_generator/lib/processor/error/type_checker_error.dart @@ -0,0 +1,112 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/value_object/field.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart'; + +class TypeCheckerError { + final Element _queryElement; + + TypeCheckerError(this._queryElement); + + InvalidGenerationSourceError columnCountMismatch( + int fieldCount, + int columnCount, + ) { + return InvalidGenerationSourceError( + 'The query should return the same amount of columns($columnCount) as the target($fieldCount).', + todo: 'Either change the target type or alter your query.', + element: _queryElement, + ); + } + + InvalidGenerationSourceError columnCountShouldBeOne(int columnCount) { + return InvalidGenerationSourceError( + 'The query should return a single column instead of $columnCount column${columnCount == 1 ? 's' : ''}.', + todo: 'Either change the target type or alter your query.', + element: _queryElement, + ); + } + + InvalidGenerationSourceError columnNotFound(Field field) { + return _newFieldTypeMismatchError( + 'The query should return a column named `${field.columnName}`', + 'rename the field', + field.fieldElement, + ); + } + + InvalidGenerationSourceError nullableMismatch(Field field) { + return _newFieldTypeMismatchError( + 'The query returns `null` for `${field.columnName}` but the type of the field is not nullable', + 'make the field nullable', + field.fieldElement, + ); + } + + InvalidGenerationSourceError nullableMismatch2(Field field) { + return _newFieldTypeMismatchError( + 'The query could return `null` for `${field.columnName}` but the type of the field is not nullable', + 'make the field nullable', + field.fieldElement, + ); + } + + InvalidGenerationSourceError typeMismatch(Field field, BasicType parserType) { + return _newFieldTypeMismatchError( + 'The query returns a column of type ${_getSqlType(parserType)} ' + 'for `${field.columnName}` but the type of the field is ${field.sqlType}', + 'change the field type', + field.fieldElement, + ); + } + + InvalidGenerationSourceError returnTypeMismatch( + DartType returnType, BasicType parserType) { + return InvalidGenerationSourceError( + 'The query returns a column of type ${_getSqlType(parserType)} ' + 'but the method return type is $returnType', + todo: 'Either change the target type or alter your query.', + element: _queryElement, + ); + } + + InvalidGenerationSourceError _newFieldTypeMismatchError( + String message, String fieldAlteration, FieldElement field) { + final buffer = StringBuffer(message); + try { + final span = spanForElement(field); + buffer + ..writeln() + ..writeln(span.start.toolString) + ..write(span.highlight()); + } catch (_) { + // Source for `element` wasn't found, it must be in a summary with no + // associated source. We can still give the name. + buffer..writeln()..writeln('Cause: $field'); + } + + return InvalidGenerationSourceError(buffer.toString(), + todo: 'Either $fieldAlteration or alter your query.', + element: _queryElement); + } + + @nonNull + static String _getSqlType(BasicType parserType) { + switch (parserType) { + case BasicType.nullType: + return 'NULL'; + case BasicType.int: + return 'INTEGER'; + case BasicType.real: + return 'REAL'; + case BasicType.text: + return 'TEXT'; + case BasicType.blob: + return 'BLOB'; + } + throw ArgumentError('_getSqlType was called on an invalid value:' + '`$parserType`. This is a bug in floor.'); + } +} diff --git a/floor_generator/lib/processor/error/view_processor_error.dart b/floor_generator/lib/processor/error/view_processor_error.dart index b6f36d5c..9a5858ea 100644 --- a/floor_generator/lib/processor/error/view_processor_error.dart +++ b/floor_generator/lib/processor/error/view_processor_error.dart @@ -1,5 +1,6 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart'; class ViewProcessorError { final ClassElement _classElement; @@ -8,7 +9,7 @@ class ViewProcessorError { : assert(classElement != null), _classElement = classElement; - InvalidGenerationSourceError get missingQuery { + InvalidGenerationSourceError get missingSelectQuery { return InvalidGenerationSourceError( 'There is no SELECT query defined on the database view ${_classElement.displayName}.', todo: @@ -16,4 +17,32 @@ class ViewProcessorError { element: _classElement, ); } + + InvalidGenerationSourceError parseErrorFromSqlparser( + ParsingError parsingError) { + return InvalidGenerationSourceError( + 'The following error occurred while parsing the SQL-Statement in ${_classElement.displayName}: ${parsingError.toString()}', + element: _classElement); + } + + InvalidGenerationSourceError analysisErrorFromSqlparser( + AnalysisError lintingError) { + return InvalidGenerationSourceError( + 'The following error occurred while analyzing the SQL-Statement in ${_classElement.displayName}: ${lintingError.toString()}', + element: _classElement); + } + + InvalidGenerationSourceError lintingErrorFromSqlparser( + AnalysisError lintingError) { + return InvalidGenerationSourceError( + 'The following error occurred while comparing the DatabaseView to the SQL-Statement in ${_classElement.displayName}: ${lintingError.toString()}', + element: _classElement); + } + + InvalidGenerationSourceError unexpectedVariable(Variable variable) { + return InvalidGenerationSourceError( + 'The query should not contain any variable references\n${variable.span.highlight()}', + todo: 'Remove all variables by altering the query.', + element: _classElement); + } } diff --git a/floor_generator/lib/processor/field_processor.dart b/floor_generator/lib/processor/field_processor.dart index 31607d23..83551677 100644 --- a/floor_generator/lib/processor/field_processor.dart +++ b/floor_generator/lib/processor/field_processor.dart @@ -70,7 +70,7 @@ class FieldProcessor extends Processor { return SqlType.blob; } throw InvalidGenerationSourceError( - 'Column type is not supported for $type.', + 'Column type is not supported for `$type.`', element: _fieldElement, ); } diff --git a/floor_generator/lib/processor/query_analyzer/dependency_graph.dart b/floor_generator/lib/processor/query_analyzer/dependency_graph.dart new file mode 100644 index 00000000..689571a9 --- /dev/null +++ b/floor_generator/lib/processor/query_analyzer/dependency_graph.dart @@ -0,0 +1,42 @@ +import 'package:floor_generator/misc/annotations.dart'; + +class DependencyGraph { + final Map> _directDependencies = {}; + + final Map> _dependencyCache = {}; + + void add(String name, Iterable dependencies) { + _directDependencies.update( + name, (existingDeps) => {...existingDeps, ...dependencies}, + ifAbsent: () => dependencies.toSet()); + _dependencyCache.clear(); + } + + @nonNull + Set indirectDependencies(String name) { + if (!_directDependencies.containsKey(name)) { + return {name}; + } + + if (_dependencyCache.containsKey(name)) { + return _dependencyCache[name]; + } + + final Set output = {name}; + final Set todo = {name}; + while (todo.isNotEmpty) { + final element = todo.first; + todo.remove(element); + for (String dependency in _directDependencies[element]) { + if (!output.contains(dependency)) { + output.add(dependency); + if (_directDependencies.containsKey(dependency)) { + todo.add(dependency); + } + } + } + } + _dependencyCache[name] = output; + return output; + } +} diff --git a/floor_generator/lib/processor/query_analyzer/engine.dart b/floor_generator/lib/processor/query_analyzer/engine.dart new file mode 100644 index 00000000..8bb71f9b --- /dev/null +++ b/floor_generator/lib/processor/query_analyzer/engine.dart @@ -0,0 +1,73 @@ +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/misc/type_utils.dart'; +import 'package:floor_generator/processor/query_analyzer/dependency_graph.dart'; +import 'package:floor_generator/processor/query_analyzer/visitors.dart'; +import 'package:floor_generator/value_object/entity.dart'; +import 'package:floor_generator/value_object/view.dart' as floor; +import 'package:sqlparser/sqlparser.dart' hide Queryable; + +const varlistPlaceholder = ':varlist'; + +//todo single test for testing engine registrations and dependencies +//todo test dependency graph +//todo test visitors with example queries +//todo add tests +//TODO test: check converter by parallel construction: field, entity + +@nonNull +EngineOptions getDefaultEngineOptions() => EngineOptions( + useMoorExtensions: false, + useLegacyTypeInference: false, + enabledExtensions: const [], + ); + +class AnalyzerEngine { + final sqlEngine = SqlEngine(getDefaultEngineOptions()); + + final dependencyGraph = DependencyGraph(); + + void registerEntity(Entity entity) { + sqlEngine.registerTable(_convertEntityToTable(entity)); + + //register dependencies + final directDependencies = entity.foreignKeys + .where((e) => e.canChangeChild) + .map((e) => e.parentName); + dependencyGraph.add(entity.name, directDependencies); + } + + void registerView(floor.View floorView, View convertedView) { + sqlEngine.registerView(convertedView); + + //register dependencies + final references = findReferencedTablesOrViews(convertedView.definition); + dependencyGraph.add(floorView.name, references.map((e) => e.name)); + } + + bool isEntity(String name) { + return sqlEngine.knownResultSets + .whereType() + .any((element) => element.name == name); + } + + /// Converts a floor [Entity] into a sqlparser [Table] + @nonNull + static Table _convertEntityToTable(Entity e) => Table( + // table constraints like foreign keys or indices are omitted here + // because they will not be needed for static analysis. + name: e.name, + resolvedColumns: e.fields + .map((field) => TableColumn( + field.columnName, + ResolvedType( + type: sqlToBasicType[field.sqlType], + nullable: field.isNullable, + isArray: false, + hint: field.fieldElement.type.isDartCoreBool + ? const IsBoolean() + : null), + )) + .toList(growable: false), + withoutRowId: e.withoutRowid, + ); +} diff --git a/floor_generator/lib/processor/query_analyzer/type_checker.dart b/floor_generator/lib/processor/query_analyzer/type_checker.dart new file mode 100644 index 00000000..3029173e --- /dev/null +++ b/floor_generator/lib/processor/query_analyzer/type_checker.dart @@ -0,0 +1,124 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/processor/error/type_checker_error.dart'; +import 'package:floor_generator/value_object/field.dart'; +import 'package:floor_generator/value_object/query.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart'; +import 'package:floor_generator/misc/type_utils.dart'; + +//TODO TypeConverters!! + +void assertMatchingTypes(List fields, + List resolvedColumns, Element queryElement) { + final converted = resolvedColumns + .map((e) => SqlResultColumn.fromColumnWithType(e)) + .toList(growable: false); + assertMatchingReturnTypes(fields, converted, queryElement); +} + +void assertMatchingReturnTypes(List fields, + List resolvedColumns, Element queryElement) { + final error = TypeCheckerError(queryElement); + + if (fields.length != resolvedColumns.length) { + throw error.columnCountMismatch(fields.length, resolvedColumns.length); + } + + for (int i = 0; i < fields.length; ++i) { + final column = resolvedColumns.firstWhere( + (col) => col.name == fields[i].columnName, + orElse: () => throw error.columnNotFound(fields[i])); + + if (!column.isResolved) { + warn(queryElement, + 'the resulting type of column `${column.name}` could not be resolved, skipping.'); + continue; + } + + //be strict here, but could be too strict. + if (column.sqlType == BasicType.nullType && !fields[i].isNullable) { + throw error.nullableMismatch(fields[i]); + } + + if (column.sqlType != BasicType.nullType) { + if (sqlToBasicType[fields[i].sqlType] != column.sqlType) { + throw error.typeMismatch(fields[i], column.sqlType); + } + if (column.isNullable && !fields[i].isNullable) { + throw error.nullableMismatch2(fields[i]); + } + } + } +} + +void assertMatchingSingleReturnType(DartType primitiveType, + List resolvedColumns, Element queryElement) { + if (resolvedColumns.length != 1) { + throw TypeCheckerError(queryElement) + .columnCountShouldBeOne(resolvedColumns.length); + } + + final column = resolvedColumns.first; + + if (!column.isResolved) { + warn(queryElement, + 'the resulting type of column `${column.name}` could not be resolved, skipping.'); + return; + } + + // We can assume all primitive return types to be nullable, + // so omit those checks and directly check the type. + + if (column.sqlType == BasicType.nullType) { + return; // if the column returns null, it will match all types. + } + + if (_getSqlParserType(primitiveType) != column.sqlType) { + throw TypeCheckerError(queryElement) + .returnTypeMismatch(primitiveType, column.sqlType); + } +} + +void assertMatchingVoidReturn( + List resolvedColumns, Element queryElement) { + if (resolvedColumns.isEmpty) { + return; + } + warn(queryElement, 'query returns non-empty result which will be ignored'); +} + +@nonNull +BasicType _getSqlParserType(DartType type) { + if (type.isDartCoreInt) { + return BasicType.int; + } else if (type.isDartCoreString) { + return BasicType.text; + } else if (type.isDartCoreBool) { + return BasicType.int; + } else if (type.isDartCoreDouble) { + return BasicType.real; + } else if (type.isUint8List) { + return BasicType.blob; + } else { + throw ArgumentError('_getSqlParserType was called on an invalid value:' + '`$type`. This is a bug in floor.'); + } +} + +void warn(Element element, String message) { + final buffer = StringBuffer('WARNING: $message'); + try { + final span = spanForElement(element); + buffer + ..writeln() + ..writeln(span.start.toolString) + ..write(span.highlight()); + } catch (_) { + // Source for `element` wasn't found, it must be in a summary with no + // associated source. We can still give the name. + buffer..writeln()..writeln('Cause: $element'); + } + print(buffer.toString()); +} diff --git a/floor_generator/lib/processor/query_analyzer/visitors.dart b/floor_generator/lib/processor/query_analyzer/visitors.dart new file mode 100644 index 00000000..4a979f67 --- /dev/null +++ b/floor_generator/lib/processor/query_analyzer/visitors.dart @@ -0,0 +1,71 @@ +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/processor/error/query_processor_error.dart'; +import 'package:sqlparser/sqlparser.dart'; +import 'package:sqlparser/utils/find_referenced_tables.dart'; + +export 'package:sqlparser/utils/find_referenced_tables.dart' + show TableWrite, findWrittenTables; + +class VariableVisitor extends RecursiveVisitor { + final QueryProcessorError _processorError; + + final bool numberedVarsAllowed; + + final variables = []; + + final numberedVariables = []; + + final Set checkIfVariableExists; + + /// creates a visitor which walks a parsed SQLite statement and accumulates + /// all variable references into a list. It can throw an error if it + /// encounters a numbered variable (instead of a named one) and if it + /// encounters a variable which is not in the Set of variable names to check. + /// + /// If both are not checked, the [_processorError] can be null as it won't be used. + /// + /// It can be used in the following way: + /// + /// ```dart + /// final visitor = VariableVisitor(null, numberedVarsAllowed: true) + /// ..visitStatement(query, null); + /// print(visitor.variables) + /// ``` + VariableVisitor(this._processorError, + {this.numberedVarsAllowed = false, this.checkIfVariableExists}); + + @override + void visitNumberedVariable(NumberedVariable e, void arg) { + //error, no numbered variables allowed + if (!numberedVarsAllowed) { + throw _processorError.shouldNotHaveNumberedVars(e); + } + + numberedVariables.add(e); + return super.visitNumberedVariable(e, arg); + } + + @override + void visitNamedVariable(ColonNamedVariable e, void arg) { + if (checkIfVariableExists != null && + !checkIfVariableExists.contains(e.name.substring(1))) { + throw _processorError.queryParameterMissingInMethod(e); + } + variables.add(e); + return super.visitNamedVariable(e, arg); + } +} + +/// Finds all tables or views referenced in [root] or a descendant. +/// +/// The [root] node must have all its references resolved. This means that using +/// a node obtained via [SqlEngine.parse] directly won't report meaningful +/// results. Instead, use [SqlEngine.analyze] or [SqlEngine.analyzeParsed]. +/// +/// If you want to use both [findWrittenTables] and this on the same ast node, +/// follow the advice on [findWrittenTables] to only walk the ast once. +@nonNull +Set findReferencedTablesOrViews(AstNode root) { + final visitor = ReferencedTablesVisitor()..visit(root, null); + return {...visitor.foundTables, ...visitor.foundViews}; +} diff --git a/floor_generator/lib/processor/query_method_processor.dart b/floor_generator/lib/processor/query_method_processor.dart index 3605b0ba..e680694b 100644 --- a/floor_generator/lib/processor/query_method_processor.dart +++ b/floor_generator/lib/processor/query_method_processor.dart @@ -1,5 +1,4 @@ import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; import 'package:floor_annotation/floor_annotation.dart' as annotations show Query; import 'package:floor_generator/misc/annotations.dart'; @@ -7,7 +6,12 @@ import 'package:floor_generator/misc/constants.dart'; import 'package:floor_generator/misc/type_utils.dart'; import 'package:floor_generator/processor/error/query_method_processor_error.dart'; import 'package:floor_generator/processor/processor.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; +import 'package:floor_generator/processor/query_analyzer/type_checker.dart'; +import 'package:floor_generator/processor/query_processor.dart'; +import 'package:floor_generator/value_object/query.dart'; import 'package:floor_generator/value_object/query_method.dart'; +import 'package:floor_generator/value_object/query_method_return_type.dart'; import 'package:floor_generator/value_object/queryable.dart'; class QueryMethodProcessor extends Processor { @@ -15,14 +19,18 @@ class QueryMethodProcessor extends Processor { final MethodElement _methodElement; final List _queryables; + final AnalyzerEngine _analyzerEngine; QueryMethodProcessor( final MethodElement methodElement, final List queryables, + final AnalyzerEngine analyzerEngine, ) : assert(methodElement != null), assert(queryables != null), + assert(analyzerEngine != null), _methodElement = methodElement, _queryables = queryables, + _analyzerEngine = analyzerEngine, _processorError = QueryMethodProcessorError(methodElement); @nonNull @@ -30,32 +38,19 @@ class QueryMethodProcessor extends Processor { QueryMethod process() { final name = _methodElement.displayName; final parameters = _methodElement.parameters; - final rawReturnType = _methodElement.returnType; + final returnType = _getAndCheckReturnType(); - final query = _getQuery(); - final returnsStream = rawReturnType.isStream; + final query = + QueryProcessor(_methodElement, _getQuery(), _analyzerEngine).process(); - _assertReturnsFutureOrStream(rawReturnType, returnsStream); - - final flattenedReturnType = _getFlattenedReturnType( - rawReturnType, - returnsStream, - ); - - final queryable = _queryables.firstWhere( - (queryable) => - queryable.classElement.displayName == - flattenedReturnType.getDisplayString(), - orElse: () => null); + _assertMatchingReturnType(returnType, query.resultColumnTypes); return QueryMethod( _methodElement, name, query, - rawReturnType, - flattenedReturnType, + returnType, parameters, - queryable, ); } @@ -63,77 +58,71 @@ class QueryMethodProcessor extends Processor { String _getQuery() { final query = _methodElement .getAnnotation(annotations.Query) - .getField(AnnotationField.queryValue) - ?.toStringValue() - ?.replaceAll('\n', ' ') - ?.replaceAll(RegExp(r'[ ]{2,}'), ' ') - ?.trim(); + ?.getField(AnnotationField.queryValue) + ?.toStringValue(); if (query == null || query.isEmpty) throw _processorError.noQueryDefined; - final substitutedQuery = query.replaceAll(RegExp(r':[.\w]+'), '?'); - _assertQueryParameters(substitutedQuery, _methodElement.parameters); - return _replaceInClauseArguments(substitutedQuery); + return query; } + /// Derive the method return type and check if it matches the conditions (has + /// to be Future<> or Stream<>, inner type must be a Queryable or a primitive + /// type, if the inner type is void, don't allow Stream<> or List<>) @nonNull - String _replaceInClauseArguments(final String query) { - var index = 0; - return query.replaceAllMapped( - RegExp(r'( in )\([?]\)', caseSensitive: false), - (match) { - index++; - final matchedString = match.input.substring(match.start, match.end); - return matchedString.replaceFirst( - RegExp(r'(\?)'), - '\$valueList$index', - ); - }, - ); - } + QueryMethodReturnType _getAndCheckReturnType() { + final returnType = QueryMethodReturnType(_methodElement.returnType); - @nonNull - DartType _getFlattenedReturnType( - final DartType rawReturnType, - final bool returnsStream, - ) { - final returnsList = _getReturnsList(rawReturnType, returnsStream); - - final type = returnsStream - ? _methodElement.returnType.flatten() - : _methodElement.library.typeSystem.flatten(rawReturnType); - if (returnsList) { - return type.flatten(); - } - return type; - } + _assertReturnsFutureOrStream(returnType); - @nonNull - bool _getReturnsList(final DartType returnType, final bool returnsStream) { - final type = returnsStream - ? returnType.flatten() - : _methodElement.library.typeSystem.flatten(returnType); + // find a matching queryable (view or entity) for the return type + returnType.queryable = _queryables.firstWhere( + (queryable) => + queryable.className == returnType.flattened.getDisplayString(), + orElse: () => null); + + _assertReturnsPrimitiveOrQueryable(returnType); - return type.isDartCoreList; + _assertVoidReturnIsFuture(returnType); + + return returnType; } - void _assertReturnsFutureOrStream( - final DartType rawReturnType, - final bool returnsStream, - ) { - if (!rawReturnType.isDartAsyncFuture && !returnsStream) { + void _assertReturnsFutureOrStream(final QueryMethodReturnType type) { + if (!type.isFuture && !type.isStream) { throw _processorError.doesNotReturnFutureNorStream; } } - void _assertQueryParameters( - final String query, - final List parameterElements, - ) { - final queryParameterCount = RegExp(r'\?').allMatches(query).length; + void _assertReturnsPrimitiveOrQueryable(final QueryMethodReturnType type) { + if (type.queryable == null && !type.isPrimitive) { + throw _processorError.doesNotReturnQueryableOrPrimitive; + } + } + + void _assertVoidReturnIsFuture(QueryMethodReturnType returnType) { + if (returnType.isVoid && returnType.isList) { + throw _processorError.voidReturnCannotBeList; + } + + if (returnType.isVoid && returnType.isStream) { + throw _processorError.voidReturnCannotBeStream; + } + } - if (queryParameterCount != parameterElements.length) { - throw _processorError.queryArgumentsAndMethodParametersDoNotMatch; + /// Checks if the method return type and the query result have matching types + /// or throws an error if they don't. Delegates to type_checker depending on + /// how many columns are expected to be returned(0, 1 or more than one) + void _assertMatchingReturnType( + QueryMethodReturnType dartType, List sqliteColumns) { + if (dartType.isVoid) { + assertMatchingVoidReturn(sqliteColumns, _methodElement); + } else if (dartType.isPrimitive) { + assertMatchingSingleReturnType( + dartType.flattened, sqliteColumns, _methodElement); + } else { + assertMatchingReturnTypes( + dartType.queryable.fields, sqliteColumns, _methodElement); } } } diff --git a/floor_generator/lib/processor/query_processor.dart b/floor_generator/lib/processor/query_processor.dart new file mode 100644 index 00000000..ab624117 --- /dev/null +++ b/floor_generator/lib/processor/query_processor.dart @@ -0,0 +1,248 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/processor/error/query_processor_error.dart'; +import 'package:floor_generator/processor/processor.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; +import 'package:floor_generator/processor/query_analyzer/visitors.dart'; +import 'package:floor_generator/value_object/entity.dart'; +import 'package:floor_generator/value_object/query.dart'; +import 'package:floor_generator/misc/type_utils.dart'; +import 'package:sqlparser/sqlparser.dart'; + +class QueryProcessor extends Processor { + final QueryProcessorError _processorError; + + final String _query; + + final AnalyzerEngine _engine; + + final List _parameters; + + QueryProcessor(MethodElement methodElement, this._query, this._engine) + : assert(methodElement != null), + assert(_query != null), + assert(_engine != null), + _processorError = QueryProcessorError(methodElement), + _parameters = methodElement.parameters; + + @override + Query process() { + final analyzeContext = _validate(); + + final listParameters = []; + + final newQuery = _processParameters(analyzeContext, listParameters); + + _assertNoNamedVarsLeft(newQuery); + + return Query( + newQuery, + listParameters, + _getOutputColumnTypes(analyzeContext), + _getDependencies(analyzeContext.root), + _getAffected(analyzeContext.root), + ); + } + + /// Run sqlparser and parse and analyze the query and return the resulting + /// analysis context for using the parse tree and analyzing the return types. + /// Any parsing or analysis errors(e.g. unknown table) are thrown as errors + /// + /// Also checks if the parameters are matching the query. + @nonNull + AnalysisContext _validate() { + // parse query, + final parsed = _engine.sqlEngine.parse(_query); + + // throw errors + if (parsed.errors.isNotEmpty) { + throw _processorError.fromParsingError(parsed.errors.first); + } + + // check if parameter names of method and query match and make sure that no + // numbered variables were used + _assertMatchingParameters(parsed.rootNode); + + // analyze query with named parameters only + final analyzed = _engine.sqlEngine.analyzeParsed(parsed, + stmtOptions: AnalyzeStatementOptions( + namedVariableTypes: Map.fromEntries(_parameters.map((param) => + MapEntry(':${param.name}', _getSqlparserType(param)))))); + // throw errors + if (analyzed.errors.isNotEmpty) { + throw _processorError.fromAnalysisError(analyzed.errors.first); + } + return analyzed; + } + + /// Processes the parameters used for the query, with the goal to be able to + /// use provided parameters in arbitrary order and multiple times, while still + /// checking correctness. + /// + /// It will write the detected parameters which provide a List<> to the given + /// [listParametersOutput] variable to be able to create a way to process them + /// at runtime. + /// + /// The rough algorithm: + /// 1. for each normal parameter: + /// 1.1 create a mapping from the parameter name to its position in the + /// dart method (don't count list parameters). + /// 1.3 replace each usage of that parameter in the query string with a + /// numbered parameter (?X). + /// 2. for each list parameter: + /// 2.1 ensure that this parameter is enclosed by parentheses + /// 2.2 replace each usage of this parameter with a placeholder and note + /// down the position within the new query. + /// 2.3 store position and name in order of appearance into the + /// [listParametersOutput] variable to be processed later + /// 3. Return the new query. + /// + /// Replacing the named variables with numbered ones is necessary for having + /// maximum control over the parameters, since named variables also get an + /// index assigned to them and then using variable lists will get complicated fast. + /// Additionally, numbered parameters are not as well supported by sqflite. + /// + /// + @nonNull + String _processParameters( + AnalysisContext ctx, List listParametersOutput) { + final indices = {}; + final fixedParameters = {}; + // map parameters to index (1-based) or 0 (=list) + int currentIndex = 1; + for (final parameter in _parameters) { + if (parameter.type.isDartCoreList) { + indices[':${parameter.name}'] = 0; + } else { + fixedParameters.add(parameter.name); + indices[':${parameter.name}'] = currentIndex++; + } + } + + // get List of query variables via VariableVisitor + final visitor = VariableVisitor(_processorError, numberedVarsAllowed: false) + ..visitStatement(ctx.root, null); + + // replace(1-x) var names with parameters or(0) map span to name + final newQuery = StringBuffer(); + int currentLast = 0; + for (final varToken in visitor.variables) { + newQuery.write(_query.substring(currentLast, varToken.firstPosition)); + final varIndexInMethod = indices[varToken.name]; + if (varIndexInMethod > 0) { + //normal variable + newQuery.write('?'); + newQuery.write(varIndexInMethod); + } else { + //list variable + if (!(varToken.parent is Parentheses || varToken.parent is Tuple)) { + throw _processorError.listParameterMissingParentheses(varToken); + } + listParametersOutput + .add(ListParameter(newQuery.length, varToken.name.substring(1))); + newQuery.write(varlistPlaceholder); + } + currentLast = varToken.lastPosition; + } + newQuery.write(_query.substring(currentLast)); + return newQuery.toString(); + } + + /// Determine all [Entity]s this query (indirectly) relies on and return + /// their sql names. + @nonNull + Set _getDependencies(AstNode root) { + return findReferencedTablesOrViews(root) + // Find indirect dependencies for referenced Queryables + .expand((e) => _engine.dependencyGraph.indirectDependencies(e.name)) + //only accept entities + .where((element) => _engine.isEntity(element)) + .toSet(); + } + + /// Determine all the directly affected entities of this query and return + /// their table names. Should be of size 0 (for SELECT queries) or of + /// size 1 (for UPDATE,DELETE,CREATE,etc.) queries. Returns a set to be + /// able to return more affected entities in the future (TODO #373) + @nonNull + Set _getAffected(AstNode root) { + return findWrittenTables(root).map((e) => e.table.name).toSet(); + } + + /// This assertion should always be successful, even with wrong input. If it + /// isn't, then there is a bug within floors mechanism to handle `:var`s. + void _assertNoNamedVarsLeft(String newQuery) { + final parsed = _engine.sqlEngine.parse(newQuery); + final visitor = VariableVisitor(_processorError, numberedVarsAllowed: true) + ..visitStatement(parsed.rootNode, null); + for (final v in visitor.variables) { + if (v.name != varlistPlaceholder) { + throw _processorError.unexpectedNamedVariableInTransformedQuery(v); + } + } + } + + /// return the list of columns which are expected to be returned by the + /// given query. This can be used for type checking. + /// + /// The columns will have a name and a type. Please be aware that some + /// column types might not have been resolved ([SqlResultColumn.isResolved]) + @nonNull + List _getOutputColumnTypes(AnalysisContext analysisContext) { + if (analysisContext.root is BaseSelectStatement) { + return (analysisContext.root as BaseSelectStatement) + .resolvedColumns + .map((c) => SqlResultColumn(c.name, analysisContext.typeOf(c))) + .toList(growable: false); + } else { + // any other statement where SQLite does not return anything. + return []; + } + } + + /// ensures that + /// 1. All variable references in the statement are written as named variables + /// (`:variable`) and have a matching parameter with the same name and + /// 2. All function parameters are used in the statement at least once + /// + /// The [statement] is represented by the top AstNode as parsed by sqlparser + void _assertMatchingParameters(AstNode statement) { + final parameterNames = _parameters.map((p) => p.displayName).toSet(); + + final visitor = VariableVisitor(_processorError, + numberedVarsAllowed: false, checkIfVariableExists: parameterNames) + ..visitStatement(statement, null); + + final references = + visitor.variables.map((v) => v.name.substring(1)).toSet(); + for (final param in _parameters) { + if (!references.contains(param.displayName)) { + throw _processorError.methodParameterMissingInQuery(param); + } + } + } + + /// converts a dart element type description to a nullable type + /// compatible with sqlparser. + @nonNull + ResolvedType _getSqlparserType(VariableElement parameter) { + //TODO typeconverters + var type = parameter.type; + if (type.isDartCoreList) { + type = type.flatten(); + } + if (type.isDartCoreInt) { + return const ResolvedType(type: BasicType.int, nullable: true); + } else if (type.isDartCoreString) { + return const ResolvedType(type: BasicType.text, nullable: true); + } else if (type.isDartCoreBool) { + return const ResolvedType( + type: BasicType.int, nullable: true, hint: IsBoolean()); + } else if (type.isDartCoreDouble) { + return const ResolvedType(type: BasicType.real, nullable: true); + } else if (type.isUint8List) { + return const ResolvedType(type: BasicType.blob, nullable: true); + } + throw _processorError.unsupportedParameterType(parameter, type); + } +} diff --git a/floor_generator/lib/processor/queryable_processor.dart b/floor_generator/lib/processor/queryable_processor.dart index 2d176408..5d6d7dfc 100644 --- a/floor_generator/lib/processor/queryable_processor.dart +++ b/floor_generator/lib/processor/queryable_processor.dart @@ -63,7 +63,7 @@ abstract class QueryableProcessor extends Processor { ); if (field != null) { final parameterValue = "row['${field.columnName}']"; - final castedParameterValue = _castParameterValue( + final castedParameterValue = castParameterValue( parameterElement.type, parameterValue, field.isNullable); if (parameterElement.isNamed) { return '$parameterName: $castedParameterValue'; @@ -75,7 +75,7 @@ abstract class QueryableProcessor extends Processor { } @nonNull - String _castParameterValue( + static String castParameterValue( final DartType parameterType, final String parameterValue, final bool isNullable, @@ -101,6 +101,7 @@ abstract class QueryableProcessor extends Processor { } extension on FieldElement { + @nonNull bool shouldBeIncluded() { final isIgnored = hasAnnotation(annotations.ignore.runtimeType); return !(isStatic || isSynthetic || isIgnored); diff --git a/floor_generator/lib/processor/view_processor.dart b/floor_generator/lib/processor/view_processor.dart index 31667cd2..3be926e6 100644 --- a/floor_generator/lib/processor/view_processor.dart +++ b/floor_generator/lib/processor/view_processor.dart @@ -4,13 +4,20 @@ import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/misc/constants.dart'; import 'package:floor_generator/misc/type_utils.dart'; import 'package:floor_generator/processor/error/view_processor_error.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; +import 'package:floor_generator/processor/query_analyzer/type_checker.dart'; +import 'package:floor_generator/processor/query_analyzer/visitors.dart'; import 'package:floor_generator/processor/queryable_processor.dart'; +import 'package:floor_generator/value_object/field.dart'; import 'package:floor_generator/value_object/view.dart'; +import 'package:sqlparser/sqlparser.dart' as sqlparser; class ViewProcessor extends QueryableProcessor { final ViewProcessorError _processorError; - ViewProcessor(final ClassElement classElement) + final AnalyzerEngine _analyzerEngine; + + ViewProcessor(final ClassElement classElement, this._analyzerEngine) : _processorError = ViewProcessorError(classElement), super(classElement); @@ -18,13 +25,24 @@ class ViewProcessor extends QueryableProcessor { @override View process() { final fields = getFields(); - return View( - classElement, - _getName(), + final name = _getName(); + final query = _getQuery(); + + final sqlparserView = _checkAndConvert(query, name, fields); + + assertMatchingTypes(fields, sqlparserView.resolvedColumns, classElement); + + final view = View( + classElement.displayName, + name, fields, - _getQuery(), + query, getConstructor(fields), ); + + _analyzerEngine.registerView(view, sqlparserView); + + return view; } @nonNull @@ -38,14 +56,65 @@ class ViewProcessor extends QueryableProcessor { @nonNull String _getQuery() { - final query = classElement + return classElement .getAnnotation(annotations.DatabaseView) .getField(AnnotationField.viewQuery) ?.toStringValue(); + } + + @nonNull + sqlparser.View _checkAndConvert( + String query, String name, List fields) { + // parse query + final parserCtx = _analyzerEngine.sqlEngine.parse(query); + + if (parserCtx.errors.isNotEmpty) { + throw _processorError.parseErrorFromSqlparser(parserCtx.errors.first); + } - if (query == null || !query.trimLeft().toLowerCase().startsWith('select')) { - throw _processorError.missingQuery; + // check if query is a select statement + if (!(parserCtx.rootNode is sqlparser.BaseSelectStatement)) { + throw _processorError.missingSelectQuery; + } + + _assertNoVariables(parserCtx.rootNode); + + // analyze query (derive types) + final ctx = _analyzerEngine.sqlEngine.analyzeParsed(parserCtx); + if (ctx.errors.isNotEmpty) { + throw _processorError.analysisErrorFromSqlparser(ctx.errors.first); + } + + // create a Parser node for sqlparser + final viewStmt = sqlparser.CreateViewStatement( + ifNotExists: true, + viewName: name, + columns: fields.map((f) => f.columnName).toList(growable: false), + query: ctx.root as sqlparser.BaseSelectStatement, + ); + + // check if any issues occurred while parsing and analyzing the query, + // such as a mismatch between the count of the result of the query and + // the count of fields in the view class + sqlparser.LintingVisitor(getDefaultEngineOptions(), ctx) + .visitCreateViewStatement(viewStmt, null); + if (ctx.errors.isNotEmpty) { + throw _processorError.lintingErrorFromSqlparser(ctx.errors.first); + } + + // let sqlparser convert the parser node into a sqlparser view + return const sqlparser.SchemaFromCreateTable(moorExtensions: false) + .readView(ctx, viewStmt); + } + + void _assertNoVariables(sqlparser.AstNode query) { + final visitor = VariableVisitor(null, numberedVarsAllowed: true) + ..visitStatement(query, null); + if (visitor.variables.isNotEmpty) { + throw _processorError.unexpectedVariable(visitor.variables.first); + } + if (visitor.numberedVariables.isNotEmpty) { + throw _processorError.unexpectedVariable(visitor.numberedVariables.first); } - return query; } } diff --git a/floor_generator/lib/value_object/dao.dart b/floor_generator/lib/value_object/dao.dart index 7ff79421..b7aecacf 100644 --- a/floor_generator/lib/value_object/dao.dart +++ b/floor_generator/lib/value_object/dao.dart @@ -1,12 +1,10 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:collection/collection.dart'; import 'package:floor_generator/value_object/deletion_method.dart'; -import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/insertion_method.dart'; import 'package:floor_generator/value_object/query_method.dart'; import 'package:floor_generator/value_object/transaction_method.dart'; import 'package:floor_generator/value_object/update_method.dart'; -import 'package:floor_generator/value_object/view.dart'; class Dao { final ClassElement classElement; @@ -16,8 +14,6 @@ class Dao { final List updateMethods; final List deletionMethods; final List transactionMethods; - final Set streamEntities; - final Set streamViews; Dao( this.classElement, @@ -27,8 +23,6 @@ class Dao { this.updateMethods, this.deletionMethods, this.transactionMethods, - this.streamEntities, - this.streamViews, ); @override @@ -47,10 +41,7 @@ class Dao { const ListEquality() .equals(deletionMethods, other.deletionMethods) && const ListEquality() - .equals(transactionMethods, other.transactionMethods) && - const SetEquality() - .equals(streamEntities, other.streamEntities) && - const SetEquality().equals(streamViews, other.streamViews); + .equals(transactionMethods, other.transactionMethods); @override int get hashCode => @@ -60,12 +51,10 @@ class Dao { insertionMethods.hashCode ^ updateMethods.hashCode ^ deletionMethods.hashCode ^ - transactionMethods.hashCode ^ - streamEntities.hashCode ^ - streamViews.hashCode; + transactionMethods.hashCode; @override String toString() { - return 'Dao{classElement: $classElement, name: $name, queryMethods: $queryMethods, insertionMethods: $insertionMethods, updateMethods: $updateMethods, deletionMethods: $deletionMethods, transactionMethods: $transactionMethods, streamEntities: $streamEntities, streamViews: $streamViews}'; + return 'Dao{classElement: $classElement, name: $name, queryMethods: $queryMethods, insertionMethods: $insertionMethods, updateMethods: $updateMethods, deletionMethods: $deletionMethods, transactionMethods: $transactionMethods}'; } } diff --git a/floor_generator/lib/value_object/database.dart b/floor_generator/lib/value_object/database.dart index 82b458a2..b664b321 100644 --- a/floor_generator/lib/value_object/database.dart +++ b/floor_generator/lib/value_object/database.dart @@ -12,8 +12,7 @@ class Database { final List views; final List daoGetters; final int version; - final bool hasViewStreams; - final Set streamEntities; + final Set streamEntities; Database( this.classElement, @@ -22,9 +21,8 @@ class Database { this.views, this.daoGetters, this.version, - ) : streamEntities = - daoGetters.expand((dg) => dg.dao.streamEntities).toSet(), - hasViewStreams = daoGetters.any((dg) => dg.dao.streamViews.isNotEmpty); + this.streamEntities, + ); @override bool operator ==(Object other) => @@ -38,8 +36,7 @@ class Database { const ListEquality() .equals(daoGetters, other.daoGetters) && version == other.version && - hasViewStreams == hasViewStreams && - const SetEquality() + const SetEquality() .equals(streamEntities, other.streamEntities); @override @@ -50,11 +47,10 @@ class Database { views.hashCode ^ daoGetters.hashCode ^ version.hashCode ^ - hasViewStreams.hashCode ^ streamEntities.hashCode; @override String toString() { - return 'Database{classElement: $classElement, name: $name, entities: $entities, views: $views, daoGetters: $daoGetters, version: $version, hasViewStreams: $hasViewStreams, streamEntities: $streamEntities}'; + return 'Database{classElement: $classElement, name: $name, entities: $entities, views: $views, daoGetters: $daoGetters, version: $version, streamEntities: $streamEntities}'; } } diff --git a/floor_generator/lib/value_object/entity.dart b/floor_generator/lib/value_object/entity.dart index c286dd15..0484a9c0 100644 --- a/floor_generator/lib/value_object/entity.dart +++ b/floor_generator/lib/value_object/entity.dart @@ -1,4 +1,3 @@ -import 'package:analyzer/dart/element/element.dart'; import 'package:collection/collection.dart'; import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/value_object/field.dart'; @@ -14,7 +13,7 @@ class Entity extends Queryable { final bool withoutRowid; Entity( - ClassElement classElement, + String className, String name, List fields, this.primaryKey, @@ -22,7 +21,7 @@ class Entity extends Queryable { this.indices, this.withoutRowid, String constructor, - ) : super(classElement, name, fields, constructor); + ) : super(className, name, fields, constructor); @nonNull String getCreateTableStatement() { @@ -87,7 +86,7 @@ class Entity extends Queryable { identical(this, other) || other is Entity && runtimeType == other.runtimeType && - classElement == other.classElement && + className == other.className && name == other.name && const ListEquality().equals(fields, other.fields) && primaryKey == other.primaryKey && @@ -99,7 +98,7 @@ class Entity extends Queryable { @override int get hashCode => - classElement.hashCode ^ + className.hashCode ^ name.hashCode ^ fields.hashCode ^ primaryKey.hashCode ^ @@ -110,6 +109,6 @@ class Entity extends Queryable { @override String toString() { - return 'Entity{classElement: $classElement, name: $name, fields: $fields, primaryKey: $primaryKey, foreignKeys: $foreignKeys, indices: $indices, constructor: $constructor, withoutRowid: $withoutRowid}'; + return 'Entity{className: $className, name: $name, fields: $fields, primaryKey: $primaryKey, foreignKeys: $foreignKeys, indices: $indices, constructor: $constructor, withoutRowid: $withoutRowid}'; } } diff --git a/floor_generator/lib/value_object/foreign_key.dart b/floor_generator/lib/value_object/foreign_key.dart index 6c464582..b2957149 100644 --- a/floor_generator/lib/value_object/foreign_key.dart +++ b/floor_generator/lib/value_object/foreign_key.dart @@ -29,6 +29,11 @@ class ForeignKey { ' ON DELETE $onDelete'; } + // The following foreignKeyActions could change the child table, `NO ACTION` and `RESTRICT` will not. + static const updateActions = {'SET NULL', 'SET DEFAULT', 'CASCADE'}; + bool get canChangeChild => + updateActions.contains(onUpdate) || updateActions.contains(onDelete); + final _listEquality = const ListEquality(); @override diff --git a/floor_generator/lib/value_object/query.dart b/floor_generator/lib/value_object/query.dart new file mode 100644 index 00000000..a1f30ab7 --- /dev/null +++ b/floor_generator/lib/value_object/query.dart @@ -0,0 +1,121 @@ +import 'package:collection/collection.dart'; +import 'package:floor_generator/misc/annotations.dart'; +import 'package:sqlparser/sqlparser.dart'; + +class Query { + final String sql; + + final List listParameters; + + final List resultColumnTypes; + + /// The names of the entities this query directly and indirectly depends on. + /// If an entity of this set changes, it is possible that the output of + /// this query also changes. + final Set dependencies; + + /// The names of the entities this query will change directly + final Set affectedEntities; + + Query(this.sql, this.listParameters, this.resultColumnTypes, + this.dependencies, this.affectedEntities); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Query && + runtimeType == other.runtimeType && + sql == other.sql && + const ListEquality() + .equals(listParameters, other.listParameters) && + const ListEquality() + .equals(resultColumnTypes, other.resultColumnTypes) && + const SetEquality() + .equals(dependencies, other.dependencies) && + const SetEquality() + .equals(affectedEntities, other.affectedEntities); + + @override + int get hashCode => + sql.hashCode ^ + listParameters.hashCode ^ + resultColumnTypes.hashCode ^ + dependencies.hashCode ^ + affectedEntities.hashCode; + + @override + String toString() { + return 'Query{sql: $sql, listParameters: $listParameters, resultColumnTypes: $resultColumnTypes, dependencies: $dependencies, affectedEntities: $affectedEntities}'; + } +} + +class ListParameter { + final int position; + final String name; + ListParameter(this.position, this.name); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ListParameter && + runtimeType == other.runtimeType && + position == other.position && + name == other.name; + + @override + int get hashCode => position.hashCode ^ name.hashCode; + + @override + String toString() { + return 'ListParameter{position: $position, name: $name}'; + } +} + +class SqlResultColumn { + @nonNull + final String name; + + @nullable + final BasicType sqlType; + + @nullable + final bool isNullable; + + @nonNull + final bool isResolved; + + SqlResultColumn(this.name, ResolveResult type) + : assert(type != null), + sqlType = type.type?.type, + isNullable = type.type?.nullable, + isResolved = !type.unknown; + + SqlResultColumn.fromColumnWithType(ColumnWithType col) + : assert(col != null), + name = col.name, + sqlType = col.type.type, + isNullable = col.type.nullable, + isResolved = true; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SqlResultColumn && + runtimeType == other.runtimeType && + name == other.name && + sqlType == other.sqlType && + isNullable == other.isNullable && + isResolved == other.isResolved; + + @override + int get hashCode => + name.hashCode ^ + sqlType.hashCode ^ + isNullable.hashCode ^ + isResolved.hashCode; + + @override + String toString() { + return 'SqlResultColumn{name: $name, sqltype: $sqlType, isNullable: $isNullable, isResolved: $isResolved}'; + } +} diff --git a/floor_generator/lib/value_object/query_method.dart b/floor_generator/lib/value_object/query_method.dart index b2950fb1..4c600033 100644 --- a/floor_generator/lib/value_object/query_method.dart +++ b/floor_generator/lib/value_object/query_method.dart @@ -1,8 +1,8 @@ import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; import 'package:collection/collection.dart'; -import 'package:floor_generator/misc/type_utils.dart'; -import 'package:floor_generator/value_object/queryable.dart'; + +import 'package:floor_generator/value_object/query.dart'; +import 'package:floor_generator/value_object/query_method_return_type.dart'; /// Wraps a method annotated with Query /// to enable easy access to code generation relevant data. @@ -11,47 +11,21 @@ class QueryMethod { final String name; - /// Query where ':' got replaced with '$'. - final String query; - - final DartType rawReturnType; + /// The annotated and analyzed Query + final Query query; - /// Flattened return type. - /// - /// E.g. - /// Future -> T, - /// Future> -> T - /// - /// Stream -> T - /// Stream> -> T - final DartType flattenedReturnType; + final QueryMethodReturnType returnType; final List parameters; - final Queryable queryable; - QueryMethod( this.methodElement, this.name, this.query, - this.rawReturnType, - this.flattenedReturnType, + this.returnType, this.parameters, - this.queryable, ); - bool get returnsList { - final type = returnsStream - ? rawReturnType.flatten() - : methodElement.library.typeSystem.flatten(rawReturnType); - - return type.isDartCoreList; - } - - bool get returnsStream => rawReturnType.isStream; - - bool get returnsVoid => flattenedReturnType.isVoid; - @override bool operator ==(Object other) => identical(this, other) || @@ -60,24 +34,20 @@ class QueryMethod { methodElement == other.methodElement && name == other.name && query == other.query && - rawReturnType == other.rawReturnType && - flattenedReturnType == other.flattenedReturnType && + returnType == other.returnType && const ListEquality() - .equals(parameters, other.parameters) && - queryable == other.queryable; + .equals(parameters, other.parameters); @override int get hashCode => methodElement.hashCode ^ name.hashCode ^ query.hashCode ^ - rawReturnType.hashCode ^ - flattenedReturnType.hashCode ^ - parameters.hashCode ^ - queryable.hashCode; + returnType.hashCode ^ + parameters.hashCode; @override String toString() { - return 'QueryMethod{methodElement: $methodElement, name: $name, query: $query, rawReturnType: $rawReturnType, flattenedReturnType: $flattenedReturnType, parameters: $parameters, entity: $queryable}'; + return 'QueryMethod{methodElement: $methodElement, name: $name, query: $query, returnType: $returnType, parameters: $parameters}'; } } diff --git a/floor_generator/lib/value_object/query_method_return_type.dart b/floor_generator/lib/value_object/query_method_return_type.dart new file mode 100644 index 00000000..41c443fa --- /dev/null +++ b/floor_generator/lib/value_object/query_method_return_type.dart @@ -0,0 +1,72 @@ +import 'package:analyzer/dart/element/type.dart'; +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/misc/type_utils.dart'; +import 'package:floor_generator/value_object/queryable.dart'; + +/// A simple accessor class for providing all properties of +/// the return type of a query method. +class QueryMethodReturnType { + final DartType raw; + + /*late*/ Queryable queryable; + // The following values are derived once (in the constructor) and stored. + final bool isStream; + final bool isFuture; + final bool isList; + + /// Flattened return type. + /// + /// E.g. + /// Future -> T, + /// Future> -> T + /// + /// Stream -> T + /// Stream> -> T + @nonNull + final DartType flattened; + + @nonNull + bool get isVoid => flattened.isVoid; + + @nonNull + bool get isPrimitive => + flattened.isVoid || + flattened.isDartCoreDouble || + flattened.isDartCoreInt || + flattened.isDartCoreBool || + flattened.isDartCoreString || + flattened.isUint8List; + + QueryMethodReturnType(this.raw) + : assert(raw != null), + isStream = raw.isStream, + isFuture = raw.isDartAsyncFuture, + isList = raw.flatten().isDartCoreList, + flattened = _flattenWithList(raw); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueryMethodReturnType && + runtimeType == other.runtimeType && + raw == other.raw && + queryable == other.queryable; + + @override + int get hashCode => raw.hashCode ^ queryable.hashCode; + + @override + String toString() { + return 'QueryMethod{raw: $raw, queryable: $queryable, flattened: $flattened}'; + } + + @nonNull + static DartType _flattenWithList(DartType rawReturnType) { + final flattenedOnce = rawReturnType.flatten(); + if (flattenedOnce.isDartCoreList) { + return flattenedOnce.flatten(); + } else { + return flattenedOnce; + } + } +} diff --git a/floor_generator/lib/value_object/queryable.dart b/floor_generator/lib/value_object/queryable.dart index d3014394..1243f695 100644 --- a/floor_generator/lib/value_object/queryable.dart +++ b/floor_generator/lib/value_object/queryable.dart @@ -1,11 +1,10 @@ -import 'package:analyzer/dart/element/element.dart'; import 'package:floor_generator/value_object/field.dart'; abstract class Queryable { - final ClassElement classElement; + final String className; final String name; final List fields; final String constructor; - Queryable(this.classElement, this.name, this.fields, this.constructor); + Queryable(this.className, this.name, this.fields, this.constructor); } diff --git a/floor_generator/lib/value_object/view.dart b/floor_generator/lib/value_object/view.dart index a38e21d8..934ccf71 100644 --- a/floor_generator/lib/value_object/view.dart +++ b/floor_generator/lib/value_object/view.dart @@ -1,4 +1,3 @@ -import 'package:analyzer/dart/element/element.dart'; import 'package:collection/collection.dart'; import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/value_object/field.dart'; @@ -8,12 +7,12 @@ class View extends Queryable { final String query; View( - ClassElement classElement, + String className, String name, List fields, this.query, String constructor, - ) : super(classElement, name, fields, constructor); + ) : super(className, name, fields, constructor); @nonNull String getCreateViewStatement() { @@ -25,7 +24,7 @@ class View extends Queryable { identical(this, other) || other is View && runtimeType == other.runtimeType && - classElement == other.classElement && + className == other.className && name == other.name && const ListEquality().equals(fields, other.fields) && query == other.query && @@ -33,7 +32,7 @@ class View extends Queryable { @override int get hashCode => - classElement.hashCode ^ + className.hashCode ^ name.hashCode ^ fields.hashCode ^ query.hashCode ^ @@ -41,6 +40,6 @@ class View extends Queryable { @override String toString() { - return 'View{classElement: $classElement, name: $name, fields: $fields, query: $query, constructor: $constructor}'; + return 'View{className: $className, name: $name, fields: $fields, query: $query, constructor: $constructor}'; } } diff --git a/floor_generator/lib/writer/dao_writer.dart b/floor_generator/lib/writer/dao_writer.dart index 671af2da..0d28a667 100644 --- a/floor_generator/lib/writer/dao_writer.dart +++ b/floor_generator/lib/writer/dao_writer.dart @@ -2,7 +2,6 @@ import 'package:code_builder/code_builder.dart'; import 'package:floor_generator/misc/string_utils.dart'; import 'package:floor_generator/value_object/dao.dart'; import 'package:floor_generator/value_object/deletion_method.dart'; -import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/insertion_method.dart'; import 'package:floor_generator/value_object/query_method.dart'; import 'package:floor_generator/value_object/transaction_method.dart'; @@ -17,10 +16,9 @@ import 'package:floor_generator/writer/writer.dart'; /// Creates the implementation of a DAO. class DaoWriter extends Writer { final Dao dao; - final Set streamEntities; - final bool dbHasViewStreams; + final Set streamEntities; - DaoWriter(this.dao, this.streamEntities, this.dbHasViewStreams); + DaoWriter(this.dao, this.streamEntities); @override Class write() { @@ -53,20 +51,25 @@ class DaoWriter extends Writer { ..name = '_queryAdapter' ..type = refer('QueryAdapter'))); - final queriesRequireChangeListener = - dao.streamEntities.isNotEmpty || dao.streamViews.isNotEmpty; + // The QueryAdapter needs a changeListener if any Query returns a Stream or + // if any Query of this dao updates an entity a stream depends upon. + final queriesRequireChangeListener = dao.queryMethods.any((queryMethod) => + queryMethod.returnType.isStream || + queryMethod.query.affectedEntities + .intersection(streamEntities) + .isNotEmpty); constructorBuilder ..initializers.add(Code( "_queryAdapter = QueryAdapter(database${queriesRequireChangeListener ? ', changeListener' : ''})")); final queryMapperFields = queryMethods - .map((method) => method.queryable) + .map((method) => method.returnType.queryable) .where((entity) => entity != null) .toSet() .map((entity) { final constructor = entity.constructor; - final name = '_${entity.name.decapitalize()}Mapper'; + final name = '_${entity.className.decapitalize()}Mapper'; return Field((builder) => builder ..name = name @@ -83,7 +86,7 @@ class DaoWriter extends Writer { final entities = insertionMethods.map((method) => method.entity).toSet(); for (final entity in entities) { - final entityClassName = entity.classElement.displayName; + final entityClassName = entity.className; final fieldName = '_${entityClassName.decapitalize()}InsertionAdapter'; final type = refer('InsertionAdapter<$entityClassName>'); @@ -95,14 +98,13 @@ class DaoWriter extends Writer { classBuilder..fields.add(field); final valueMapper = - '(${entity.classElement.displayName} item) => ${entity.getValueMapping()}'; + '(${entity.className} item) => ${entity.getValueMapping()}'; - final requiresChangeListener = - dbHasViewStreams || streamEntities.contains(entity); + final requiresChangeListener = streamEntities.contains(entity.name); constructorBuilder ..initializers.add(Code( - "$fieldName = InsertionAdapter(database, '${entity.name}', $valueMapper${requiresChangeListener ? ', changeListener' : ''})")); + "$fieldName = InsertionAdapter(database, ${entity.name.toLiteral()}, $valueMapper${requiresChangeListener ? ', changeListener' : ''})")); } } @@ -111,7 +113,7 @@ class DaoWriter extends Writer { final entities = updateMethods.map((method) => method.entity).toSet(); for (final entity in entities) { - final entityClassName = entity.classElement.displayName; + final entityClassName = entity.className; final fieldName = '_${entityClassName.decapitalize()}UpdateAdapter'; final type = refer('UpdateAdapter<$entityClassName>'); @@ -123,14 +125,13 @@ class DaoWriter extends Writer { classBuilder..fields.add(field); final valueMapper = - '(${entity.classElement.displayName} item) => ${entity.getValueMapping()}'; + '(${entity.className} item) => ${entity.getValueMapping()}'; - final requiresChangeListener = - dbHasViewStreams || streamEntities.contains(entity); + final requiresChangeListener = streamEntities.contains(entity.name); constructorBuilder ..initializers.add(Code( - "$fieldName = UpdateAdapter(database, '${entity.name}', ${entity.primaryKey.fields.map((field) => '\'${field.columnName}\'').toList()}, $valueMapper${requiresChangeListener ? ', changeListener' : ''})")); + "$fieldName = UpdateAdapter(database, ${entity.name.toLiteral()}, ${entity.primaryKey.fields.map((field) => field.columnName.toLiteral()).toList()}, $valueMapper${requiresChangeListener ? ', changeListener' : ''})")); } } @@ -139,7 +140,7 @@ class DaoWriter extends Writer { final entities = deleteMethods.map((method) => method.entity).toSet(); for (final entity in entities) { - final entityClassName = entity.classElement.displayName; + final entityClassName = entity.className; final fieldName = '_${entityClassName.decapitalize()}DeletionAdapter'; final type = refer('DeletionAdapter<$entityClassName>'); @@ -151,14 +152,13 @@ class DaoWriter extends Writer { classBuilder..fields.add(field); final valueMapper = - '(${entity.classElement.displayName} item) => ${entity.getValueMapping()}'; + '(${entity.className} item) => ${entity.getValueMapping()}'; - final requiresChangeListener = - dbHasViewStreams || streamEntities.contains(entity); + final requiresChangeListener = streamEntities.contains(entity.name); constructorBuilder ..initializers.add(Code( - "$fieldName = DeletionAdapter(database, '${entity.name}', ${entity.primaryKey.fields.map((field) => '\'${field.columnName}\'').toList()}, $valueMapper${requiresChangeListener ? ', changeListener' : ''})")); + "$fieldName = DeletionAdapter(database, ${entity.name.toLiteral()}, ${entity.primaryKey.fields.map((field) => field.columnName.toLiteral()).toList()}, $valueMapper${requiresChangeListener ? ', changeListener' : ''})")); } } diff --git a/floor_generator/lib/writer/database_writer.dart b/floor_generator/lib/writer/database_writer.dart index 0e3de2dc..51248fa3 100644 --- a/floor_generator/lib/writer/database_writer.dart +++ b/floor_generator/lib/writer/database_writer.dart @@ -4,6 +4,7 @@ import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/value_object/database.dart'; import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/writer/writer.dart'; +import 'package:floor_generator/misc/string_utils.dart'; /// Takes care of generating the database implementation. class DatabaseWriter implements Writer { @@ -77,16 +78,19 @@ class DatabaseWriter implements Writer { Method _generateOpenMethod(final Database database) { final createTableStatements = _generateCreateTableSqlStatements(database.entities) - .map((statement) => "await database.execute('$statement');") + .map((statement) => statement.toLiteral()) + .map((statement) => 'await database.execute($statement);') .join('\n'); final createIndexStatements = database.entities .map((entity) => entity.indices.map((index) => index.createQuery())) .expand((statements) => statements) - .map((statement) => "await database.execute('$statement');") + .map((statement) => statement.toLiteral()) + .map((statement) => 'await database.execute($statement);') .join('\n'); final createViewStatements = database.views .map((view) => view.getCreateViewStatement()) - .map((statement) => "await database.execute('''$statement''');") + .map((statement) => statement.toLiteral()) + .map((statement) => 'await database.execute($statement);') .join('\n'); final pathParameter = Parameter((builder) => builder diff --git a/floor_generator/lib/writer/deletion_method_writer.dart b/floor_generator/lib/writer/deletion_method_writer.dart index ab5c9cd4..127e78ac 100644 --- a/floor_generator/lib/writer/deletion_method_writer.dart +++ b/floor_generator/lib/writer/deletion_method_writer.dart @@ -26,8 +26,7 @@ class DeletionMethodWriter implements Writer { @nonNull String _generateMethodBody() { - final entityClassName = - _method.entity.classElement.displayName.decapitalize(); + final entityClassName = _method.entity.className.decapitalize(); final methodSignatureParameterName = _method.parameterElement.name; if (_method.flattenedReturnType.isVoid) { diff --git a/floor_generator/lib/writer/insertion_method_writer.dart b/floor_generator/lib/writer/insertion_method_writer.dart index 860c2982..65cfa922 100644 --- a/floor_generator/lib/writer/insertion_method_writer.dart +++ b/floor_generator/lib/writer/insertion_method_writer.dart @@ -26,8 +26,7 @@ class InsertionMethodWriter implements Writer { @nonNull String _generateMethodBody() { - final entityClassName = - _method.entity.classElement.displayName.decapitalize(); + final entityClassName = _method.entity.className.decapitalize(); final methodSignatureParameterName = _method.parameterElement.displayName; if (_method.flattenedReturnType.isVoid) { diff --git a/floor_generator/lib/writer/query_method_writer.dart b/floor_generator/lib/writer/query_method_writer.dart index 598bdb1b..67b72a28 100644 --- a/floor_generator/lib/writer/query_method_writer.dart +++ b/floor_generator/lib/writer/query_method_writer.dart @@ -2,11 +2,10 @@ import 'package:code_builder/code_builder.dart'; import 'package:floor_generator/misc/annotation_expression.dart'; import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/misc/string_utils.dart'; -import 'package:floor_generator/misc/type_utils.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; +import 'package:floor_generator/processor/queryable_processor.dart'; import 'package:floor_generator/value_object/query_method.dart'; -import 'package:floor_generator/value_object/view.dart'; import 'package:floor_generator/writer/writer.dart'; -import 'package:source_gen/source_gen.dart'; class QueryMethodWriter implements Writer { final QueryMethod _queryMethod; @@ -23,12 +22,12 @@ class QueryMethodWriter implements Writer { Method _generateQueryMethod() { final builder = MethodBuilder() ..annotations.add(overrideAnnotationExpression) - ..returns = refer(_queryMethod.rawReturnType.getDisplayString()) + ..returns = refer(_queryMethod.returnType.raw.getDisplayString()) ..name = _queryMethod.name ..requiredParameters.addAll(_generateMethodParameters()) ..body = Code(_generateMethodBody()); - if (!_queryMethod.returnsStream || _queryMethod.returnsVoid) { + if (_queryMethod.returnType.isFuture) { builder..modifier = MethodModifier.async; } @@ -36,76 +35,62 @@ class QueryMethodWriter implements Writer { } List _generateMethodParameters() { - return _queryMethod.parameters.map((parameter) { - if (!parameter.type.isSupported) { - throw InvalidGenerationSourceError( - 'The type of this parameter is not supported.', - element: parameter, - ); - } - - return Parameter((builder) => builder - ..name = parameter.name - ..type = refer(parameter.type.getDisplayString())); - }).toList(); + return _queryMethod.parameters + .map((parameter) => Parameter((builder) => builder + ..name = parameter.name + ..type = refer(parameter.type.getDisplayString()))) + .toList(); } + @nonNull String _generateMethodBody() { final _methodBody = StringBuffer(); - final valueLists = _generateInClauseValueLists(); - if (valueLists.isNotEmpty) { - _methodBody.write(valueLists.join('')); - } + // generate the variable definitions which will store the sqlite argument + // lists, e.g. '?5,?6,?7,?8'. These have to be generated for each call to + // the querymethod to accommodate for different list sizes. This is + // necessary to guarantee that each single value is inserted at the right + // place and only via SQLite's escape-mechanism. + // If no [List] parameters are present, Nothing will be written. + _methodBody.write(_generateListConvertersForQuery()); + // generate the common inputs for all queries final arguments = _generateArguments(); - if (_queryMethod.returnsVoid) { - _methodBody.write(_generateNoReturnQuery(arguments)); - return _methodBody.toString(); - } + final query = _generateQueryString(); - final mapper = '_${_queryMethod.queryable.name.decapitalize()}Mapper'; - if (_queryMethod.returnsStream) { - _methodBody.write(_generateStreamQuery(arguments, mapper)); + if (_queryMethod.returnType.isVoid) { + _methodBody.write(_generateNoReturnQuery(query, arguments)); } else { - _methodBody.write(_generateQuery(arguments, mapper)); + _methodBody.write(_generateQuery(query, arguments)); } return _methodBody.toString(); } @nonNull - List _generateInClauseValueLists() { - var index = 0; - return _queryMethod.parameters - .map((parameter) { - if (parameter.type.isDartCoreList) { - index++; - return '''final valueList$index = ${parameter.displayName}.map((value) => "'\$value'").join(', ');'''; - } else { - return null; - } - }) - .where((string) => string != null) - .toList(); - } + String _generateListConvertersForQuery() { + final start = _queryMethod.parameters + .where((param) => !param.type.isDartCoreList) + .length + + 1; + final code = StringBuffer(); + String lastParam; + for (final listParam in _queryMethod.parameters + .where((param) => param.type.isDartCoreList)) { + if (lastParam == null) { + code.write('int _start=$start;'); + } else { + code.write('_start+=$lastParam.length;'); + } + code.write( + 'final _sqliteVariablesFor${listParam.displayName.capitalize()}='); + code.write('Iterable.generate('); + code.write("${listParam.displayName}.length,(i)=>'?\${i+_start}'"); + code.write(").join(',');"); - @nonNull - List _generateParameters() { - return _queryMethod.parameters - .map((parameter) { - if (!parameter.type.isDartCoreList) { - if (parameter.type.isDartCoreBool) { - return '${parameter.displayName} == null ? null : (${parameter.displayName} ? 1 : 0)'; - } else { - return parameter.displayName; - } - } else { - return null; - } - }) - .where((string) => string != null) - .toList(); + lastParam = listParam.displayName; + } + return code.toString(); } @nullable @@ -115,46 +100,91 @@ class QueryMethodWriter implements Writer { } @nonNull - String _generateNoReturnQuery(@nullable final String arguments) { - final parameters = StringBuffer()..write("'${_queryMethod.query}'"); + List _generateParameters() { + //TODO Typeconverters + return [ + ..._queryMethod.parameters + .where((parameter) => !parameter.type.isDartCoreList) + .map((parameter) { + if (parameter.type.isDartCoreBool) { + return '${parameter.displayName} == null ? null : (${parameter.displayName} ? 1 : 0)'; + } else { + return parameter.displayName; + } + }), + ..._queryMethod.parameters + .where((parameter) => parameter.type.isDartCoreList) + .map((parameter) => '...${parameter.displayName}') + ]; + } + + /// Generates the Query string while accounting for the dynamically-inserted + /// list parameters (created as `_sqliteVariablesForX`). + @nonNull + String _generateQueryString() { + final code = StringBuffer(); + int start = 0; + final originalQuery = _queryMethod.query.sql; + for (final listParameter in _queryMethod.query.listParameters) { + code.write( + originalQuery.substring(start, listParameter.position).toLiteral()); + + code.write('+ _sqliteVariablesFor${listParameter.name.capitalize()} +'); + start = listParameter.position + varlistPlaceholder.length; + } + code.write(originalQuery.substring(start).toLiteral()); + + return code.toString(); + } + + @nonNull + String _generateNoReturnQuery( + @nonNull final String query, @nullable final String arguments) { + final affected = + _generateSetStringOrNull(_queryMethod.query.affectedEntities); + + final parameters = StringBuffer()..write(query); if (arguments != null) parameters.write(', arguments: $arguments'); + if (affected != null) parameters.write(', changedEntities: $affected'); + return 'await _queryAdapter.queryNoReturn($parameters);'; } @nonNull String _generateQuery( + @nonNull final String query, @nullable final String arguments, - @nonNull final String mapper, ) { - final parameters = StringBuffer()..write("'${_queryMethod.query}', "); - if (arguments != null) parameters.write('arguments: $arguments, '); - parameters.write('mapper: $mapper'); + final mapper = _generateMapper(); + final deps = _queryMethod.returnType.isStream + ? _generateSetStringOrNull(_queryMethod.query.dependencies) + : null; - if (_queryMethod.returnsList) { - return 'return _queryAdapter.queryList($parameters);'; - } else { - return 'return _queryAdapter.query($parameters);'; - } + final parameters = StringBuffer(query)..write(', mapper: $mapper'); + if (arguments != null) parameters.write(', arguments: $arguments'); + if (deps != null) parameters.write(', dependencies: $deps'); + + final list = _queryMethod.returnType.isList ? 'List' : ''; + final stream = _queryMethod.returnType.isStream ? 'Stream' : ''; + + return 'return _queryAdapter.query$list$stream($parameters);'; + } + + @nullable + String _generateSetStringOrNull(Iterable input) { + final iter = input.map((e) => e.toLiteral()); + return iter.isNotEmpty ? '{ ${iter.join(', ')} }' : null; } @nonNull - String _generateStreamQuery( - @nullable final String arguments, - @nonNull final String mapper, - ) { - final queryableName = _queryMethod.queryable.name; - final isView = _queryMethod.queryable is View; - final parameters = StringBuffer()..write("'${_queryMethod.query}', "); - if (arguments != null) parameters.write('arguments: $arguments, '); - parameters - ..write("queryableName: '$queryableName', ") - ..write('isView: $isView, ') - ..write('mapper: $mapper'); - - if (_queryMethod.returnsList) { - return 'return _queryAdapter.queryListStream($parameters);'; + String _generateMapper() { + if (_queryMethod.returnType.queryable == null) { + //TODO Typeconverters + final cast = QueryableProcessor.castParameterValue( + _queryMethod.returnType.flattened, 'row.values.first', true); + return '(Map row) => $cast'; } else { - return 'return _queryAdapter.queryStream($parameters);'; + return '_${_queryMethod.returnType.queryable.className.decapitalize()}Mapper'; } } } diff --git a/floor_generator/lib/writer/update_method_writer.dart b/floor_generator/lib/writer/update_method_writer.dart index ed86b95e..39400bff 100644 --- a/floor_generator/lib/writer/update_method_writer.dart +++ b/floor_generator/lib/writer/update_method_writer.dart @@ -26,8 +26,7 @@ class UpdateMethodWriter implements Writer { @nonNull String _generateMethodBody() { - final entityClassName = - _method.entity.classElement.displayName.decapitalize(); + final entityClassName = _method.entity.className.decapitalize(); final methodSignatureParameterName = _method.parameterElement.displayName; if (_method.flattenedReturnType.isVoid) { diff --git a/floor_generator/pubspec.yaml b/floor_generator/pubspec.yaml index 541c2d61..06dc34d3 100644 --- a/floor_generator/pubspec.yaml +++ b/floor_generator/pubspec.yaml @@ -10,15 +10,17 @@ environment: sdk: '>=2.6.0 <3.0.0' dependencies: - analyzer: ^0.39.4 + analyzer: 0.39.14 # pinned because of https://github.com/dart-lang/build/issues/2763 build: ^1.2.2 code_builder: ^3.2.1 meta: ^1.1.8 source_gen: ^0.9.4+7 build_config: ^0.4.1+1 collection: ^1.14.11 + strings: ^0.1.2 floor_annotation: path: ../floor_annotation/ + sqlparser: ^0.10.1 dev_dependencies: test: ^1.11.0 diff --git a/floor_generator/test/misc/string_utils_test.dart b/floor_generator/test/misc/string_utils_test.dart index 537bfa45..765bc325 100644 --- a/floor_generator/test/misc/string_utils_test.dart +++ b/floor_generator/test/misc/string_utils_test.dart @@ -7,4 +7,18 @@ void main() { expect(actual, 'fOO'); }); + + test('capitalize word (first letter to uppercase)', () { + expect('foo'.capitalize(), 'Foo'); + expect('FOO'.capitalize(), 'FOO'); + expect('00f'.capitalize(), '00f'); + }); + + test('convert string to string literal', () { + expect('foo'.toLiteral(), "'foo'"); + expect('"foo"'.toLiteral(), "'\\\"foo\\\"'"); + expect('\'foo\''.toLiteral(), "'\\'foo\\''"); + expect('fo\no'.toLiteral(), "'fo\\no'"); + expect(null.toLiteral(), 'null'); + }); } diff --git a/floor_generator/test/misc/type_utils_test.dart b/floor_generator/test/misc/type_utils_test.dart index 45594555..44a87735 100644 --- a/floor_generator/test/misc/type_utils_test.dart +++ b/floor_generator/test/misc/type_utils_test.dart @@ -1,6 +1,8 @@ import 'dart:typed_data'; +import 'package:floor_generator/misc/constants.dart'; import 'package:floor_generator/misc/type_utils.dart'; +import 'package:sqlparser/sqlparser.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; @@ -81,4 +83,12 @@ void main() { expect(actual.isDartCoreInt, isTrue); }); }); + group('sql type conversions', () { + test('floor into sqlparser', () async { + expect(sqlToBasicType[SqlType.integer], equals(BasicType.int)); + expect(sqlToBasicType[SqlType.blob], equals(BasicType.blob)); + expect(sqlToBasicType[SqlType.real], equals(BasicType.real)); + expect(sqlToBasicType[SqlType.text], equals(BasicType.text)); + }); + }); } diff --git a/floor_generator/test/processor/dao_processor_test.dart b/floor_generator/test/processor/dao_processor_test.dart index 43cd8419..ebbe127b 100644 --- a/floor_generator/test/processor/dao_processor_test.dart +++ b/floor_generator/test/processor/dao_processor_test.dart @@ -1,22 +1,26 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:build_test/build_test.dart'; -import 'package:floor_annotation/floor_annotation.dart' as annotations; -import 'package:floor_generator/misc/type_utils.dart'; import 'package:floor_generator/processor/dao_processor.dart'; -import 'package:floor_generator/processor/entity_processor.dart'; -import 'package:floor_generator/processor/view_processor.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; import 'package:floor_generator/value_object/dao.dart'; import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/view.dart'; import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; +import '../test_utils.dart'; + void main() { List entities; List views; + AnalyzerEngine engine; + + setUpAll(() async { + engine = AnalyzerEngine(); - setUpAll(() async => entities = await _getEntities()); - setUpAll(() async => views = await _getViews()); + entities = await getEntities(engine); + views = await getViews(engine); + }); test('Includes methods from abstract parent class', () async { final classElement = await _createDao(''' @@ -32,7 +36,7 @@ void main() { } '''); - final actual = DaoProcessor(classElement, '', '', entities, views) + final actual = DaoProcessor(classElement, '', '', entities, views, engine) .process() .methodsLength; @@ -59,7 +63,7 @@ void main() { } '''); - final actual = DaoProcessor(classElement, '', '', entities, views) + final actual = DaoProcessor(classElement, '', '', entities, views, engine) .process() .methodsLength; @@ -80,7 +84,7 @@ void main() { } '''); - final actual = DaoProcessor(classElement, '', '', entities, views) + final actual = DaoProcessor(classElement, '', '', entities, views, engine) .process() .methodsLength; @@ -101,7 +105,7 @@ void main() { } '''); - final actual = DaoProcessor(classElement, '', '', entities, views) + final actual = DaoProcessor(classElement, '', '', entities, views, engine) .process() .methodsLength; @@ -122,7 +126,7 @@ void main() { } '''); - final actual = DaoProcessor(classElement, '', '', entities, views) + final actual = DaoProcessor(classElement, '', '', entities, views, engine) .process() .methodsLength; @@ -150,11 +154,9 @@ void main() { '''); final processedDao = - DaoProcessor(classElement, '', '', entities, views).process(); + DaoProcessor(classElement, '', '', entities, views, engine).process(); expect(processedDao.methodsLength, equals(4)); - expect(processedDao.streamViews, equals(views)); - expect(processedDao.streamEntities, equals([])); }); } @@ -201,51 +203,3 @@ Future _createDao(final String dao) async { return library.classes.first; } - -Future> _getEntities() async { - final library = await resolveSource(''' - library test; - - import 'package:floor_annotation/floor_annotation.dart'; - - @entity - class Person { - @primaryKey - final int id; - - final String name; - - Person(this.id, this.name); - } - ''', (resolver) async { - return LibraryReader(await resolver.findLibraryByName('test')); - }); - - return library.classes - .where((classElement) => classElement.hasAnnotation(annotations.Entity)) - .map((classElement) => EntityProcessor(classElement).process()) - .toList(); -} - -Future> _getViews() async { - final library = await resolveSource(''' - library test; - - import 'package:floor_annotation/floor_annotation.dart'; - - @DatabaseView("SELECT name FROM Person") - class Name { - final String name; - - Person(this.name); - } - ''', (resolver) async { - return LibraryReader(await resolver.findLibraryByName('test')); - }); - - return library.classes - .where((classElement) => - classElement.hasAnnotation(annotations.DatabaseView)) - .map((classElement) => ViewProcessor(classElement).process()) - .toList(); -} diff --git a/floor_generator/test/processor/dependency_graph_test.dart b/floor_generator/test/processor/dependency_graph_test.dart new file mode 100644 index 00000000..643b8a06 --- /dev/null +++ b/floor_generator/test/processor/dependency_graph_test.dart @@ -0,0 +1,48 @@ +import 'package:floor_generator/processor/query_analyzer/dependency_graph.dart'; +import 'package:test/test.dart'; + +void main() { + DependencyGraph graph; + + setUp(() { + graph = DependencyGraph(); + }); + + test('empty graph', () { + expect(graph.indirectDependencies('foo'), {'foo'}); + expect(graph.indirectDependencies(null), {null}); + }); + + test('single dependency', () { + graph.add('foo', ['bar', 'baz']); + + expect(graph.indirectDependencies('foo'), {'foo', 'bar', 'baz'}); + expect(graph.indirectDependencies(null), {null}); + expect(graph.indirectDependencies('bar'), {'bar'}); + expect(graph.indirectDependencies('baz'), {'baz'}); + }); + + test('transitive dependency', () { + graph.add('foo', ['bar']); + graph.add('bar', ['baz']); + + expect(graph.indirectDependencies(null), {null}); + expect(graph.indirectDependencies('foo'), {'foo', 'bar', 'baz'}); + expect(graph.indirectDependencies('bar'), {'bar', 'baz'}); + expect(graph.indirectDependencies('baz'), {'baz'}); + }); + + test('transitive dependency with cycle', () { + graph.add('foo', ['bar', 'baz']); + graph.add('bar', ['baz']); + graph.add('baz', ['far']); + graph.add('far', ['bar']); + + expect(graph.indirectDependencies(null), {null}); + expect(graph.indirectDependencies('other'), {'other'}); + expect(graph.indirectDependencies('foo'), {'foo', 'bar', 'baz', 'far'}); + expect(graph.indirectDependencies('bar'), {'bar', 'baz', 'far'}); + expect(graph.indirectDependencies('far'), {'bar', 'baz', 'far'}); + expect(graph.indirectDependencies('baz'), {'bar', 'baz', 'far'}); + }); +} diff --git a/floor_generator/test/processor/entity_processor_test.dart b/floor_generator/test/processor/entity_processor_test.dart index 70786a26..13154b30 100644 --- a/floor_generator/test/processor/entity_processor_test.dart +++ b/floor_generator/test/processor/entity_processor_test.dart @@ -36,7 +36,7 @@ void main() { const indices = []; const constructor = "Person(row['id'] as int, row['name'] as String)"; final expected = Entity( - classElement, + classElement.displayName, name, fields, primaryKey, @@ -71,7 +71,7 @@ void main() { const indices = []; const constructor = "Person(row['id'] as int, row['name'] as String)"; final expected = Entity( - classElement, + classElement.displayName, name, fields, primaryKey, @@ -157,7 +157,7 @@ void main() { const indices = []; const constructor = "Person(row['id'] as int, row['name'] as String)"; final expected = Entity( - classElement, + classElement.displayName, name, fields, primaryKey, diff --git a/floor_generator/test/processor/query_method_processor_test.dart b/floor_generator/test/processor/query_method_processor_test.dart index 548636ba..306a04b2 100644 --- a/floor_generator/test/processor/query_method_processor_test.dart +++ b/floor_generator/test/processor/query_method_processor_test.dart @@ -1,34 +1,46 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:build_test/build_test.dart'; -import 'package:floor_annotation/floor_annotation.dart' as annotations; -import 'package:floor_generator/misc/type_utils.dart'; -import 'package:floor_generator/processor/entity_processor.dart'; import 'package:floor_generator/processor/error/query_method_processor_error.dart'; +import 'package:floor_generator/processor/error/type_checker_error.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; import 'package:floor_generator/processor/query_method_processor.dart'; -import 'package:floor_generator/processor/view_processor.dart'; +import 'package:floor_generator/processor/query_processor.dart'; import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/query_method.dart'; +import 'package:floor_generator/value_object/query_method_return_type.dart'; import 'package:floor_generator/value_object/view.dart'; import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; +// TODO update tests (list incomplete) +// already done: columnCountMismatch Error + void main() { List entities; List views; + AnalyzerEngine engine; + + setUpAll(() async { + engine = AnalyzerEngine(); - setUpAll(() async => entities = await _getEntities()); - setUpAll(() async => views = await _getViews()); + entities = await getEntities(engine); + views = await getViews(engine); + }); test('create query method', () async { + // has to exist or DartType(Person) != DartType(Person) because + // they are from different sources otherwise + const matchingSourceId = 1234; final methodElement = await _createQueryMethodElement(''' @Query('SELECT * FROM Person') Future> findAllPersons(); - '''); + ''', matchingSourceId); final actual = - QueryMethodProcessor(methodElement, [...entities, ...views]).process(); + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); expect( actual, @@ -36,24 +48,29 @@ void main() { QueryMethod( methodElement, 'findAllPersons', - 'SELECT * FROM Person', - await getDartTypeWithPerson('Future>'), - await getDartTypeWithPerson('Person'), + QueryProcessor(methodElement, 'SELECT * FROM Person', engine) + .process(), + QueryMethodReturnType(await getDartTypeWithPerson( + 'Future>', matchingSourceId)) + ..queryable = entities.first, [], - entities.first, ), ), ); }); test('create query method for a view', () async { + // has to exist or DartType(Name) != DartType(Name) because + // they are from different sources otherwise + const matchingSourceId = 1234; final methodElement = await _createQueryMethodElement(''' @Query('SELECT * FROM name') Future> findAllNames(); - '''); + ''', matchingSourceId); final actual = - QueryMethodProcessor(methodElement, [...entities, ...views]).process(); + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); expect( actual, @@ -61,155 +78,94 @@ void main() { QueryMethod( methodElement, 'findAllNames', - 'SELECT * FROM name', - await getDartTypeWithName('Future>'), - await getDartTypeWithName('Name'), + QueryProcessor(methodElement, 'SELECT * FROM name', engine).process(), + QueryMethodReturnType( + await getDartTypeWithName('Future>', matchingSourceId)) + ..queryable = views.first, [], - views.first, ), ), ); }); - - group('query parsing', () { - test('parse query', () async { + group('query parsing - dart syntax', () { + test('parse simple query', () async { final methodElement = await _createQueryMethodElement(''' @Query('SELECT * FROM Person WHERE id = :id') Future findPerson(int id); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process() + .query + .sql; - expect(actual, equals('SELECT * FROM Person WHERE id = ?')); + expect(actual, equals('SELECT * FROM Person WHERE id = ?1')); }); test('parse multiline query', () async { final methodElement = await _createQueryMethodElement(""" @Query(''' SELECT * FROM person - WHERE id = :id AND custom_name = :name + WHERE id = :id AND name = :name ''') Future findPersonByIdAndName(int id, String name); """); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process() + .query + .sql; expect( actual, - equals('SELECT * FROM person WHERE id = ? AND custom_name = ?'), + equals( + ' SELECT * FROM person\n WHERE id = ?1 AND name = ?2\n '), ); }); test('parse concatenated string query', () async { final methodElement = await _createQueryMethodElement(''' @Query('SELECT * FROM person ' - 'WHERE id = :id AND custom_name = :name') + 'WHERE id = :id AND name = :name') Future findPersonByIdAndName(int id, String name); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; - - expect( - actual, - equals('SELECT * FROM person WHERE id = ? AND custom_name = ?'), - ); - }); - - test('Parse IN clause', () async { - final methodElement = await _createQueryMethodElement(''' - @Query('update sports set rated = 1 where id in (:ids)') - Future setRated(List ids); - '''); - - final actual = QueryMethodProcessor(methodElement, []).process().query; - - expect( - actual, - equals(r'update sports set rated = 1 where id in ($valueList1)'), - ); - }); - - test('Parse query with multiple IN clauses', () async { - final methodElement = await _createQueryMethodElement(''' - @Query('update sports set rated = 1 where id in (:ids) and where foo in (:bar)') - Future setRated(List ids, List bar); - '''); - - final actual = QueryMethodProcessor(methodElement, []).process().query; - - expect( - actual, - equals( - r'update sports set rated = 1 where id in ($valueList1) ' - r'and where foo in ($valueList2)', - ), - ); - }); - - test('Parse query with IN clause and other parameter', () async { - final methodElement = await _createQueryMethodElement(''' - @Query('update sports set rated = 1 where id in (:ids) AND foo = :bar') - Future setRated(List ids, int bar); - '''); - - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process() + .query + .sql; expect( actual, - equals( - r'update sports set rated = 1 where id in ($valueList1) ' - r'AND foo = ?', - ), + equals('SELECT * FROM person WHERE id = ?1 AND name = ?2'), ); }); + }); - test('Parse query with LIKE operator', () async { - final methodElement = await _createQueryMethodElement(''' - @Query('SELECT * FROM Persons WHERE name LIKE :name') - Future> findPersonsWithNamesLike(String name); - '''); - - final actual = QueryMethodProcessor(methodElement, []).process().query; - - expect(actual, equals('SELECT * FROM Persons WHERE name LIKE ?')); - }); - - test('Parse query with commas', () async { + group('return type checking', () { + test('parse contains function', () async { final methodElement = await _createQueryMethodElement(''' - @Query('SELECT * FROM :table, :otherTable') - Future> findPersonsWithNamesLike(String table, String otherTable); - '''); + @Query('SELECT :needle IN (:haystack)') + Future contains(List haystack, String needle);'''); - final actual = QueryMethodProcessor(methodElement, []).process().query; - - expect(actual, equals('SELECT * FROM ?, ?')); + //should not throw errors + QueryMethodProcessor(methodElement, [], engine).process(); }); + //TODO more }); group('errors', () { - test('exception when method does not return future', () async { - final methodElement = await _createQueryMethodElement(''' - @Query('SELECT * FROM Person') - List findAllPersons(); - '''); - - final actual = () => - QueryMethodProcessor(methodElement, [...entities, ...views]) - .process(); - - final error = - QueryMethodProcessorError(methodElement).doesNotReturnFutureNorStream; - expect(actual, throwsInvalidGenerationSourceError(error)); - }); - test('exception when query is empty string', () async { final methodElement = await _createQueryMethodElement(''' - @Query('') - Future> findAllPersons(); - '''); + @Query('') + Future> findAllPersons(); + '''); final actual = () => - QueryMethodProcessor(methodElement, [...entities, ...views]) + QueryMethodProcessor(methodElement, [...entities, ...views], engine) .process(); final error = QueryMethodProcessorError(methodElement).noQueryDefined; @@ -218,55 +174,153 @@ void main() { test('exception when query is null', () async { final methodElement = await _createQueryMethodElement(''' - @Query() - Future> findAllPersons(); - '''); + @Query() + Future> findAllPersons(); + '''); final actual = () => - QueryMethodProcessor(methodElement, [...entities, ...views]) + QueryMethodProcessor(methodElement, [...entities, ...views], engine) .process(); final error = QueryMethodProcessorError(methodElement).noQueryDefined; expect(actual, throwsInvalidGenerationSourceError(error)); }); - test('exception when query arguments do not match method parameters', - () async { - final methodElement = await _createQueryMethodElement(''' - @Query('SELECT * FROM Person WHERE id = :id AND name = :name') - Future findPersonByIdAndName(int id); - '''); - - final actual = () => - QueryMethodProcessor(methodElement, [...entities, ...views]) - .process(); - - final error = QueryMethodProcessorError(methodElement) - .queryArgumentsAndMethodParametersDoNotMatch; - expect(actual, throwsInvalidGenerationSourceError(error)); - }); - - test('exception when query arguments do not match method parameters', - () async { - final methodElement = await _createQueryMethodElement(''' - @Query('SELECT * FROM Person WHERE id = :id') - Future findPersonByIdAndName(int id, String name); - '''); - - final actual = () => - QueryMethodProcessor(methodElement, [...entities, ...views]) - .process(); - - final error = QueryMethodProcessorError(methodElement) - .queryArgumentsAndMethodParametersDoNotMatch; - expect(actual, throwsInvalidGenerationSourceError(error)); + group('return type', () { + test('exception when method does not return future', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person') + List findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = QueryMethodProcessorError(methodElement) + .doesNotReturnFutureNorStream; + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + + test( + 'exception when method does not return primitive or Queryable or List', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person') + Future> findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = QueryMethodProcessorError(methodElement) + .doesNotReturnQueryableOrPrimitive; + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + + test( + 'exception when method does not return Future when returning void - Stream', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person') + Stream findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = + QueryMethodProcessorError(methodElement).voidReturnCannotBeStream; + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + test( + 'exception when method does not return Future when returning void - Stream', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person') + Stream> findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = + QueryMethodProcessorError(methodElement).voidReturnCannotBeList; + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + test( + 'exception when method does not return Future when returning void - Future', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person') + Future> findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = + QueryMethodProcessorError(methodElement).voidReturnCannotBeList; + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + + test( + 'exception when method does not return void on empty result - Queryable', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('DELETE FROM Person') + Future> findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = TypeCheckerError(methodElement).columnCountMismatch(2, 0); + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + + test( + 'exception when method does not return void on empty result - primitive return type', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('DELETE FROM Person') + Future> findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = TypeCheckerError(methodElement).columnCountShouldBeOne(0); + expect(actual, throwsInvalidGenerationSourceError(error)); + }); + + test( + 'exception when method does not return void on empty result - primitive return type', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('DELETE FROM Person') + Future> findAllPersons(); + '''); + + final actual = () => + QueryMethodProcessor(methodElement, [...entities, ...views], engine) + .process(); + + final error = TypeCheckerError(methodElement).columnCountShouldBeOne(0); + expect(actual, throwsInvalidGenerationSourceError(error)); + }); }); }); } -Future _createQueryMethodElement( - final String method, -) async { +Future _createQueryMethodElement(final String method, + [int id]) async { final library = await resolveSource(''' library test; @@ -274,7 +328,7 @@ Future _createQueryMethodElement( @dao abstract class PersonDao { - $method + $method } @entity @@ -295,55 +349,7 @@ Future _createQueryMethodElement( } ''', (resolver) async { return LibraryReader(await resolver.findLibraryByName('test')); - }); + }, inputId: createAssetId(id)); return library.classes.first.methods.first; } - -Future> _getEntities() async { - final library = await resolveSource(''' - library test; - - import 'package:floor_annotation/floor_annotation.dart'; - - @entity - class Person { - @primaryKey - final int id; - - final String name; - - Person(this.id, this.name); - } - ''', (resolver) async { - return LibraryReader(await resolver.findLibraryByName('test')); - }); - - return library.classes - .where((classElement) => classElement.hasAnnotation(annotations.Entity)) - .map((classElement) => EntityProcessor(classElement).process()) - .toList(); -} - -Future> _getViews() async { - final library = await resolveSource(''' - library test; - - import 'package:floor_annotation/floor_annotation.dart'; - - @DatabaseView("SELECT DISTINCT(name) AS name from person") - class Name { - final String name; - - Name(this.name); - } - ''', (resolver) async { - return LibraryReader(await resolver.findLibraryByName('test')); - }); - - return library.classes - .where((classElement) => - classElement.hasAnnotation(annotations.DatabaseView)) - .map((classElement) => ViewProcessor(classElement).process()) - .toList(); -} diff --git a/floor_generator/test/processor/query_processor_test.dart b/floor_generator/test/processor/query_processor_test.dart new file mode 100644 index 00000000..52690f86 --- /dev/null +++ b/floor_generator/test/processor/query_processor_test.dart @@ -0,0 +1,588 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:build_test/build_test.dart'; +import 'package:floor_generator/processor/error/query_processor_error.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; +import 'package:floor_generator/processor/query_processor.dart'; +import 'package:floor_generator/value_object/query.dart'; +import 'package:floor_generator/misc/type_utils.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart' hide View; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + AnalyzerEngine engine; + + setUpAll(() async { + engine = AnalyzerEngine(); + + await getEntities(engine); + + await getViews(engine); + }); + + test('create simple query object', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person') + Future> findAllPersons(); + '''); + + final actual = + QueryProcessor(methodElement, 'SELECT * FROM Person', engine).process(); + + expect( + actual, + equals(Query( + 'SELECT * FROM Person', + [], + [ + SqlResultColumn( + 'id', + const ResolveResult( + ResolvedType(type: BasicType.int, nullable: true))), + SqlResultColumn( + 'name', + const ResolveResult( + ResolvedType(type: BasicType.text, nullable: true))), + ], + {'Person'}, + {}, + )), + ); + }); + + test('create query object from insert', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('REPLACE INTO Person DEFAULT VALUES') + Future insertOrReplaceDefaultPerson(); + '''); + + final actual = QueryProcessor( + methodElement, 'REPLACE INTO Person DEFAULT VALUES', engine) + .process(); + + expect( + actual, + equals(Query( + 'REPLACE INTO Person DEFAULT VALUES', + [], + [], + {'Person'}, + {'Person'}, + )), + ); + }); + + test('create query object from delete', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('DELETE FROM Person WHERE id in (:ids)') + Future deletePersonWithIds(List ids); + '''); + + final actual = QueryProcessor( + methodElement, 'DELETE FROM Person WHERE id in (:ids)', engine) + .process(); + + expect( + actual, + equals(Query( + 'DELETE FROM Person WHERE id in (:varlist)', + [ListParameter(32, 'ids')], + [], + {'Person'}, + {'Person'}, + )), + ); + }); + + test('create complex query object from update', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('UPDATE Person SET name = :newName where id in (:ids) and name in (:bar)') + Future updateNames(List ids, List bar, String newName); + '''); + + final actual = QueryProcessor( + methodElement, + 'UPDATE Person SET name = :newName where id in (:ids) and name in (:bar)', + engine) + .process(); + + expect( + actual, + equals(Query( + 'UPDATE Person SET name = ?1 where id in (:varlist) and name in (:varlist)', + [ListParameter(41, 'ids'), ListParameter(64, 'bar')], + [], + {'Person'}, + {'Person'}, + )), + ); + }); + + test('create complex query object from select', () async { + final methodElement = await _createQueryMethodElement(''' + @Query("SELECT *, name='Jules', length(name), :arg1 as X FROM Name WHERE length(name) in (:lengths)") + Future findAllPersons(List lengths, Uint8List arg1); + '''); + + final actual = QueryProcessor( + methodElement, + 'SELECT *, name=\'Jules\', length(name), :arg1 as X FROM Name WHERE length(name) in (:lengths)', + engine) + .process(); + + expect( + actual, + equals(Query( + 'SELECT *, name=\'Jules\', length(name), ?1 as X FROM Name WHERE length(name) in (:varlist)', + [ListParameter(79, 'lengths')], + [ + SqlResultColumn( + 'name', + const ResolveResult( + ResolvedType(type: BasicType.text, nullable: true))), + SqlResultColumn( + 'name=\'Jules\'', + const ResolveResult(ResolvedType( + type: BasicType.int, nullable: false, hint: IsBoolean()))), + SqlResultColumn( + 'length(name)', + const ResolveResult( + ResolvedType(type: BasicType.int, nullable: false))), + SqlResultColumn( + 'X', + const ResolveResult( + ResolvedType(type: BasicType.blob, nullable: true))), + ], + {'Person'}, + {}, + )), + ); + }); + + test('create query object without dependencies', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT :needle IN (:haystack)') + Future contains(List haystack, String needle); + '''); + + final actual = + QueryProcessor(methodElement, 'SELECT :needle IN (:haystack)', engine) + .process(); + + expect( + actual, + equals(Query( + 'SELECT ?1 IN (:varlist)', + [ListParameter(14, 'haystack')], + [ + SqlResultColumn( + ':needle IN (:haystack)', + const ResolveResult(ResolvedType( + type: BasicType.int, nullable: false, hint: IsBoolean()))), + ], + {}, + {}, + )), + ); + }); + + group('parameter parsing', () { + test('Parse query with IN clause', () async { + final methodElement = await _createQueryMethodElement(''' + @Query("update Person set name = '1' where id in (:ids)") + Future setRated(List ids); + '''); + + final actual = QueryProcessor(methodElement, + "update Person set name = '1' where id in (:ids)", engine) + .process(); + + expect( + actual.listParameters, + equals([ListParameter(42, 'ids')]), + ); + expect(actual.sql, + equals("update Person set name = '1' where id in (:varlist)")); + }); + + test('Parse query with multiple IN clauses', () async { + final methodElement = await _createQueryMethodElement(''' + @Query("update Person set name = '1' where id in (:ids) and name in (:bar)") + Future setRated(List ids, List bar); + '''); + + final actual = QueryProcessor( + methodElement, + "update Person set name = '1' where id in (:ids) and name in (:bar)", + engine) + .process(); + + expect( + actual.sql, + equals( + "update Person set name = '1' where id in (:varlist) " + 'and name in (:varlist)', + ), + ); + expect( + actual.listParameters, + equals([ListParameter(42, 'ids'), ListParameter(65, 'bar')]), + ); + }); + + test('Parse query with IN clause and other parameter', () async { + final methodElement = await _createQueryMethodElement(''' + @Query("update Person set name = '1' where id in (:ids) AND name = :bar") + Future setRated(List ids, int bar); + '''); + + final actual = QueryProcessor( + methodElement, + "update Person set name = '1' where id in (:ids) AND name = :bar", + engine) + .process(); + + expect( + actual.sql, + equals( + "update Person set name = '1' where id in (:varlist) AND name = ?1", + ), + ); + expect( + actual.listParameters, + equals([ListParameter(42, 'ids')]), + ); + }); + + test('Parse query with LIKE operator', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE name LIKE :name') + Future> findPersonsWithNamesLike(String name); + '''); + + final actual = QueryProcessor(methodElement, + 'SELECT * FROM Person WHERE name LIKE :name', engine) + .process() + .sql; + + expect(actual, equals('SELECT * FROM Person WHERE name LIKE ?1')); + }); + + test('Parse query with commas', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT :table, :otherTable') + Future findPersonsWithNamesLike(String table, String otherTable); + '''); + + final actual = + QueryProcessor(methodElement, 'SELECT :table, :otherTable', engine) + .process() + .sql; + + expect(actual, equals('SELECT ?1, ?2')); + }); + + test('Do not parse parameters in string literals', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT :table, :otherTable, \':variable and ?4 \'') + Future findPersonsWithNamesLike(String table, String otherTable); + '''); + + final actual = QueryProcessor(methodElement, + 'SELECT :table, :otherTable, \':variable and ?4 \'', engine) + .process() + .sql; + + expect(actual, equals('SELECT ?1, ?2, \':variable and ?4 \'')); + }); + + test('Parse query with multiple parameters', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT :table, :otherTable, :otherTable, :table') + Future findPersonsWithNamesLike(String table, String otherTable); + '''); + + final actual = QueryProcessor(methodElement, + 'SELECT :table, :otherTable, :otherTable, :table', engine) + .process(); + + expect(actual.sql, equals('SELECT ?1, ?2, ?2, ?1')); + expect(actual.listParameters, equals([])); + }); + + test('Parse complex query with multiple parameters', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT :otherTable, (:list2), :otherTable, (:list1), (:list2), :otherTable, :table, (:list1)') + Future findPersonsWithNamesLike(String table, String otherTable, List list1, List list2); + '''); + + final actual = QueryProcessor( + methodElement, + 'SELECT :otherTable, (:list2), :otherTable, (:list1), (:list2), :otherTable, :table, (:list1)', + engine) + .process(); + + expect( + actual.sql, + equals( + 'SELECT ?2, (:varlist), ?2, (:varlist), (:varlist), ?2, ?1, (:varlist)')); + expect( + actual.listParameters, + equals([ + ListParameter(12, 'list2'), + ListParameter(28, 'list1'), + ListParameter(40, 'list2'), + ListParameter(60, 'list1'), + ])); + }); + }); + + group('errors', () { + test('normal parser exception when query string is malformed', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('FROM Person SELECT 1') + Future> findAllPersons(); + '''); + + final actual = () => + QueryProcessor(methodElement, 'FROM Person SELECT 1', engine) + .process(); + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError('The query contained parser errors:', + element: methodElement))); + }); + + test('parser exception when query string has more than one query', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT 1;SELECT 2') + Future> findAllPersons(); + '''); + + final actual = () => + QueryProcessor(methodElement, 'SELECT 1;SELECT 2', engine).process(); + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError('The query contained parser errors:', + element: methodElement))); + }); + + test('analyzer exception when query string contains unknown entity', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT 1 FROM UnknownTable') + Future> findAllPersons(); + '''); + + final actual = () => + QueryProcessor(methodElement, 'SELECT 1 FROM UnknownTable', engine) + .process(); + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The query contained analyzer errors:', + element: methodElement))); + }); + + test('analyzer exception when query string references unknown column', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT unknownColumn FROM Person') + Future> findAllPersons(); + '''); + + final actual = () => QueryProcessor( + methodElement, 'SELECT unknownColumn FROM Person', engine) + .process(); + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The query contained analyzer errors:', + element: methodElement))); + }); + + group('parameters', () { + test('exception when method parameters have an unsupported type', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE id = :person') + Future findById(Person person); + '''); + + final actual = () => QueryProcessor(methodElement, + 'SELECT * FROM Person WHERE id = :person', engine) + .process(); + final parameterElement = methodElement.parameters.first; + expect( + actual, + throwsInvalidGenerationSourceError( + QueryProcessorError(methodElement).unsupportedParameterType( + parameterElement, parameterElement.type))); + }); + + test( + 'exception when method parameters have an unsupported type wrapped in a List', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE id = :person') + Future findById(List person); + '''); + + final actual = () => QueryProcessor(methodElement, + 'SELECT * FROM Person WHERE id = :person', engine) + .process(); + final parameterElement = methodElement.parameters.first; + expect( + actual, + throwsInvalidGenerationSourceError( + QueryProcessorError(methodElement).unsupportedParameterType( + parameterElement, parameterElement.type.flatten()))); + }); + + test('exception when query arguments do not match method parameters', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE id = :id AND name = :name') + Future findPersonByIdAndName(int id); + '''); + + final actual = () => QueryProcessor(methodElement, + 'SELECT * FROM Person WHERE id = :id AND name = :name', engine) + .process(); + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The named variable in the statement of the `@Query` annotation should exist in the method parameters.', + todo: + 'Please add a method parameter for the variable `:name` with the name `name`.', + element: methodElement))); + }); + + test('exception when query arguments do not match method parameters', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE id = :id') + Future findPersonByIdAndName(int id, String name); + '''); + + final actual = () => QueryProcessor( + methodElement, 'SELECT * FROM Person WHERE id = :id', engine) + .process(); + expect( + actual, + throwsInvalidGenerationSourceError( + QueryProcessorError(methodElement) + .methodParameterMissingInQuery( + methodElement.parameters.skip(1).first))); + }); + + test('exception when query has numbered variables', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE id = ?1') + Future findPersonByIdAndName(int id, String name); + '''); + + final actual = () => QueryProcessor( + methodElement, 'SELECT * FROM Person WHERE id = ?1', engine) + .process(); + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'Statements used in floor should only have named parameters with colons.', + todo: + 'Please use a named variable (`:name`) instead of numbered variables (`?` or `?3`).', + element: methodElement))); + }); + + test('exception when query has list parameters without parentheses', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Person WHERE id = :id AND name IN :names') + Future findPersonByIdAndName(int id, List names); + '''); + + final actual = () => QueryProcessor( + methodElement, + 'SELECT * FROM Person WHERE id = :id AND name IN :names', + engine) + .process(); + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The named variable `:names` referencing a list parameter should be enclosed by parentheses.', + todo: 'Please replace `:names` with `(:names)`', + element: methodElement))); + }); + + test('Do not parse parameters if it is not an expression', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT :table, :otherTable, `:variable`.id FROM Person AS :variable') + Future findPersonsWithNamesLike(String table, String otherTable); + '''); + + final actual = () => QueryProcessor( + methodElement, + 'SELECT :table, :otherTable, `:variable`.id FROM Person AS :variable', + engine) + .process(); + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The query contained parser errors: line 1, column 59: Error: Expected an identifier', + element: methodElement))); + }); + }); + }); +} + +Future _createQueryMethodElement( + final String method, +) async { + final library = await resolveSource(''' + library test; + + import 'dart:typed_data'; + import 'package:floor_annotation/floor_annotation.dart'; + + @dao + abstract class PersonDao { + $method + } + + @entity + class Person { + @primaryKey + final int id; + + final String name; + + Person(this.id, this.name); + } + + @DatabaseView("SELECT DISTINCT(name) AS name from person") + class Name { + final String name; + + Name(this.name); + } + ''', (resolver) async { + return LibraryReader(await resolver.findLibraryByName('test')); + }); + + return library.classes.first.methods.first; +} diff --git a/floor_generator/test/processor/queryable_processor_test.dart b/floor_generator/test/processor/queryable_processor_test.dart index cf24657a..3adffa8b 100644 --- a/floor_generator/test/processor/queryable_processor_test.dart +++ b/floor_generator/test/processor/queryable_processor_test.dart @@ -28,7 +28,7 @@ void main() { .toList(); const constructor = "Person(row['id'] as int, row['name'] as String)"; final expected = TestQueryable( - classElement, + classElement.displayName, fields, constructor, ); @@ -424,27 +424,27 @@ void main() { class TestQueryable extends Queryable { TestQueryable( - ClassElement classElement, + String className, List fields, String constructor, - ) : super(classElement, '', fields, constructor); + ) : super(className, '', fields, constructor); @override bool operator ==(Object other) => identical(this, other) || other is TestQueryable && runtimeType == other.runtimeType && - classElement == other.classElement && + className == other.className && const ListEquality().equals(fields, other.fields) && constructor == other.constructor; @override int get hashCode => - classElement.hashCode ^ fields.hashCode ^ constructor.hashCode; + className.hashCode ^ fields.hashCode ^ constructor.hashCode; @override String toString() { - return 'TestQueryable{classElement: $classElement, name: $name, fields: $fields, constructor: $constructor}'; + return 'TestQueryable{className: $className, name: $name, fields: $fields, constructor: $constructor}'; } } @@ -455,7 +455,7 @@ class TestProcessor extends QueryableProcessor { TestQueryable process() { final fields = getFields(); return TestQueryable( - classElement, + classElement.displayName, fields, getConstructor(fields), ); diff --git a/floor_generator/test/processor/view_processor_test.dart b/floor_generator/test/processor/view_processor_test.dart index f3ba51e6..07cd8a0b 100644 --- a/floor_generator/test/processor/view_processor_test.dart +++ b/floor_generator/test/processor/view_processor_test.dart @@ -1,6 +1,11 @@ +import 'package:floor_generator/processor/error/type_checker_error.dart'; +import 'package:floor_generator/processor/error/view_processor_error.dart'; import 'package:floor_generator/processor/field_processor.dart'; import 'package:floor_generator/processor/view_processor.dart'; +import 'package:floor_generator/value_object/field.dart'; import 'package:floor_generator/value_object/view.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:sqlparser/sqlparser.dart' show BasicType; import 'package:test/test.dart'; import '../test_utils.dart'; @@ -8,26 +13,28 @@ import '../test_utils.dart'; void main() { test('Process view', () async { final classElement = await createClassElement(''' - @DatabaseView("SELECT * from otherentity") - class Person { + @DatabaseView('SELECT * from Person') + class PersonView { final int id; final String name; - Person(this.id, this.name); + PersonView(this.id, this.name); } '''); - final actual = ViewProcessor(classElement).process(); + final actual = + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); - const name = 'Person'; + const name = 'PersonView'; final fields = classElement.fields .map((fieldElement) => FieldProcessor(fieldElement).process()) .toList(); - const query = 'SELECT * from otherentity'; - const constructor = "Person(row['id'] as int, row['name'] as String)"; + const query = 'SELECT * from Person'; + const constructor = "PersonView(row['id'] as int, row['name'] as String)"; final expected = View( - classElement, + classElement.displayName, name, fields, query, @@ -36,12 +43,12 @@ void main() { expect(actual, equals(expected)); }); - test('Process view with mutliline query', () async { + test('Process view with multiline query', () async { final classElement = await createClassElement(""" @DatabaseView(''' SELECT * - from otherentity - ''') + from Person + ''', viewName:'personview') class Person { final int id; @@ -51,11 +58,14 @@ void main() { } """); - final actual = ViewProcessor(classElement).process().query; + final actual = + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process() + .query; const expected = ''' SELECT * - from otherentity + from Person '''; expect(actual, equals(expected)); }); @@ -63,7 +73,7 @@ void main() { test('Process view with concatenated string query', () async { final classElement = await createClassElement(''' @DatabaseView('SELECT * ' - 'from otherentity') + 'from Person', viewName: 'personview') class Person { final int id; @@ -73,15 +83,18 @@ void main() { } '''); - final actual = ViewProcessor(classElement).process().query; + final actual = + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process() + .query; - const expected = 'SELECT * from otherentity'; + const expected = 'SELECT * from Person'; expect(actual, equals(expected)); }); test('Process view with dedicated name', () async { final classElement = await createClassElement(''' - @DatabaseView("SELECT * from otherentity",viewName: "personview") + @DatabaseView("SELECT * from Person", viewName: "personview") class Person { final int id; @@ -91,16 +104,18 @@ void main() { } '''); - final actual = ViewProcessor(classElement).process(); + final actual = + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); const name = 'personview'; final fields = classElement.fields .map((fieldElement) => FieldProcessor(fieldElement).process()) .toList(); - const query = 'SELECT * from otherentity'; + const query = 'SELECT * from Person'; const constructor = "Person(row['id'] as int, row['name'] as String)"; final expected = View( - classElement, + classElement.displayName, name, fields, query, @@ -108,4 +123,236 @@ void main() { ); expect(actual, equals(expected)); }); + + group('Expecting errors:', () { + test('Wrong syntax in annotation', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT *, (wrong_column from Person)', viewName: 'personview') + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The following error occurred while parsing the SQL-Statement in ', + element: classElement))); + }); + + test('Not a SELECT statement', () async { + final classElement = await createClassElement(''' + @DatabaseView('DELETE FROM Person', viewName: 'personview') + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceError( + ViewProcessorError(classElement).missingSelectQuery)); + }); + + test('Wrong column reference in annotation', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT *, wrong_column from Person', viewName: 'personview') + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The following error occurred while analyzing the SQL-Statement in ', + element: classElement))); + }); + + test('Using :variables in annotation', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT *, :var from Person', viewName: 'personview') + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The query should not contain any variable references\n', + todo: 'Remove all variables by altering the query.', + element: classElement))); + }); + + test('Using ?variables in annotation', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT *, ?2 from Person', viewName: 'personview') + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The query should not contain any variable references\n', + todo: 'Remove all variables by altering the query.', + element: classElement))); + }); + + test('Column mismatch', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT NULL from Person', viewName: 'personview') + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceErrorWithMessagePrefix( + InvalidGenerationSourceError( + 'The following error occurred while comparing the DatabaseView to the SQL-Statement in ', + todo: '', + element: classElement))); + }); + + test('Column type mismatch: null to non-nullable', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT NULL as id, name from Person', viewName: 'personview') + class Person { + + @ColumnInfo(nullable:false) + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceError(TypeCheckerError(classElement) + .nullableMismatch(Field( + classElement.fields[0], 'id', 'id', false, 'INTEGER')))); + }); + + test('Column type mismatch: int to String', () async { + //field id from Person Entity is of type int + final classElement = await createClassElement(''' + @DatabaseView('SELECT id, name from Person', viewName: 'personview') + class Person { + + final String id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceError(TypeCheckerError(classElement) + .typeMismatch( + Field(classElement.fields[0], 'id', 'id', true, 'TEXT'), + BasicType.int))); + }); + + test('Column type mismatch: nullable to non-nullable', () async { + final classElement = await createClassElement(''' + @DatabaseView('SELECT nullif(id,1) as id, name from Person', viewName: 'personview') + class Person { + + @ColumnInfo(nullable:false) + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = () async { + ViewProcessor(classElement, await getEngineWithPersonEntity()) + .process(); + }; + + expect( + actual, + throwsInvalidGenerationSourceError(TypeCheckerError(classElement) + .nullableMismatch2(Field( + classElement.fields[0], 'id', 'id', false, 'INTEGER')))); + }); + }); } diff --git a/floor_generator/test/test_utils.dart b/floor_generator/test/test_utils.dart index 7131e6d6..5bff1ce9 100644 --- a/floor_generator/test/test_utils.dart +++ b/floor_generator/test/test_utils.dart @@ -10,9 +10,11 @@ import 'package:floor_annotation/floor_annotation.dart' as annotations; import 'package:floor_generator/misc/type_utils.dart'; import 'package:floor_generator/processor/dao_processor.dart'; import 'package:floor_generator/processor/entity_processor.dart'; +import 'package:floor_generator/processor/query_analyzer/engine.dart'; import 'package:floor_generator/processor/view_processor.dart'; import 'package:floor_generator/value_object/dao.dart'; import 'package:floor_generator/value_object/entity.dart'; +import 'package:floor_generator/value_object/view.dart'; import 'package:path/path.dart' as path; import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; @@ -41,14 +43,14 @@ Future getDartType(final dynamic value) async { return resolveSource(source, (item) async { final libraryReader = LibraryReader(await item.findLibraryByName('test')); return (libraryReader.allElements.elementAt(1) as VariableElement).type; - }); + }, inputId: createAssetId()); } Future getDartTypeFromString(final String value) { return getDartType(value); } -Future getDartTypeWithPerson(String value) async { +Future getDartTypeWithPerson(String value, [int id]) async { final source = ''' library test; @@ -71,10 +73,10 @@ Future getDartTypeWithPerson(String value) async { return (libraryReader.allElements.first as PropertyAccessorElement) .type .returnType; - }); + }, inputId: createAssetId(id)); } -Future getDartTypeWithName(String value) async { +Future getDartTypeWithName(String value, [int id]) async { final source = ''' library test; @@ -94,7 +96,7 @@ Future getDartTypeWithName(String value) async { return (libraryReader.allElements.first as PropertyAccessorElement) .type .returnType; - }); + }, inputId: createAssetId(id)); } final _dartfmt = DartFormatter(); @@ -121,41 +123,62 @@ Matcher throwsInvalidGenerationSourceError( ); } -Future createDao(final String methodSignature) async { +Matcher throwsInvalidGenerationSourceErrorWithMessagePrefix( + final InvalidGenerationSourceError error, +) { + return throwsA( + const TypeMatcher() + .having((e) => e.message, 'message', startsWith(error.message)) + .having((e) => e.todo, 'todo', error.todo) + .having((e) => e.element, 'element', error.element), + ); +} + +Future createDao(final String dao) async { final library = await resolveSource(''' library test; import 'package:floor_annotation/floor_annotation.dart'; - @dao - abstract class PersonDao { - $methodSignature - } + $dao $_personEntity $_nameView ''', (resolver) async { return LibraryReader(await resolver.findLibraryByName('test')); - }); + }, inputId: createAssetId()); final daoClass = library.classes.firstWhere((classElement) => classElement.hasAnnotation(annotations.dao.runtimeType)); + final engine = AnalyzerEngine(); + final entities = library.classes .where((classElement) => classElement.hasAnnotation(annotations.Entity)) .map((classElement) => EntityProcessor(classElement).process()) .toList(); + entities.forEach(engine.registerEntity); final views = library.classes .where((classElement) => classElement.hasAnnotation(annotations.DatabaseView)) - .map((classElement) => ViewProcessor(classElement).process()) + .map((classElement) => ViewProcessor(classElement, engine).process()) .toList(); - return DaoProcessor(daoClass, 'personDao', 'TestDatabase', entities, views) + return DaoProcessor( + daoClass, 'personDao', 'TestDatabase', entities, views, engine) .process(); } +Future createDaoMethod(final String methodSignature) async { + return createDao(''' + @dao + abstract class PersonDao { + $methodSignature + } + '''); +} + Future createClassElement(final String clazz) async { final library = await resolveSource(''' library test; @@ -165,7 +188,7 @@ Future createClassElement(final String clazz) async { $clazz ''', (resolver) async { return LibraryReader(await resolver.findLibraryByName('test')); - }); + }, inputId: createAssetId()); return library.classes.first; } @@ -179,7 +202,7 @@ Future getPersonEntity() async { $_personEntity ''', (resolver) async { return LibraryReader(await resolver.findLibraryByName('test')); - }); + }, inputId: createAssetId()); return library.classes .where((classElement) => classElement.hasAnnotation(annotations.Entity)) @@ -202,12 +225,59 @@ extension StringExtension on String { $_personEntity ''', (resolver) async { return LibraryReader(await resolver.findLibraryByName('test')); - }); + }, inputId: createAssetId()); return library.classes.first.methods.first; } } +Future getEngineWithPersonEntity() async { + final engine = AnalyzerEngine(); + engine.registerEntity(await getPersonEntity()); + return engine; +} + +Future> getEntities([AnalyzerEngine engine]) async { + final library = await resolveSource(''' + library test; + + import 'package:floor_annotation/floor_annotation.dart'; + + $_personEntity + ''', (resolver) async { + return LibraryReader(await resolver.findLibraryByName('test')); + }, inputId: createAssetId()); + + final entities = library.classes + .where((classElement) => classElement.hasAnnotation(annotations.Entity)) + .map((classElement) => EntityProcessor(classElement).process()) + .toList(); + if (engine != null) { + entities.forEach(engine.registerEntity); + } + return entities; +} + +Future> getViews([AnalyzerEngine engine]) async { + final library = await resolveSource(''' + library test; + + import 'package:floor_annotation/floor_annotation.dart'; + + $_nameView + ''', (resolver) async { + return LibraryReader(await resolver.findLibraryByName('test')); + }, inputId: createAssetId()); + + engine ??= await getEngineWithPersonEntity(); + + return library.classes + .where((classElement) => + classElement.hasAnnotation(annotations.DatabaseView)) + .map((classElement) => ViewProcessor(classElement, engine).process()) + .toList(); +} + const _personEntity = ''' @entity class Person { @@ -221,7 +291,7 @@ const _personEntity = ''' '''; const _nameView = ''' - @DatabaseView("SELECT name FROM Person") + @DatabaseView("SELECT DISTINCT name FROM Person") class Name { final String name; @@ -229,3 +299,13 @@ const _nameView = ''' } '''; + +/// give each created source a unique id to avoid messing up span +/// calculation for error tests. This does not have to be thread-safe as tests +/// are usually executed single-threaded and the ids only have to be different +/// for multiple assets in the same tests. +int _id = 0; +AssetId createAssetId([int id]) { + id ??= _id++; + return AssetId('_resolve_source', 'lib/_resolve_source$id.dart'); +} diff --git a/floor_generator/test/value_object/entity_test.dart b/floor_generator/test/value_object/entity_test.dart index be008e08..7dcf1b62 100644 --- a/floor_generator/test/value_object/entity_test.dart +++ b/floor_generator/test/value_object/entity_test.dart @@ -9,7 +9,7 @@ import 'package:test/test.dart'; import '../mocks.dart'; void main() { - final mockClassElement = MockClassElement(); + const mockClassName = 'SomeClassName'; final mockFieldElement = MockFieldElement(); final mockDartType = MockDartType(); @@ -30,10 +30,8 @@ void main() { final allFields = [field, nullableField]; tearDown(() { - clearInteractions(mockClassElement); clearInteractions(mockFieldElement); clearInteractions(mockDartType); - reset(mockClassElement); reset(mockFieldElement); reset(mockDartType); }); @@ -42,7 +40,7 @@ void main() { test('Create table statement with single primary key auto increment', () { final primaryKey = PrimaryKey([field], true); final entity = Entity( - mockClassElement, + mockClassName, 'entityName', allFields, primaryKey, @@ -64,7 +62,7 @@ void main() { test('Create table statement with single primary key', () { final primaryKey = PrimaryKey([field], false); final entity = Entity( - mockClassElement, + mockClassName, 'entityName', allFields, primaryKey, @@ -87,7 +85,7 @@ void main() { test('Create table statement with compound primary key', () { final primaryKey = PrimaryKey(allFields, false); final entity = Entity( - mockClassElement, + mockClassName, 'entityName', allFields, primaryKey, @@ -119,7 +117,7 @@ void main() { ); final primaryKey = PrimaryKey([nullableField], true); final entity = Entity( - mockClassElement, + mockClassName, 'entityName', [nullableField], primaryKey, @@ -146,7 +144,7 @@ void main() { test('Create table statement with "WITHOUT ROWID"', () { final primaryKey = PrimaryKey([field], false); final entity = Entity( - mockClassElement, + mockClassName, 'entityName', allFields, primaryKey, @@ -169,7 +167,7 @@ void main() { group('Value mapping', () { final primaryKey = PrimaryKey([nullableField], true); final entity = Entity( - mockClassElement, + mockClassName, 'entityName', [nullableField], primaryKey, @@ -209,7 +207,7 @@ void main() { test('Get non-nullable boolean value mapping', () { final entity = Entity( - mockClassElement, + mockClassName, 'entityName', [nullableField, field], primaryKey, diff --git a/floor_generator/test/value_object/view_test.dart b/floor_generator/test/value_object/view_test.dart index ad75ec0a..9feae75c 100644 --- a/floor_generator/test/value_object/view_test.dart +++ b/floor_generator/test/value_object/view_test.dart @@ -7,7 +7,7 @@ import 'package:test/test.dart'; import '../mocks.dart'; void main() { - final mockClassElement = MockClassElement(); + const mockClassName = 'SomeClassName'; final mockFieldElement = MockFieldElement(); final mockDartType = MockDartType(); @@ -28,17 +28,15 @@ void main() { final allFields = [field, nullableField]; tearDown(() { - clearInteractions(mockClassElement); clearInteractions(mockFieldElement); clearInteractions(mockDartType); - reset(mockClassElement); reset(mockFieldElement); reset(mockDartType); }); test('Create view statement with simple query', () { final view = View( - mockClassElement, + mockClassName, 'entityName', allFields, 'SELECT * FROM x', diff --git a/floor_generator/test/writer/dao_writer_test.dart b/floor_generator/test/writer/dao_writer_test.dart index bf5a0a08..53e0d2f2 100644 --- a/floor_generator/test/writer/dao_writer_test.dart +++ b/floor_generator/test/writer/dao_writer_test.dart @@ -1,14 +1,6 @@ -import 'package:build_test/build_test.dart'; import 'package:code_builder/code_builder.dart'; -import 'package:floor_annotation/floor_annotation.dart' as annotations; -import 'package:floor_generator/misc/type_utils.dart'; -import 'package:floor_generator/processor/dao_processor.dart'; -import 'package:floor_generator/processor/entity_processor.dart'; -import 'package:floor_generator/processor/view_processor.dart'; -import 'package:floor_generator/value_object/dao.dart'; -import 'package:floor_generator/value_object/entity.dart'; + import 'package:floor_generator/writer/dao_writer.dart'; -import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; @@ -17,7 +9,7 @@ void main() { useDartfmt(); test('create DAO no stream query', () async { - final dao = await _createDao(''' + final dao = await createDao(''' @dao abstract class PersonDao { @Query('SELECT * FROM person') @@ -34,9 +26,7 @@ void main() { } '''); - final actual = - DaoWriter(dao, dao.streamEntities.toSet(), dao.streamViews.isNotEmpty) - .write(); + final actual = DaoWriter(dao, {}).write(); expect(actual, equalsDart(r''' class _$PersonDao extends PersonDao { @@ -99,7 +89,7 @@ void main() { }); test('create DAO stream query', () async { - final dao = await _createDao(''' + final dao = await createDao(''' @dao abstract class PersonDao { @Query('SELECT * FROM person') @@ -116,9 +106,7 @@ void main() { } '''); - final actual = - DaoWriter(dao, dao.streamEntities.toSet(), dao.streamViews.isNotEmpty) - .write(); + final actual = DaoWriter(dao, {'Person'}).write(); expect(actual, equalsDart(r''' class _$PersonDao extends PersonDao { @@ -162,7 +150,7 @@ void main() { @override Stream> findAllPersonsAsStream() { - return _queryAdapter.queryListStream('SELECT * FROM person', queryableName: 'Person', isView: false, mapper: _personMapper); + return _queryAdapter.queryListStream('SELECT * FROM person', mapper: _personMapper, dependencies: {'Person'}); } @override @@ -184,7 +172,7 @@ void main() { }); test('create DAO aware of other entity stream query', () async { - final dao = await _createDao(''' + final dao = await createDao(''' @dao abstract class PersonDao { @insert @@ -198,8 +186,7 @@ void main() { } '''); // simulate DB is aware of streamed Person and no View - final actual = - DaoWriter(dao, {dao.deletionMethods[0].entity}, false).write(); + final actual = DaoWriter(dao, {'Person'}).write(); expect(actual, equalsDart(r''' class _$PersonDao extends PersonDao { @@ -254,7 +241,7 @@ void main() { }); test('create DAO aware of other different entity stream query', () async { - final dao = await _createDao(''' + final dao = await createDao(''' @dao abstract class PersonDao { @insert @@ -267,18 +254,8 @@ void main() { Future deletePerson(Person person); } '''); - // simulate DB is aware of another streamed Entity and no View - final otherEntity = Entity( - null, // classElement, - 'Dog', // name, - [], // fields, - null, // primaryKey, - [], // foreignKeys, - [], // indices, - false, // withoutRowid, - '', // constructor - ); - final actual = DaoWriter(dao, {otherEntity}, false).write(); + // simulate DB is aware of another streamed Entity('Dog') and no View + final actual = DaoWriter(dao, {'Dog'}).write(); expect(actual, equalsDart(r''' class _$PersonDao extends PersonDao { @@ -329,118 +306,165 @@ void main() { ''')); }); - test('create DAO aware of other view stream query', () async { - final dao = await _createDao(''' - @dao - abstract class PersonDao { - @insert - Future insertPerson(Person person); - - @update - Future updatePerson(Person person); - - @delete - Future deletePerson(Person person); - } - '''); - // simulate DB is aware of no streamed entity but at least a single View - final actual = DaoWriter(dao, {}, true).write(); - - expect(actual, equalsDart(r''' - class _$PersonDao extends PersonDao { - _$PersonDao(this.database, this.changeListener) - : _personInsertionAdapter = InsertionAdapter( - database, - 'Person', - (Person item) => - {'id': item.id, 'name': item.name}, - changeListener), - _personUpdateAdapter = UpdateAdapter( - database, - 'Person', - ['id'], - (Person item) => - {'id': item.id, 'name': item.name}, - changeListener), - _personDeletionAdapter = DeletionAdapter( - database, - 'Person', - ['id'], - (Person item) => - {'id': item.id, 'name': item.name}, - changeListener); + test( + 'create DAO with altering query, QueryAdapter doesn\'t need changeListener', + () async { + final dao = await createDao(''' + @dao + abstract class PersonDao { + @Query("DELETE FROM Person") + Future deleteAllPersons(); - final sqflite.DatabaseExecutor database; + @insert + Future insertPerson(Person person); - final StreamController changeListener; + @update + Future updatePerson(Person person); - final InsertionAdapter _personInsertionAdapter; + @delete + Future deletePerson(Person person); + } + '''); + final actual = DaoWriter(dao, {'Dog'}).write(); + + expect(actual, equalsDart(r''' + class _$PersonDao extends PersonDao { + _$PersonDao(this.database, this.changeListener) + : _queryAdapter = QueryAdapter(database), + _personInsertionAdapter = InsertionAdapter( + database, + 'Person', + (Person item) => + {'id': item.id, 'name': item.name}), + _personUpdateAdapter = UpdateAdapter( + database, + 'Person', + ['id'], + (Person item) => + {'id': item.id, 'name': item.name}), + _personDeletionAdapter = DeletionAdapter( + database, + 'Person', + ['id'], + (Person item) => + {'id': item.id, 'name': item.name}); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _personInsertionAdapter; + + final UpdateAdapter _personUpdateAdapter; + + final DeletionAdapter _personDeletionAdapter; + + @override + Future deleteAllPersons() async { + await _queryAdapter + .queryNoReturn('DELETE FROM Person', changedEntities: {'Person'}); + } - final UpdateAdapter _personUpdateAdapter; + @override + Future insertPerson(Person person) async { + await _personInsertionAdapter.insert(person, OnConflictStrategy.abort); + } - final DeletionAdapter _personDeletionAdapter; + @override + Future updatePerson(Person person) async { + await _personUpdateAdapter.update(person, OnConflictStrategy.abort); + } - @override - Future insertPerson(Person person) async { - await _personInsertionAdapter.insert(person, OnConflictStrategy.abort); - } - - @override - Future updatePerson(Person person) async { - await _personUpdateAdapter.update(person, OnConflictStrategy.abort); - } - - @override - Future deletePerson(Person person) async { - await _personDeletionAdapter.delete(person); - } + @override + Future deletePerson(Person person) async { + await _personDeletionAdapter.delete(person); } - ''')); + } + ''')); }); -} -Future _createDao(final String dao) async { - final library = await resolveSource(''' - library test; + test( + 'create DAO with altering query, QueryAdapter does need a changeListener', + () async { + final dao = await createDao(''' + @dao + abstract class PersonDao { + @Query("DELETE FROM Person") + Future deleteAllPersons(); + + @insert + Future insertPerson(Person person); + + @update + Future updatePerson(Person person); + + @delete + Future deletePerson(Person person); + } + '''); + // simulate DB is aware of another streamed Entity('Dog') and no View + final actual = DaoWriter(dao, {'Person'}).write(); + + expect(actual, equalsDart(r''' + class _$PersonDao extends PersonDao { + _$PersonDao(this.database, this.changeListener) + : _queryAdapter = QueryAdapter(database, changeListener), + _personInsertionAdapter = InsertionAdapter( + database, + 'Person', + (Person item) => + {'id': item.id, 'name': item.name}, + changeListener), + _personUpdateAdapter = UpdateAdapter( + database, + 'Person', + ['id'], + (Person item) => + {'id': item.id, 'name': item.name}, + changeListener), + _personDeletionAdapter = DeletionAdapter( + database, + 'Person', + ['id'], + (Person item) => + {'id': item.id, 'name': item.name}, + changeListener); - import 'package:floor_annotation/floor_annotation.dart'; + final sqflite.DatabaseExecutor database; - $dao + final StreamController changeListener; - @entity - class Person { - @primaryKey - final int id; + final QueryAdapter _queryAdapter; - final String name; + final InsertionAdapter _personInsertionAdapter; - Person(this.id, this.name); - } - - @DatabaseView("SELECT name FROM Person") - class Name { - final String name; + final UpdateAdapter _personUpdateAdapter; - Name(this.name); + final DeletionAdapter _personDeletionAdapter; + + @override + Future deleteAllPersons() async { + await _queryAdapter + .queryNoReturn('DELETE FROM Person', changedEntities: {'Person'}); + } + + @override + Future insertPerson(Person person) async { + await _personInsertionAdapter.insert(person, OnConflictStrategy.abort); + } + + @override + Future updatePerson(Person person) async { + await _personUpdateAdapter.update(person, OnConflictStrategy.abort); + } + + @override + Future deletePerson(Person person) async { + await _personDeletionAdapter.delete(person); + } } - ''', (resolver) async { - return LibraryReader(await resolver.findLibraryByName('test')); + ''')); }); - - final daoClass = library.classes.firstWhere((classElement) => - classElement.hasAnnotation(annotations.dao.runtimeType)); - - final entities = library.classes - .where((classElement) => classElement.hasAnnotation(annotations.Entity)) - .map((classElement) => EntityProcessor(classElement).process()) - .toList(); - - final views = library.classes - .where((classElement) => - classElement.hasAnnotation(annotations.DatabaseView)) - .map((classElement) => ViewProcessor(classElement).process()) - .toList(); - - return DaoProcessor(daoClass, 'personDao', 'TestDatabase', entities, views) - .process(); } diff --git a/floor_generator/test/writer/database_writer_test.dart b/floor_generator/test/writer/database_writer_test.dart index bb89312e..c972b19d 100644 --- a/floor_generator/test/writer/database_writer_test.dart +++ b/floor_generator/test/writer/database_writer_test.dart @@ -124,7 +124,7 @@ void main() { abstract class TestDatabase extends FloorDatabase {} @DatabaseView( - 'SELECT custom_name as name FROM person', + 'SELECT upper(name) as name FROM person', viewName: 'names') class Name { final String name; @@ -172,7 +172,7 @@ void main() { 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER, `name` TEXT, PRIMARY KEY (`id`))'); await database.execute( - '''CREATE VIEW IF NOT EXISTS `names` AS SELECT custom_name as name FROM person'''); + 'CREATE VIEW IF NOT EXISTS `names` AS SELECT upper(name) as name FROM person'); await callback?.onCreate?.call(database, version); }, diff --git a/floor_generator/test/writer/deletion_method_writer_test.dart b/floor_generator/test/writer/deletion_method_writer_test.dart index 64fbcf31..19eab325 100644 --- a/floor_generator/test/writer/deletion_method_writer_test.dart +++ b/floor_generator/test/writer/deletion_method_writer_test.dart @@ -80,6 +80,6 @@ void main() { Future _createDeletionMethod( final String methodSignature, ) async { - final dao = await createDao(methodSignature); + final dao = await createDaoMethod(methodSignature); return dao.deletionMethods.first; } diff --git a/floor_generator/test/writer/insert_method_writer_test.dart b/floor_generator/test/writer/insert_method_writer_test.dart index 94484024..03620c81 100644 --- a/floor_generator/test/writer/insert_method_writer_test.dart +++ b/floor_generator/test/writer/insert_method_writer_test.dart @@ -96,6 +96,6 @@ void main() { Future _createInsertionMethod( final String methodSignature, ) async { - final dao = await createDao(methodSignature); + final dao = await createDaoMethod(methodSignature); return dao.insertionMethods.first; } diff --git a/floor_generator/test/writer/query_method_writer_test.dart b/floor_generator/test/writer/query_method_writer_test.dart index e0c2180e..da8a6c17 100644 --- a/floor_generator/test/writer/query_method_writer_test.dart +++ b/floor_generator/test/writer/query_method_writer_test.dart @@ -1,7 +1,6 @@ import 'package:code_builder/code_builder.dart'; import 'package:floor_generator/value_object/query_method.dart'; import 'package:floor_generator/writer/query_method_writer.dart'; -import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; @@ -20,7 +19,8 @@ void main() { expect(actual, equalsDart(r''' @override Future deleteAll() async { - await _queryAdapter.queryNoReturn('DELETE FROM Person'); + await _queryAdapter + .queryNoReturn('DELETE FROM Person', changedEntities: {'Person'}); } ''')); }); @@ -36,7 +36,42 @@ void main() { expect(actual, equalsDart(r''' @override Future deletePersonById(int id) async { - await _queryAdapter.queryNoReturn('DELETE FROM Person WHERE id = ?', arguments: [id]); + await _queryAdapter.queryNoReturn('DELETE FROM Person WHERE id = ?1', + arguments: [id], changedEntities: {'Person'}); + } + ''')); + }); + + test('query with primitive return type', () async { + final queryMethod = await _createQueryMethod(''' + @Query('SELECT COUNT(*) FROM Person WHERE name = :name') + Future count(String name); + '''); + + final actual = QueryMethodWriter(queryMethod).write(); + + expect(actual, equalsDart(r''' + @override + Future count(String name) async { + return _queryAdapter.query('SELECT COUNT(*) FROM Person WHERE name = ?1', + mapper: (Map row) => row.values.first as int, arguments: [name]); + } + ''')); + }); + + test('query with bool return type', () async { + final queryMethod = await _createQueryMethod(''' + @Query('SELECT COUNT(*)>0 FROM Person WHERE name = :name') + Future personWithNameExists(String name); + '''); + + final actual = QueryMethodWriter(queryMethod).write(); + + expect(actual, equalsDart(r''' + @override + Future personWithNameExists(String name) async { + return _queryAdapter.query('SELECT COUNT(*)>0 FROM Person WHERE name = ?1', + mapper: (Map row) => row.values.first == null ? null : (row.values.first as int) != 0, arguments: [name]); } ''')); }); @@ -52,14 +87,15 @@ void main() { expect(actual, equalsDart(r''' @override Future findById(int id) async { - return _queryAdapter.query('SELECT * FROM Person WHERE id = ?', arguments: [id], mapper: _personMapper); + return _queryAdapter.query('SELECT * FROM Person WHERE id = ?1', + mapper: _personMapper, arguments: [id]); } ''')); }); test('query boolean parameter', () async { final queryMethod = await _createQueryMethod(''' - @Query('SELECT * FROM Person WHERE flag = :flag') + @Query('SELECT * FROM Person WHERE (name = name) = :flag') Future> findWithFlag(bool flag); '''); @@ -68,7 +104,7 @@ void main() { expect(actual, equalsDart(r''' @override Future> findWithFlag(bool flag) async { - return _queryAdapter.queryList('SELECT * FROM Person WHERE flag = ?', arguments: [flag == null ? null : (flag ? 1 : 0)], mapper: _personMapper); + return _queryAdapter.queryList('SELECT * FROM Person WHERE (name = name) = ?1', mapper: _personMapper, arguments: [flag == null ? null : (flag ? 1 : 0)]); } ''')); }); @@ -84,7 +120,7 @@ void main() { expect(actual, equalsDart(r''' @override Future findById(int id, String name) async { - return _queryAdapter.query('SELECT * FROM Person WHERE id = ? AND name = ?', arguments: [id, name], mapper: _personMapper); + return _queryAdapter.query('SELECT * FROM Person WHERE id = ?1 AND name = ?2', mapper: _personMapper, arguments: [id, name]); } ''')); }); @@ -116,7 +152,7 @@ void main() { expect(actual, equalsDart(r''' @override Stream findByIdAsStream(int id) { - return _queryAdapter.queryStream('SELECT * FROM Person WHERE id = ?', arguments: [id], queryableName: 'Person', isView: false, mapper: _personMapper); + return _queryAdapter.queryStream('SELECT * FROM Person WHERE id = ?1', mapper: _personMapper, arguments: [id], dependencies: {'Person'}); } ''')); }); @@ -132,7 +168,7 @@ void main() { expect(actual, equalsDart(r''' @override Stream> findAllAsStream() { - return _queryAdapter.queryListStream('SELECT * FROM Person', queryableName: 'Person', isView: false, mapper: _personMapper); + return _queryAdapter.queryListStream('SELECT * FROM Person', mapper: _personMapper, dependencies: {'Person'}); } ''')); }); @@ -148,7 +184,7 @@ void main() { expect(actual, equalsDart(r''' @override Stream> findAllAsStream() { - return _queryAdapter.queryListStream('SELECT * FROM Name', queryableName: 'Name', isView: true, mapper: _nameMapper); + return _queryAdapter.queryListStream('SELECT * FROM Name', mapper: _nameMapper, dependencies: {'Person'}); } ''')); }); @@ -164,8 +200,16 @@ void main() { expect(actual, equalsDart(r''' @override Future> findWithIds(List ids) async { - final valueList1 = ids.map((value) => "'$value'").join(', '); - return _queryAdapter.queryList('SELECT * FROM Person WHERE id IN ($valueList1)', mapper: _personMapper); + int _start = 1; + final _sqliteVariablesForIds = + Iterable.generate(ids.length, (i) => '?${i + _start}') + .join(','); + return _queryAdapter.queryList( + 'SELECT * FROM Person WHERE id IN (' + + _sqliteVariablesForIds + + ')', + mapper: _personMapper, + arguments: [...ids]); } ''')); }); @@ -181,26 +225,74 @@ void main() { expect(actual, equalsDart(r''' @override Future> findWithIds(List ids, List idx) async { - final valueList1 = ids.map((value) => "'$value'").join(', '); - final valueList2 = idx.map((value) => "'$value'").join(', '); - return _queryAdapter.queryList('SELECT * FROM Person WHERE id IN ($valueList1) AND id IN ($valueList2)', mapper: _personMapper); + int _start = 1; + final _sqliteVariablesForIds = + Iterable.generate(ids.length, (i) => '?${i + _start}').join(','); + _start += ids.length; + final _sqliteVariablesForIdx = + Iterable.generate(idx.length, (i) => '?${i + _start}').join(','); + return _queryAdapter.queryList( + 'SELECT * FROM Person WHERE id IN (' + + _sqliteVariablesForIds + + ') AND id IN (' + + _sqliteVariablesForIdx + + ')', + mapper: _personMapper, + arguments: [...ids,...idx]); } ''')); }); - test('query with unsupported type throws', () async { - final queryMethod = await _createQueryMethod(''' - @Query('SELECT * FROM Person WHERE id = :person') - Future findById(Person person); + test('Query with \' characters', () async { + final queryMethod = await _createQueryMethod(r''' + @Query('SELECT * FROM Person WHERE name = \'\'') + Future> findEmptyNames(); + '''); + + final actual = QueryMethodWriter(queryMethod).write(); + + expect(actual, equalsDart(r''' + @override + Future> findEmptyNames() async { + return _queryAdapter.queryList('SELECT * FROM Person WHERE name = \'\'', mapper: _personMapper); + } + ''')); + }); + + test('Query with \" characters', () async { + final queryMethod = await _createQueryMethod(r''' + @Query('SELECT * FROM Person WHERE "name" = \'\'') + Future> findEmptyNames(); + '''); + + final actual = QueryMethodWriter(queryMethod).write(); + + expect(actual, equalsDart(r''' + @override + Future> findEmptyNames() async { + return _queryAdapter.queryList('SELECT * FROM Person WHERE \"name\" = \'\'', mapper: _personMapper); + } + ''')); + }); + + test('Query with ` characters', () async { + final queryMethod = await _createQueryMethod(r''' + @Query('SELECT * FROM Person WHERE `name` = \'\'') + Future> findEmptyNames(); '''); - final actual = () => QueryMethodWriter(queryMethod).write(); + final actual = QueryMethodWriter(queryMethod).write(); - expect(actual, throwsA(const TypeMatcher())); + expect(actual, equalsDart(r''' + @override + Future> findEmptyNames() async { + return _queryAdapter.queryList('SELECT * FROM Person WHERE `name` = \'\'', mapper: _personMapper); + } + ''')); }); } Future _createQueryMethod(final String methodSignature) async { - final dao = await createDao(methodSignature); + final dao = await createDaoMethod(methodSignature); return dao.queryMethods.first; } diff --git a/floor_generator/test/writer/transaction_method_writer_test.dart b/floor_generator/test/writer/transaction_method_writer_test.dart index 85e7cdfe..70894129 100644 --- a/floor_generator/test/writer/transaction_method_writer_test.dart +++ b/floor_generator/test/writer/transaction_method_writer_test.dart @@ -92,6 +92,6 @@ void main() { Future _createTransactionMethod( final String methodSignature, ) async { - final dao = await createDao(methodSignature); + final dao = await createDaoMethod(methodSignature); return dao.transactionMethods.first; } diff --git a/floor_generator/test/writer/update_method_writer_test.dart b/floor_generator/test/writer/update_method_writer_test.dart index 8f2be131..7b0b7b8f 100644 --- a/floor_generator/test/writer/update_method_writer_test.dart +++ b/floor_generator/test/writer/update_method_writer_test.dart @@ -96,6 +96,6 @@ void main() { Future _createUpdateMethod( final String methodSignature, ) async { - final dao = await createDao(methodSignature); + final dao = await createDaoMethod(methodSignature); return dao.updateMethods.first; }