diff --git a/README.md b/README.md index 660c1219..7e0d069e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This package is still in an early phase and the API will likely change. 1. [Primary Keys](#primary-keys) 1. [Indices](#indices) 1. [Ignoring Fields](#ignoring-fields) +1. [Database Views](#database-views) 1. [Migrations](#migrations) 1. [In-Memory Database](#in-memory-database) 1. [Callback](#callback) @@ -275,6 +276,9 @@ StreamBuilder>( ); ``` +NOTE: It is currently not possible to return a `Stream` if the function queries a database view. This is mostly due +to the complexity of detecting which entities are involved in a database view. + ## Transactions Whenever you want to perform some operations in a transaction you have to add the `@transaction` annotation to the method. It's also required to add the `async` modifier. These methods can only return `Future`. @@ -414,9 +418,47 @@ class Person { Person(this.id, this.name); } ``` +## Database Views +If you want to define static `SELECT`-statements which return different types than your entities, your best option is +to use `@DatabaseView`. A database view can be understood as a virtual table, which can be queried like a real table. + +A database view in floor is defined and used similarly to entities, with the main difference being that +access is read-only, which means that update, insert and delete functions are not possible. Similarly to +entities, the class name is used if no `viewName` was set. + +```dart +@DatabaseView('SELECT distinct(name) as name FROM person', viewName: 'name') +class Name { + final String name; + + Name(this.name); +} +``` + +Database views do not have any foreign/primary keys or indices. Instead, you should manually define indices which fit to +your statement and put them into the `@Entity` annotation of the involved entities. + +Setters, getters and static fields are automatically ignored (like in entities), you can specify additional fields +to ignore by annotating them with `@ignore`. + +After defining a database view in your code, you have to add it to your database by adding it to the `views` field of +the `@Database` annotation: + +```dart + @Database(version: 1, entities: [Person], views:[Name]) + abstract class AppDatabase extends FloorDatabase { + PersonDao get personDao; + } + +``` + +You can then query the view via a DAO function like an entity. + +NOTE: Be aware that it is currently not possible to return a +`Stream<>` object from a function which queries a database view. ## Migrations -Whenever are doing changes to your entities, you're required to also migrate the old data. +Whenever you are doing changes to your entities, you're required to also migrate the old data. First, update your entity. Next, Increase the database version. diff --git a/floor/README.md b/floor/README.md index 660c1219..7e0d069e 100644 --- a/floor/README.md +++ b/floor/README.md @@ -23,6 +23,7 @@ This package is still in an early phase and the API will likely change. 1. [Primary Keys](#primary-keys) 1. [Indices](#indices) 1. [Ignoring Fields](#ignoring-fields) +1. [Database Views](#database-views) 1. [Migrations](#migrations) 1. [In-Memory Database](#in-memory-database) 1. [Callback](#callback) @@ -275,6 +276,9 @@ StreamBuilder>( ); ``` +NOTE: It is currently not possible to return a `Stream` if the function queries a database view. This is mostly due +to the complexity of detecting which entities are involved in a database view. + ## Transactions Whenever you want to perform some operations in a transaction you have to add the `@transaction` annotation to the method. It's also required to add the `async` modifier. These methods can only return `Future`. @@ -414,9 +418,47 @@ class Person { Person(this.id, this.name); } ``` +## Database Views +If you want to define static `SELECT`-statements which return different types than your entities, your best option is +to use `@DatabaseView`. A database view can be understood as a virtual table, which can be queried like a real table. + +A database view in floor is defined and used similarly to entities, with the main difference being that +access is read-only, which means that update, insert and delete functions are not possible. Similarly to +entities, the class name is used if no `viewName` was set. + +```dart +@DatabaseView('SELECT distinct(name) as name FROM person', viewName: 'name') +class Name { + final String name; + + Name(this.name); +} +``` + +Database views do not have any foreign/primary keys or indices. Instead, you should manually define indices which fit to +your statement and put them into the `@Entity` annotation of the involved entities. + +Setters, getters and static fields are automatically ignored (like in entities), you can specify additional fields +to ignore by annotating them with `@ignore`. + +After defining a database view in your code, you have to add it to your database by adding it to the `views` field of +the `@Database` annotation: + +```dart + @Database(version: 1, entities: [Person], views:[Name]) + abstract class AppDatabase extends FloorDatabase { + PersonDao get personDao; + } + +``` + +You can then query the view via a DAO function like an entity. + +NOTE: Be aware that it is currently not possible to return a +`Stream<>` object from a function which queries a database view. ## Migrations -Whenever are doing changes to your entities, you're required to also migrate the old data. +Whenever you are doing changes to your entities, you're required to also migrate the old data. First, update your entity. Next, Increase the database version. diff --git a/floor/test/integration/dao/name_dao.dart b/floor/test/integration/dao/name_dao.dart new file mode 100644 index 00000000..f441150d --- /dev/null +++ b/floor/test/integration/dao/name_dao.dart @@ -0,0 +1,15 @@ +import 'package:floor/floor.dart'; + +import '../model/name.dart'; + +@dao +abstract class NameDao { + @Query('SELECT * FROM names ORDER BY name ASC') + Future> findAllNames(); + + @Query('SELECT * FROM names WHERE name = :name') + Future findExactName(String name); + + @Query('SELECT * FROM names WHERE name LIKE :suffix ORDER BY name ASC') + Future> findNamesLike(String suffix); +} diff --git a/floor/test/integration/model/name.dart b/floor/test/integration/model/name.dart new file mode 100644 index 00000000..a465cb40 --- /dev/null +++ b/floor/test/integration/model/name.dart @@ -0,0 +1,23 @@ +import 'package:floor/floor.dart'; + +@DatabaseView( + 'SELECT custom_name as name FROM person UNION SELECT name from dog', + viewName: 'names') +class Name { + final String name; + + Name(this.name); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Name && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; + + @override + String toString() { + return 'Name{name: $name}'; + } +} diff --git a/floor/test/integration/view_test/view_test.dart b/floor/test/integration/view_test/view_test.dart new file mode 100644 index 00000000..ab09f2a2 --- /dev/null +++ b/floor/test/integration/view_test/view_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:floor/floor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' hide equals; +import 'package:sqflite/sqflite.dart' as sqflite; +import 'package:sqflite_ffi_test/sqflite_ffi_test.dart'; + +import '../dao/dog_dao.dart'; +import '../dao/name_dao.dart'; +import '../dao/person_dao.dart'; +import '../model/dog.dart'; +import '../model/name.dart'; +import '../model/person.dart'; + +part 'view_test.g.dart'; + +@Database(version: 1, entities: [Person, Dog], views: [Name]) +abstract class ViewTestDatabase extends FloorDatabase { + PersonDao get personDao; + + DogDao get dogDao; + + NameDao get nameDao; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiTestInit(); + + group('database tests', () { + ViewTestDatabase database; + PersonDao personDao; + DogDao dogDao; + NameDao nameDao; + + setUp(() async { + database = await $FloorViewTestDatabase.inMemoryDatabaseBuilder().build(); + + personDao = database.personDao; + dogDao = database.dogDao; + nameDao = database.nameDao; + }); + + tearDown(() async { + await database.close(); + }); + + group('Query Views', () { + test('query view with exact value', () async { + final person = Person(1, 'Frank'); + await personDao.insertPerson(person); + + final actual = await nameDao.findExactName('Frank'); + + final expected = Name('Frank'); + expect(actual, equals(expected)); + }); + + test('query view with LIKE', () async { + final persons = [Person(1, 'Leo'), Person(2, 'Frank')]; + await personDao.insertPersons(persons); + + final dog = Dog(1, 'Romeo', 'Rome', 1); + await dogDao.insertDog(dog); + + final actual = await nameDao.findNamesLike('%eo'); + + final expected = [Name('Leo'), Name('Romeo')]; + expect(actual, equals(expected)); + }); + + test('query view with all values', () async { + final persons = [Person(1, 'Leo'), Person(2, 'Frank')]; + await personDao.insertPersons(persons); + + final dog = Dog(1, 'Romeo', 'Rome', 1); + await dogDao.insertDog(dog); + + final actual = await nameDao.findAllNames(); + + final expected = [Name('Frank'), Name('Leo'), Name('Romeo')]; + expect(actual, equals(expected)); + }); + }); + }); +} diff --git a/floor_annotation/lib/floor_annotation.dart b/floor_annotation/lib/floor_annotation.dart index 1986d9a3..82bef862 100644 --- a/floor_annotation/lib/floor_annotation.dart +++ b/floor_annotation/lib/floor_annotation.dart @@ -3,6 +3,7 @@ library floor_annotation; export 'src/column_info.dart'; export 'src/dao.dart'; export 'src/database.dart'; +export 'src/database_view.dart'; export 'src/delete.dart'; export 'src/entity.dart'; export 'src/foreign_key.dart'; diff --git a/floor_annotation/lib/src/database.dart b/floor_annotation/lib/src/database.dart index 26ccc788..13b2bc5f 100644 --- a/floor_annotation/lib/src/database.dart +++ b/floor_annotation/lib/src/database.dart @@ -8,6 +8,13 @@ class Database { /// The entities the database manages. final List entities; + /// The views the database manages. + final List views; + /// Marks a class as a FloorDatabase. - const Database({@required this.version, @required this.entities}); + const Database({ + @required this.version, + @required this.entities, + this.views = const [], + }); } diff --git a/floor_annotation/lib/src/database_view.dart b/floor_annotation/lib/src/database_view.dart new file mode 100644 index 00000000..393d4209 --- /dev/null +++ b/floor_annotation/lib/src/database_view.dart @@ -0,0 +1,14 @@ +/// Marks a class as a database view (a fixed select statement). +class DatabaseView { + /// The table name of the SQLite view. + final String viewName; + + /// The SELECT query on which the view is based on. + final String query; + + /// Marks a class as a database view (a fixed select statement). + const DatabaseView( + this.query, { + this.viewName, + }); +} diff --git a/floor_generator/lib/misc/constants.dart b/floor_generator/lib/misc/constants.dart index e84b513b..edde8fae 100644 --- a/floor_generator/lib/misc/constants.dart +++ b/floor_generator/lib/misc/constants.dart @@ -5,6 +5,7 @@ abstract class AnnotationField { static const DATABASE_VERSION = 'version'; static const DATABASE_ENTITIES = 'entities'; + static const DATABASE_VIEWS = 'views'; static const COLUMN_INFO_NAME = 'name'; static const COLUMN_INFO_NULLABLE = 'nullable'; @@ -13,6 +14,9 @@ abstract class AnnotationField { static const ENTITY_FOREIGN_KEYS = 'foreignKeys'; static const ENTITY_INDICES = 'indices'; static const ENTITY_PRIMARY_KEYS = 'primaryKeys'; + + static const VIEW_NAME = 'viewName'; + static const VIEW_QUERY = 'query'; } abstract class ForeignKeyField { diff --git a/floor_generator/lib/processor/dao_processor.dart b/floor_generator/lib/processor/dao_processor.dart index d956a123..9a3a5627 100644 --- a/floor_generator/lib/processor/dao_processor.dart +++ b/floor_generator/lib/processor/dao_processor.dart @@ -9,6 +9,7 @@ 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'; import 'package:floor_generator/value_object/dao.dart'; +import 'package:floor_generator/value_object/view.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'; @@ -21,20 +22,24 @@ class DaoProcessor extends Processor { final String _daoGetterName; final String _databaseName; final List _entities; + final List _views; DaoProcessor( final ClassElement classElement, final String daoGetterName, final String databaseName, final List entities, + final List views, ) : assert(classElement != null), assert(daoGetterName != null), assert(databaseName != null), assert(entities != null), + assert(views != null), _classElement = classElement, _daoGetterName = daoGetterName, _databaseName = databaseName, - _entities = entities; + _entities = entities, + _views = views; @override Dao process() { @@ -66,7 +71,8 @@ class DaoProcessor extends Processor { List _getQueryMethods(final List methods) { return methods .where((method) => method.hasAnnotation(annotations.Query)) - .map((method) => QueryMethodProcessor(method, _entities).process()) + .map((method) => + QueryMethodProcessor(method, _entities, _views).process()) .toList(); } @@ -119,7 +125,7 @@ class DaoProcessor extends Processor { List _getStreamEntities(final List queryMethods) { return queryMethods .where((method) => method.returnsStream) - .map((method) => method.entity) + .map((method) => method.queryable as Entity) .toList(); } } diff --git a/floor_generator/lib/processor/database_processor.dart b/floor_generator/lib/processor/database_processor.dart index 6dd36262..d3ca62c7 100644 --- a/floor_generator/lib/processor/database_processor.dart +++ b/floor_generator/lib/processor/database_processor.dart @@ -1,6 +1,6 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:floor_annotation/floor_annotation.dart' as annotations - show Database, dao, Entity; + show Database, dao, Entity, DatabaseView; import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/misc/constants.dart'; import 'package:floor_generator/misc/type_utils.dart'; @@ -8,8 +8,10 @@ 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/view_processor.dart'; import 'package:floor_generator/value_object/dao_getter.dart'; import 'package:floor_generator/value_object/database.dart'; +import 'package:floor_generator/value_object/view.dart'; import 'package:floor_generator/value_object/entity.dart'; class DatabaseProcessor extends Processor { @@ -27,10 +29,18 @@ class DatabaseProcessor extends Processor { Database process() { final databaseName = _classElement.displayName; final entities = _getEntities(_classElement); - final daoGetters = _getDaoGetters(databaseName, entities); + final views = _getViews(_classElement); + final daoGetters = _getDaoGetters(databaseName, entities, views); final version = _getDatabaseVersion(); - return Database(_classElement, databaseName, entities, daoGetters, version); + return Database( + _classElement, + databaseName, + entities, + views, + daoGetters, + version, + ); } @nonNull @@ -50,6 +60,7 @@ class DatabaseProcessor extends Processor { List _getDaoGetters( final String databaseName, final List entities, + final List views, ) { return _classElement.fields.where(_isDao).map((field) { final classElement = field.type.element as ClassElement; @@ -60,6 +71,7 @@ class DatabaseProcessor extends Processor { name, databaseName, entities, + views, ).process(); return DaoGetter(field, name, dao); @@ -97,9 +109,28 @@ class DatabaseProcessor extends Processor { return entities; } + @nonNull + List _getViews(final ClassElement databaseClassElement) { + return _classElement + .getAnnotation(annotations.Database) + .getField(AnnotationField.DATABASE_VIEWS) + ?.toListValue() + ?.map((object) => object.toTypeValue().element) + ?.whereType() + ?.where(_isView) + ?.map((classElement) => ViewProcessor(classElement).process()) + ?.toList(); + } + @nonNull bool _isEntity(final ClassElement classElement) { return classElement.hasAnnotation(annotations.Entity) && !classElement.isAbstract; } + + @nonNull + bool _isView(final ClassElement classElement) { + return classElement.hasAnnotation(annotations.DatabaseView) && + !classElement.isAbstract; + } } diff --git a/floor_generator/lib/processor/entity_processor.dart b/floor_generator/lib/processor/entity_processor.dart index 4c53a45f..d9273f68 100644 --- a/floor_generator/lib/processor/entity_processor.dart +++ b/floor_generator/lib/processor/entity_processor.dart @@ -1,66 +1,54 @@ import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; import 'package:floor_annotation/floor_annotation.dart' as annotations; import 'package:floor_generator/misc/annotations.dart'; import 'package:floor_generator/misc/constants.dart'; import 'package:floor_generator/misc/foreign_key_action.dart'; import 'package:floor_generator/misc/type_utils.dart'; import 'package:floor_generator/processor/error/entity_processor_error.dart'; -import 'package:floor_generator/processor/field_processor.dart'; -import 'package:floor_generator/processor/processor.dart'; +import 'package:floor_generator/processor/queryable_processor.dart'; import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/field.dart'; import 'package:floor_generator/value_object/foreign_key.dart'; import 'package:floor_generator/value_object/index.dart'; import 'package:floor_generator/value_object/primary_key.dart'; -class EntityProcessor extends Processor { - final ClassElement _classElement; +class EntityProcessor extends QueryableProcessor { final EntityProcessorError _processorError; EntityProcessor(final ClassElement classElement) - : assert(classElement != null), - _classElement = classElement, - _processorError = EntityProcessorError(classElement); + : _processorError = EntityProcessorError(classElement), + super(classElement); @nonNull @override Entity process() { final name = _getName(); - final fields = _getFields(); + final fields = getFields(); return Entity( - _classElement, + classElement, name, fields, _getPrimaryKey(fields), _getForeignKeys(), _getIndices(fields, name), - _getConstructor(fields), + getConstructor(fields), ); } @nonNull String _getName() { - return _classElement + return classElement .getAnnotation(annotations.Entity) .getField(AnnotationField.ENTITY_TABLE_NAME) .toStringValue() ?? - _classElement.displayName; - } - - @nonNull - List _getFields() { - return _classElement.fields - .where((fieldElement) => fieldElement.shouldBeIncluded()) - .map((field) => FieldProcessor(field).process()) - .toList(); + classElement.displayName; } @nonNull List _getForeignKeys() { - return _classElement + return classElement .getAnnotation(annotations.Entity) .getField(AnnotationField.ENTITY_FOREIGN_KEYS) ?.toListValue() @@ -114,7 +102,7 @@ class EntityProcessor extends Processor { @nonNull List _getIndices(final List fields, final String tableName) { - return _classElement + return classElement .getAnnotation(annotations.Entity) .getField(AnnotationField.ENTITY_INDICES) ?.toListValue() @@ -182,7 +170,7 @@ class EntityProcessor extends Processor { @nullable PrimaryKey _getCompoundPrimaryKey(final List fields) { - final compoundPrimaryKeyColumnNames = _classElement + final compoundPrimaryKeyColumnNames = classElement .getAnnotation(annotations.Entity) .getField(AnnotationField.ENTITY_PRIMARY_KEYS) ?.toListValue() @@ -219,64 +207,4 @@ class EntityProcessor extends Processor { return PrimaryKey([primaryKeyField], autoGenerate); } - - @nonNull - String _getConstructor(final List fields) { - final constructorParameters = _classElement.constructors.first.parameters; - final parameterValues = constructorParameters - .map((parameterElement) => _getParameterValue(parameterElement, fields)) - .where((parameterValue) => parameterValue != null) - .join(', '); - - return '${_classElement.displayName}($parameterValues)'; - } - - /// Returns `null` whenever field is @ignored - @nullable - String _getParameterValue( - final ParameterElement parameterElement, - final List fields, - ) { - final parameterName = parameterElement.displayName; - final field = fields.firstWhere( - (field) => field.name == parameterName, - orElse: () => null, // whenever field is @ignored - ); - if (field != null) { - final parameterValue = "row['${field.columnName}']"; - final castedParameterValue = - _castParameterValue(parameterElement.type, parameterValue); - if (parameterElement.isNamed) { - return '$parameterName: $castedParameterValue'; - } - return castedParameterValue; // also covers positional parameter - } else { - return null; - } - } - - @nonNull - String _castParameterValue( - final DartType parameterType, - final String parameterValue, - ) { - if (parameterType.isDartCoreBool) { - return '($parameterValue as int) != 0'; // maps int to bool - } else if (parameterType.isDartCoreString) { - return '$parameterValue as String'; - } else if (parameterType.isDartCoreInt) { - return '$parameterValue as int'; - } else if (parameterType.isUint8List) { - return '$parameterValue as Uint8List'; - } else { - return '$parameterValue as double'; // must be double - } - } -} - -extension on FieldElement { - bool shouldBeIncluded() { - final isIgnored = hasAnnotation(annotations.ignore.runtimeType); - return !(isStatic || isSynthetic || isIgnored); - } } 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 b6bab525..33e6261f 100644 --- a/floor_generator/lib/processor/error/query_method_processor_error.dart +++ b/floor_generator/lib/processor/error/query_method_processor_error.dart @@ -35,4 +35,13 @@ class QueryMethodProcessorError { element: _methodElement, ); } + + // ignore: non_constant_identifier_names + InvalidGenerationSourceError get VIEW_NOT_STREAMABLE { + return InvalidGenerationSourceError( + 'Queries on a view can not be returned as a Stream yet.', + todo: 'Don\'t use Stream as the return type of a Query on a View.', + element: _methodElement, + ); + } } diff --git a/floor_generator/lib/processor/error/view_processor_error.dart b/floor_generator/lib/processor/error/view_processor_error.dart new file mode 100644 index 00000000..6ca59fe6 --- /dev/null +++ b/floor_generator/lib/processor/error/view_processor_error.dart @@ -0,0 +1,20 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:source_gen/source_gen.dart'; + +class ViewProcessorError { + final ClassElement _classElement; + + ViewProcessorError(final ClassElement classElement) + : assert(classElement != null), + _classElement = classElement; + + // ignore: non_constant_identifier_names + InvalidGenerationSourceError get MISSING_QUERY { + return InvalidGenerationSourceError( + 'There is no SELECT query defined on the database view ${_classElement.displayName}.', + todo: + 'Define a SELECT query for this database view with @DatabaseView(\'SELECT [...]\') ', + element: _classElement, + ); + } +} diff --git a/floor_generator/lib/processor/query_method_processor.dart b/floor_generator/lib/processor/query_method_processor.dart index 9f69bea4..de8ce17e 100644 --- a/floor_generator/lib/processor/query_method_processor.dart +++ b/floor_generator/lib/processor/query_method_processor.dart @@ -7,22 +7,28 @@ 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/value_object/view.dart'; import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/query_method.dart'; +import 'package:floor_generator/value_object/queryable.dart'; class QueryMethodProcessor extends Processor { final QueryMethodProcessorError _processorError; final MethodElement _methodElement; final List _entities; + final List _views; QueryMethodProcessor( final MethodElement methodElement, final List entities, + final List views, ) : assert(methodElement != null), assert(entities != null), + assert(views != null), _methodElement = methodElement, _entities = entities, + _views = views, _processorError = QueryMethodProcessorError(methodElement); @nonNull @@ -42,11 +48,17 @@ class QueryMethodProcessor extends Processor { returnsStream, ); - final entity = _entities.firstWhere( - (entity) => - entity.classElement.displayName == - flattenedReturnType.getDisplayString(), - orElse: () => null); // doesn't return an entity + final queryable = _entities.firstWhere( + (entity) => + entity.classElement.displayName == + flattenedReturnType.getDisplayString(), + orElse: () => null) ?? + _views.firstWhere( + (view) => + view.classElement.displayName == + flattenedReturnType.getDisplayString(), + orElse: () => null); // doesn't return entity nor view + _assertViewQueryDoesNotReturnStream(queryable, returnsStream); return QueryMethod( _methodElement, @@ -55,7 +67,7 @@ class QueryMethodProcessor extends Processor { rawReturnType, flattenedReturnType, parameters, - entity, + queryable, ); } @@ -126,6 +138,15 @@ class QueryMethodProcessor extends Processor { } } + void _assertViewQueryDoesNotReturnStream( + final Queryable queryable, + final bool returnsStream, + ) { + if (queryable != null && queryable is View && returnsStream) { + throw _processorError.VIEW_NOT_STREAMABLE; + } + } + void _assertQueryParameters( final String query, final List parameterElements, diff --git a/floor_generator/lib/processor/queryable_processor.dart b/floor_generator/lib/processor/queryable_processor.dart new file mode 100644 index 00000000..043abe3d --- /dev/null +++ b/floor_generator/lib/processor/queryable_processor.dart @@ -0,0 +1,88 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:floor_annotation/floor_annotation.dart' as annotations; +import 'package:floor_generator/misc/annotations.dart'; +import 'package:floor_generator/misc/type_utils.dart'; +import 'package:floor_generator/processor/field_processor.dart'; +import 'package:floor_generator/processor/processor.dart'; +import 'package:floor_generator/value_object/field.dart'; +import 'package:floor_generator/value_object/queryable.dart'; +import 'package:meta/meta.dart'; + +abstract class QueryableProcessor extends Processor { + @protected + final ClassElement classElement; + + @protected + QueryableProcessor(this.classElement) : assert(classElement != null); + + @nonNull + @protected + List getFields() { + return classElement.fields + .where((fieldElement) => fieldElement.shouldBeIncluded()) + .map((field) => FieldProcessor(field).process()) + .toList(); + } + + @nonNull + @protected + String getConstructor(final List fields) { + final constructorParameters = classElement.constructors.first.parameters; + final parameterValues = constructorParameters + .map((parameterElement) => _getParameterValue(parameterElement, fields)) + .where((parameterValue) => parameterValue != null) + .join(', '); + + return '${classElement.displayName}($parameterValues)'; + } + + /// Returns `null` whenever field is @ignored + @nullable + String _getParameterValue( + final ParameterElement parameterElement, + final List fields, + ) { + final parameterName = parameterElement.displayName; + final field = fields.firstWhere( + (field) => field.name == parameterName, + orElse: () => null, // whenever field is @ignored + ); + if (field != null) { + final parameterValue = "row['${field.columnName}']"; + final castedParameterValue = + _castParameterValue(parameterElement.type, parameterValue); + if (parameterElement.isNamed) { + return '$parameterName: $castedParameterValue'; + } + return castedParameterValue; // also covers positional parameter + } else { + return null; + } + } + + @nonNull + String _castParameterValue( + final DartType parameterType, + final String parameterValue, + ) { + if (parameterType.isDartCoreBool) { + return '($parameterValue as int) != 0'; // maps int to bool + } else if (parameterType.isDartCoreString) { + return '$parameterValue as String'; + } else if (parameterType.isDartCoreInt) { + return '$parameterValue as int'; + } else if (parameterType.isUint8List) { + return '$parameterValue as Uint8List'; + } else { + return '$parameterValue as double'; // must be double + } + } +} + +extension on FieldElement { + 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 new file mode 100644 index 00000000..7ce7fbf8 --- /dev/null +++ b/floor_generator/lib/processor/view_processor.dart @@ -0,0 +1,52 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:floor_annotation/floor_annotation.dart' as annotations; +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/queryable_processor.dart'; +import 'package:floor_generator/value_object/view.dart'; + +class ViewProcessor extends QueryableProcessor { + final ViewProcessorError _processorError; + + ViewProcessor(final ClassElement classElement) + : _processorError = ViewProcessorError(classElement), + super(classElement); + + @nonNull + @override + View process() { + final name = _getName(); + final fields = getFields(); + final query = _getQuery(); + return View( + classElement, + name, + fields, + query, + getConstructor(fields), + ); + } + + @nonNull + String _getName() { + return classElement + .getAnnotation(annotations.DatabaseView) + .getField(AnnotationField.VIEW_NAME) + .toStringValue() ?? + classElement.displayName; + } + + @nonNull + String _getQuery() { + final query = classElement + .getAnnotation(annotations.DatabaseView) + .getField(AnnotationField.VIEW_QUERY) + .toStringValue(); + + if (query == null || !query.toLowerCase().startsWith('select')) + throw _processorError.MISSING_QUERY; + return query; + } +} diff --git a/floor_generator/lib/value_object/database.dart b/floor_generator/lib/value_object/database.dart index 06c6a7a1..3121ceb6 100644 --- a/floor_generator/lib/value_object/database.dart +++ b/floor_generator/lib/value_object/database.dart @@ -1,5 +1,7 @@ import 'package:analyzer/dart/element/element.dart'; +import 'package:collection/collection.dart'; import 'package:floor_generator/value_object/dao_getter.dart'; +import 'package:floor_generator/value_object/view.dart'; import 'package:floor_generator/value_object/entity.dart'; /// Representation of the database component. @@ -7,6 +9,7 @@ class Database { final ClassElement classElement; final String name; final List entities; + final List views; final List daoGetters; final int version; @@ -14,6 +17,7 @@ class Database { this.classElement, this.name, this.entities, + this.views, this.daoGetters, this.version, ); @@ -25,8 +29,10 @@ class Database { runtimeType == other.runtimeType && classElement == other.classElement && name == other.name && - entities == other.entities && - daoGetters == other.daoGetters && + const ListEquality().equals(entities, other.entities) && + const ListEquality().equals(views, other.views) && + const ListEquality() + .equals(daoGetters, other.daoGetters) && version == other.version; @override @@ -34,11 +40,12 @@ class Database { classElement.hashCode ^ name.hashCode ^ entities.hashCode ^ + views.hashCode ^ daoGetters.hashCode ^ version.hashCode; @override String toString() { - return 'Database{classElement: $classElement, name: $name, entities: $entities, daoGetters: $daoGetters, version: $version}'; + return 'Database{classElement: $classElement, name: $name, entities: $entities, views: $views, daoGetters: $daoGetters, version: $version}'; } } diff --git a/floor_generator/lib/value_object/entity.dart b/floor_generator/lib/value_object/entity.dart index cc91af63..38b9d16b 100644 --- a/floor_generator/lib/value_object/entity.dart +++ b/floor_generator/lib/value_object/entity.dart @@ -5,25 +5,22 @@ import 'package:floor_generator/value_object/field.dart'; import 'package:floor_generator/value_object/foreign_key.dart'; import 'package:floor_generator/value_object/index.dart'; import 'package:floor_generator/value_object/primary_key.dart'; +import 'package:floor_generator/value_object/queryable.dart'; -class Entity { - final ClassElement classElement; - final String name; - final List fields; +class Entity extends Queryable { final PrimaryKey primaryKey; final List foreignKeys; final List indices; - final String constructor; Entity( - this.classElement, - this.name, - this.fields, + ClassElement classElement, + String name, + List fields, this.primaryKey, this.foreignKeys, this.indices, - this.constructor, - ); + String constructor, + ) : super(classElement, name, fields, constructor); @nonNull String getCreateTableStatement() { diff --git a/floor_generator/lib/value_object/query_method.dart b/floor_generator/lib/value_object/query_method.dart index 794dc1ce..b2950fb1 100644 --- a/floor_generator/lib/value_object/query_method.dart +++ b/floor_generator/lib/value_object/query_method.dart @@ -2,7 +2,7 @@ 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/entity.dart'; +import 'package:floor_generator/value_object/queryable.dart'; /// Wraps a method annotated with Query /// to enable easy access to code generation relevant data. @@ -28,7 +28,7 @@ class QueryMethod { final List parameters; - final Entity entity; + final Queryable queryable; QueryMethod( this.methodElement, @@ -37,7 +37,7 @@ class QueryMethod { this.rawReturnType, this.flattenedReturnType, this.parameters, - this.entity, + this.queryable, ); bool get returnsList { @@ -64,7 +64,7 @@ class QueryMethod { flattenedReturnType == other.flattenedReturnType && const ListEquality() .equals(parameters, other.parameters) && - entity == other.entity; + queryable == other.queryable; @override int get hashCode => @@ -74,10 +74,10 @@ class QueryMethod { rawReturnType.hashCode ^ flattenedReturnType.hashCode ^ parameters.hashCode ^ - entity.hashCode; + queryable.hashCode; @override String toString() { - return 'QueryMethod{methodElement: $methodElement, name: $name, query: $query, rawReturnType: $rawReturnType, flattenedReturnType: $flattenedReturnType, parameters: $parameters, entity: $entity}'; + return 'QueryMethod{methodElement: $methodElement, name: $name, query: $query, rawReturnType: $rawReturnType, flattenedReturnType: $flattenedReturnType, parameters: $parameters, entity: $queryable}'; } } diff --git a/floor_generator/lib/value_object/queryable.dart b/floor_generator/lib/value_object/queryable.dart new file mode 100644 index 00000000..d3014394 --- /dev/null +++ b/floor_generator/lib/value_object/queryable.dart @@ -0,0 +1,11 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:floor_generator/value_object/field.dart'; + +abstract class Queryable { + final ClassElement classElement; + final String name; + final List fields; + final String constructor; + + Queryable(this.classElement, this.name, this.fields, this.constructor); +} diff --git a/floor_generator/lib/value_object/view.dart b/floor_generator/lib/value_object/view.dart new file mode 100644 index 00000000..4b3814ab --- /dev/null +++ b/floor_generator/lib/value_object/view.dart @@ -0,0 +1,61 @@ +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'; +import 'package:floor_generator/value_object/queryable.dart'; + +class View extends Queryable { + final String query; + + View(ClassElement classElement, String name, List fields, this.query, + String constructor) + : super(classElement, name, fields, constructor); + + @nonNull + String getCreateViewStatement() { + return 'CREATE VIEW IF NOT EXISTS `$name` AS $query'; + } + + @nonNull + String getValueMapping() { + final keyValueList = fields.map((field) { + final columnName = field.columnName; + final attributeValue = _getAttributeValue(field.fieldElement); + return "'$columnName': $attributeValue"; + }).toList(); + + return '{${keyValueList.join(', ')}}'; + } + + @nonNull + String _getAttributeValue(final FieldElement fieldElement) { + final parameterName = fieldElement.displayName; + return fieldElement.type.isDartCoreBool + ? 'item.$parameterName ? 1 : 0' + : 'item.$parameterName'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is View && + runtimeType == other.runtimeType && + classElement == other.classElement && + name == other.name && + const ListEquality().equals(fields, other.fields) && + query == other.query && + constructor == other.constructor; + + @override + int get hashCode => + classElement.hashCode ^ + name.hashCode ^ + fields.hashCode ^ + query.hashCode ^ + constructor.hashCode; + + @override + String toString() { + return 'View{classElement: $classElement, 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 532137ac..14cad7df 100644 --- a/floor_generator/lib/writer/dao_writer.dart +++ b/floor_generator/lib/writer/dao_writer.dart @@ -59,7 +59,7 @@ class DaoWriter extends Writer { "_queryAdapter = QueryAdapter(database${requiresChangeListener ? ', changeListener' : ''})")); final queryMapperFields = queryMethods - .map((method) => method.entity) + .map((method) => method.queryable) .where((entity) => entity != null) .toSet() .map((entity) { diff --git a/floor_generator/lib/writer/database_writer.dart b/floor_generator/lib/writer/database_writer.dart index 83a818e8..daffee17 100644 --- a/floor_generator/lib/writer/database_writer.dart +++ b/floor_generator/lib/writer/database_writer.dart @@ -85,6 +85,10 @@ class DatabaseWriter implements Writer { .expand((statements) => statements) .map((statement) => "await database.execute('$statement');") .join('\n'); + final createViewStatements = database.views + .map((view) => view.getCreateViewStatement()) + .map((statement) => "await database.execute('$statement');") + .join('\n'); final pathParameter = Parameter((builder) => builder ..name = 'path' @@ -122,6 +126,7 @@ class DatabaseWriter implements Writer { onCreate: (database, version) async { $createTableStatements $createIndexStatements + $createViewStatements await callback?.onCreate?.call(database, version); }, diff --git a/floor_generator/lib/writer/query_method_writer.dart b/floor_generator/lib/writer/query_method_writer.dart index 428aabcd..584701c3 100644 --- a/floor_generator/lib/writer/query_method_writer.dart +++ b/floor_generator/lib/writer/query_method_writer.dart @@ -63,7 +63,7 @@ class QueryMethodWriter implements Writer { return _methodBody.toString(); } - final mapper = '_${_queryMethod.entity.name.decapitalize()}Mapper'; + final mapper = '_${_queryMethod.queryable.name.decapitalize()}Mapper'; if (_queryMethod.returnsStream) { _methodBody.write(_generateStreamQuery(arguments, mapper)); } else { @@ -137,7 +137,7 @@ class QueryMethodWriter implements Writer { @nullable final String arguments, @nonNull final String mapper, ) { - final entityName = _queryMethod.entity.name; + final entityName = _queryMethod.queryable.name; final parameters = StringBuffer()..write("'${_queryMethod.query}', "); if (arguments != null) parameters.write('arguments: $arguments, '); diff --git a/floor_generator/test/processor/dao_processor_test.dart b/floor_generator/test/processor/dao_processor_test.dart index e6a1f7bc..59fa99d6 100644 --- a/floor_generator/test/processor/dao_processor_test.dart +++ b/floor_generator/test/processor/dao_processor_test.dart @@ -28,8 +28,9 @@ void main() { } '''); - final actual = - DaoProcessor(classElement, '', '', entities).process().methodsLength; + final actual = DaoProcessor(classElement, '', '', entities, []) + .process() + .methodsLength; expect(actual, equals(2)); }); @@ -54,8 +55,9 @@ void main() { } '''); - final actual = - DaoProcessor(classElement, '', '', entities).process().methodsLength; + final actual = DaoProcessor(classElement, '', '', entities, []) + .process() + .methodsLength; expect(actual, equals(3)); }); @@ -74,8 +76,9 @@ void main() { } '''); - final actual = - DaoProcessor(classElement, '', '', entities).process().methodsLength; + final actual = DaoProcessor(classElement, '', '', entities, []) + .process() + .methodsLength; expect(actual, equals(2)); }); @@ -94,8 +97,9 @@ void main() { } '''); - final actual = - DaoProcessor(classElement, '', '', entities).process().methodsLength; + final actual = DaoProcessor(classElement, '', '', entities, []) + .process() + .methodsLength; expect(actual, equals(2)); }); @@ -114,8 +118,9 @@ void main() { } '''); - final actual = - DaoProcessor(classElement, '', '', entities).process().methodsLength; + final actual = DaoProcessor(classElement, '', '', entities, []) + .process() + .methodsLength; expect(actual, equals(2)); }); diff --git a/floor_generator/test/processor/entity_processor_test.dart b/floor_generator/test/processor/entity_processor_test.dart index 060e6968..94740922 100644 --- a/floor_generator/test/processor/entity_processor_test.dart +++ b/floor_generator/test/processor/entity_processor_test.dart @@ -9,9 +9,11 @@ import 'package:floor_generator/value_object/primary_key.dart'; import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; +import '../test_utils.dart'; + void main() { test('Process entity', () async { - final classElement = await _createClassElement(''' + final classElement = await createClassElement(''' @entity class Person { @primaryKey @@ -46,7 +48,7 @@ void main() { }); test('Process entity with compound primary key', () async { - final classElement = await _createClassElement(''' + final classElement = await createClassElement(''' @Entity(primaryKeys: ['id', 'name']) class Person { final int id; @@ -79,255 +81,6 @@ void main() { expect(actual, equals(expected)); }); - test('Ignore hashCode field', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - Person(this.id, this.name); - - @override - int get hashCode => id.hashCode ^ name.hashCode; - } - '''); - - final actual = EntityProcessor(classElement).process(); - - expect(actual.fields.length, equals(2)); - }); - - test('Ignore static field', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - Person(this.id, this.name); - - static String foo = 'foo'; - } - '''); - - final actual = EntityProcessor(classElement).process(); - - expect(actual.fields.length, equals(2)); - }); - - test('Ignore getter', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - String get label => '\$id: \$name' - - Person(this.id, this.name); - } - '''); - - final actual = EntityProcessor(classElement) - .process() - .fields - .map((field) => field.name) - .toList(); - - expect(actual, equals(['id', 'name'])); - }); - - test('Ignore setter', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - set printwith(String prefix) => print(prefix+name); - - Person(this.id, this.name); - - static String foo = 'foo'; - } - '''); - - final actual = EntityProcessor(classElement) - .process() - .fields - .map((field) => field.name) - .toList(); - - expect(actual, equals(['id', 'name'])); - }); - - group('Constructors', () { - test('generate simple constructor', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - Person(this.id, this.name); - } - '''); - - final actual = EntityProcessor(classElement).process().constructor; - - const expected = "Person(row['id'] as int, row['name'] as String)"; - expect(actual, equals(expected)); - }); - - test('generate constructor with named argument', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - final String bar; - - Person(this.id, this.name, {this.bar}); - } - '''); - - final actual = EntityProcessor(classElement).process().constructor; - - const expected = - "Person(row['id'] as int, row['name'] as String, bar: row['bar'] as String)"; - expect(actual, equals(expected)); - }); - - test('generate constructor with named arguments', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - final String bar; - - Person({this.id, this.name, this.bar}); - } - '''); - - final actual = EntityProcessor(classElement).process().constructor; - - const expected = - "Person(id: row['id'] as int, name: row['name'] as String, bar: row['bar'] as String)"; - expect(actual, equals(expected)); - }); - - test('generate constructor with optional argument', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - final String bar; - - Person(this.id, this.name, [this.bar]); - } - '''); - - final actual = EntityProcessor(classElement).process().constructor; - - const expected = - "Person(row['id'] as int, row['name'] as String, row['bar'] as String)"; - expect(actual, equals(expected)); - }); - - test('generate constructor with optional arguments', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - final String bar; - - Person([this.id, this.name, this.bar]); - } - '''); - - final actual = EntityProcessor(classElement).process().constructor; - - const expected = - "Person(row['id'] as int, row['name'] as String, row['bar'] as String)"; - expect(actual, equals(expected)); - }); - }); - - group('@Ignore', () { - test('ignore field not present in constructor', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - @ignore - String foo; - - Person(this.id, this.name); - } - '''); - - final actual = EntityProcessor(classElement) - .process() - .fields - .map((field) => field.name); - - const expected = 'foo'; - expect(actual, isNot(contains(expected))); - }); - - test('ignore field present in constructor', () async { - final classElement = await _createClassElement(''' - @entity - class Person { - @primaryKey - final int id; - - final String name; - - @ignore - String foo; - - Person(this.id, this.name, [this.foo = 'foo']); - } - '''); - - final actual = EntityProcessor(classElement).process().constructor; - - const expected = "Person(row['id'] as int, row['name'] as String)"; - expect(actual, equals(expected)); - }); - }); - group('foreign keys', () { test('foreign key holds correct values', () async { final classElements = await _createClassElements(''' @@ -379,20 +132,6 @@ void main() { }); } -Future _createClassElement(final String clazz) async { - final library = await resolveSource(''' - library test; - - import 'package:floor_annotation/floor_annotation.dart'; - - $clazz - ''', (resolver) async { - return LibraryReader(await resolver.findLibraryByName('test')); - }); - - return library.classes.first; -} - Future> _createClassElements(final String classes) async { final library = await resolveSource(''' library test; diff --git a/floor_generator/test/processor/query_method_processor_test.dart b/floor_generator/test/processor/query_method_processor_test.dart index 61867dc9..da4e3830 100644 --- a/floor_generator/test/processor/query_method_processor_test.dart +++ b/floor_generator/test/processor/query_method_processor_test.dart @@ -5,6 +5,8 @@ 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/query_method_processor.dart'; +import 'package:floor_generator/processor/view_processor.dart'; +import 'package:floor_generator/value_object/view.dart'; import 'package:floor_generator/value_object/entity.dart'; import 'package:floor_generator/value_object/query_method.dart'; import 'package:source_gen/source_gen.dart'; @@ -14,8 +16,10 @@ import '../test_utils.dart'; void main() { List entities; + List views; setUpAll(() async => entities = await _getEntities()); + setUpAll(() async => views = await _getViews()); test('create query method', () async { final methodElement = await _createQueryMethodElement(''' @@ -23,7 +27,8 @@ void main() { Future> findAllPersons(); '''); - final actual = QueryMethodProcessor(methodElement, entities).process(); + final actual = + QueryMethodProcessor(methodElement, entities, views).process(); expect( actual, @@ -41,6 +46,31 @@ void main() { ); }); + test('create query method for a view', () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM name') + Future> findAllNames(); + '''); + + final actual = + QueryMethodProcessor(methodElement, entities, views).process(); + + expect( + actual, + equals( + QueryMethod( + methodElement, + 'findAllNames', + 'SELECT * FROM name', + await getDartTypeWithName('Future>'), + await getDartTypeWithName('Name'), + [], + views.first, + ), + ), + ); + }); + group('query parsing', () { test('parse query', () async { final methodElement = await _createQueryMethodElement(''' @@ -48,7 +78,8 @@ void main() { Future findPerson(int id); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect(actual, equals('SELECT * FROM Person WHERE id = ?')); }); @@ -62,7 +93,8 @@ void main() { Future findPersonByIdAndName(int id, String name); """); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect( actual, @@ -77,7 +109,8 @@ void main() { Future findPersonByIdAndName(int id, String name); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect( actual, @@ -91,7 +124,8 @@ void main() { Future setRated(List ids); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect( actual, @@ -105,7 +139,8 @@ void main() { Future setRated(List ids, List bar); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect( actual, @@ -122,7 +157,8 @@ void main() { Future setRated(List ids, int bar); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect( actual, @@ -139,7 +175,8 @@ void main() { Future> findPersonsWithNamesLike(String name); '''); - final actual = QueryMethodProcessor(methodElement, []).process().query; + final actual = + QueryMethodProcessor(methodElement, [], []).process().query; expect(actual, equals('SELECT * FROM Persons WHERE name LIKE ?')); }); @@ -153,7 +190,7 @@ void main() { '''); final actual = - () => QueryMethodProcessor(methodElement, entities).process(); + () => QueryMethodProcessor(methodElement, entities, views).process(); final error = QueryMethodProcessorError(methodElement) .DOES_NOT_RETURN_FUTURE_NOR_STREAM; @@ -167,7 +204,7 @@ void main() { '''); final actual = - () => QueryMethodProcessor(methodElement, entities).process(); + () => QueryMethodProcessor(methodElement, entities, views).process(); final error = QueryMethodProcessorError(methodElement).NO_QUERY_DEFINED; expect(actual, throwsInvalidGenerationSourceError(error)); @@ -180,7 +217,7 @@ void main() { '''); final actual = - () => QueryMethodProcessor(methodElement, entities).process(); + () => QueryMethodProcessor(methodElement, entities, views).process(); final error = QueryMethodProcessorError(methodElement).NO_QUERY_DEFINED; expect(actual, throwsInvalidGenerationSourceError(error)); @@ -194,7 +231,7 @@ void main() { '''); final actual = - () => QueryMethodProcessor(methodElement, entities).process(); + () => QueryMethodProcessor(methodElement, entities, views).process(); final error = QueryMethodProcessorError(methodElement) .QUERY_ARGUMENTS_AND_METHOD_PARAMETERS_DO_NOT_MATCH; @@ -209,12 +246,28 @@ void main() { '''); final actual = - () => QueryMethodProcessor(methodElement, entities).process(); + () => QueryMethodProcessor(methodElement, entities, views).process(); final error = QueryMethodProcessorError(methodElement) .QUERY_ARGUMENTS_AND_METHOD_PARAMETERS_DO_NOT_MATCH; expect(actual, throwsInvalidGenerationSourceError(error)); }); + + test( + 'exception when a query returns Stream while querying a DatabaseView', + () async { + final methodElement = await _createQueryMethodElement(''' + @Query('SELECT * FROM Name') + Stream> allNamesAsStream(); + '''); + + final actual = + () => QueryMethodProcessor(methodElement, entities, views).process(); + + final error = + QueryMethodProcessorError(methodElement).VIEW_NOT_STREAMABLE; + expect(actual, throwsInvalidGenerationSourceError(error)); + }); }); } @@ -240,6 +293,13 @@ Future _createQueryMethodElement( 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')); }); @@ -271,3 +331,26 @@ Future> _getEntities() async { .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/queryable_processor_test.dart b/floor_generator/test/processor/queryable_processor_test.dart new file mode 100644 index 00000000..e998335f --- /dev/null +++ b/floor_generator/test/processor/queryable_processor_test.dart @@ -0,0 +1,300 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:collection/collection.dart'; +import 'package:floor_generator/processor/queryable_processor.dart'; +import 'package:floor_generator/processor/field_processor.dart'; +import 'package:floor_generator/value_object/field.dart'; +import 'package:floor_generator/value_object/queryable.dart'; + +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +class MockQueryable extends Queryable { + MockQueryable( + ClassElement classElement, List fields, String constructor) + : super(classElement, '', fields, constructor); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MockQueryable && + runtimeType == other.runtimeType && + classElement == other.classElement && + const ListEquality().equals(fields, other.fields) && + constructor == other.constructor; + + @override + int get hashCode => + classElement.hashCode ^ fields.hashCode ^ constructor.hashCode; +} + +class MockProcessor extends QueryableProcessor { + MockProcessor(ClassElement classElement) : super(classElement); + + @override + MockQueryable process() { + final fields = getFields(); + return MockQueryable( + classElement, + fields, + getConstructor(fields), + ); + } +} + +void main() { + test('Process Queryable', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = MockProcessor(classElement).process(); + + final fields = classElement.fields + .map((fieldElement) => FieldProcessor(fieldElement).process()) + .toList(); + const constructor = "Person(row['id'] as int, row['name'] as String)"; + final expected = MockQueryable( + classElement, + fields, + constructor, + ); + expect(actual, equals(expected)); + }); + + group('Ignore special fields', () { + test('Ignore static field', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + + static String foo = 'foo'; + } + '''); + + final actual = MockProcessor(classElement).process(); + + expect(actual.fields.length, equals(2)); + }); + + test('Ignore hashCode field', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + + @override + int get hashCode => id.hashCode ^ name.hashCode; + } + '''); + + final actual = MockProcessor(classElement).process(); + + expect(actual.fields.length, equals(2)); + }); + + test('Ignore getter', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + String get label => '\$id: \$name' + + Person(this.id, this.name); + } + '''); + + final actual = MockProcessor(classElement) + .process() + .fields + .map((field) => field.name) + .toList(); + + expect(actual, equals(['id', 'name'])); + }); + + test('Ignore setter', () async { + final classElement = await createClassElement(''' + class Person { + @primaryKey + final int id; + + final String name; + + set printwith(String prefix) => print(prefix+name); + + Person(this.id, this.name); + + static String foo = 'foo'; + } + '''); + + final actual = MockProcessor(classElement) + .process() + .fields + .map((field) => field.name) + .toList(); + + expect(actual, equals(['id', 'name'])); + }); + }); + + group('Constructors', () { + test('generate simple constructor', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = MockProcessor(classElement).process().constructor; + + const expected = "Person(row['id'] as int, row['name'] as String)"; + expect(actual, equals(expected)); + }); + + test('generate constructor with named argument', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + final String bar; + + Person(this.id, this.name, {this.bar}); + } + '''); + + final actual = MockProcessor(classElement).process().constructor; + + const expected = + "Person(row['id'] as int, row['name'] as String, bar: row['bar'] as String)"; + expect(actual, equals(expected)); + }); + + test('generate constructor with named arguments', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + final String bar; + + Person({this.id, this.name, this.bar}); + } + '''); + + final actual = MockProcessor(classElement).process().constructor; + + const expected = + "Person(id: row['id'] as int, name: row['name'] as String, bar: row['bar'] as String)"; + expect(actual, equals(expected)); + }); + + test('generate constructor with optional argument', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + final String bar; + + Person(this.id, this.name, [this.bar]); + } + '''); + + final actual = MockProcessor(classElement).process().constructor; + + const expected = + "Person(row['id'] as int, row['name'] as String, row['bar'] as String)"; + expect(actual, equals(expected)); + }); + + test('generate constructor with optional arguments', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + final String bar; + + Person([this.id, this.name, this.bar]); + } + '''); + + final actual = MockProcessor(classElement).process().constructor; + + const expected = + "Person(row['id'] as int, row['name'] as String, row['bar'] as String)"; + expect(actual, equals(expected)); + }); + }); + + group('@Ignore', () { + test('ignore field not present in constructor', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + @ignore + String foo; + + Person(this.id, this.name); + } + '''); + + final actual = MockProcessor(classElement) + .process() + .fields + .map((field) => field.name); + + const expected = 'foo'; + expect(actual, isNot(contains(expected))); + }); + + test('ignore field present in constructor', () async { + final classElement = await createClassElement(''' + class Person { + final int id; + + final String name; + + @ignore + String foo; + + Person(this.id, this.name, [this.foo = 'foo']); + } + '''); + + final actual = MockProcessor(classElement).process().constructor; + + const expected = "Person(row['id'] as int, row['name'] as String)"; + expect(actual, equals(expected)); + }); + }); +} diff --git a/floor_generator/test/processor/view_processor_test.dart b/floor_generator/test/processor/view_processor_test.dart new file mode 100644 index 00000000..a98e5efa --- /dev/null +++ b/floor_generator/test/processor/view_processor_test.dart @@ -0,0 +1,68 @@ +import 'package:floor_generator/processor/field_processor.dart'; +import 'package:floor_generator/processor/view_processor.dart'; +import 'package:floor_generator/value_object/view.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + test('Process view', () async { + final classElement = await createClassElement(''' + @DatabaseView("SELECT * from otherentity") + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = ViewProcessor(classElement).process(); + + const name = 'Person'; + 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)"; + final expected = View( + classElement, + name, + fields, + query, + constructor, + ); + expect(actual, equals(expected)); + }); + + test('Process view with dedicated name', () async { + final classElement = await createClassElement(''' + @DatabaseView("SELECT * from otherentity",viewName: "personview") + class Person { + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = ViewProcessor(classElement).process(); + + 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)"; + final expected = View( + classElement, + name, + fields, + query, + constructor, + ); + expect(actual, equals(expected)); + }); +} diff --git a/floor_generator/test/test_utils.dart b/floor_generator/test/test_utils.dart index 4050b8b6..b94409b2 100644 --- a/floor_generator/test/test_utils.dart +++ b/floor_generator/test/test_utils.dart @@ -10,6 +10,7 @@ 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:path/path.dart' as path; import 'package:source_gen/source_gen.dart'; @@ -72,6 +73,29 @@ Future getDartTypeWithPerson(String value) async { }); } +Future getDartTypeWithName(String value) async { + final source = ''' + library test; + + import 'package:floor_annotation/floor_annotation.dart'; + + $value value; + + @DatabaseView("SELECT DISTINCT(name) AS name from person") + class Name { + final String name; + + Name(this.name); + } + '''; + return resolveSource(source, (item) async { + final libraryReader = LibraryReader(await item.findLibraryByName('test')); + return (libraryReader.allElements.first as PropertyAccessorElement) + .type + .returnType; + }); +} + final _dartfmt = DartFormatter(); String _format(final String source) { @@ -127,7 +151,26 @@ Future createDao(final String methodSignature) async { .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) + return DaoProcessor(daoClass, 'personDao', 'TestDatabase', entities, views) .process(); } + +Future createClassElement(final String clazz) async { + final library = await resolveSource(''' + library test; + + import 'package:floor_annotation/floor_annotation.dart'; + + $clazz + ''', (resolver) async { + return LibraryReader(await resolver.findLibraryByName('test')); + }); + + return library.classes.first; +} diff --git a/floor_generator/test/value_object/view_test.dart b/floor_generator/test/value_object/view_test.dart new file mode 100644 index 00000000..c0685e9c --- /dev/null +++ b/floor_generator/test/value_object/view_test.dart @@ -0,0 +1,94 @@ +import 'package:floor_generator/misc/constants.dart'; +import 'package:floor_generator/value_object/view.dart'; +import 'package:floor_generator/value_object/field.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + final mockClassElement = MockClassElement(); + final mockFieldElement = MockFieldElement(); + final mockDartType = MockDartType(); + + final field = Field( + mockFieldElement, + 'field1Name', + 'field1ColumnName', + false, + SqlType.INTEGER, + ); + final nullableField = Field( + mockFieldElement, + 'field2Name', + 'field2ColumnName', + true, + SqlType.TEXT, + ); + final allFields = [field, nullableField]; + + tearDown(() { + clearInteractions(mockClassElement); + clearInteractions(mockFieldElement); + clearInteractions(mockDartType); + reset(mockClassElement); + reset(mockFieldElement); + reset(mockDartType); + }); + + group('statement', () { + test('Create view statement with simple query', () { + final view = View( + mockClassElement, + 'entityName', + allFields, + 'select * from x', + '', + ); + + final actual = view.getCreateViewStatement(); + + final expected = + 'CREATE VIEW IF NOT EXISTS `${view.name}` AS ${view.query}'; + expect(actual, equals(expected)); + }); + }); + + group('Value mapping', () { + final view = View( + mockClassElement, + 'entityName', + [nullableField], + 'select * from x', + '', + ); + const fieldElementDisplayName = 'foo'; + + setUp(() { + when(mockFieldElement.displayName).thenReturn(fieldElementDisplayName); + when(mockFieldElement.type).thenReturn(mockDartType); + }); + + test('Get value mapping', () { + when(mockDartType.isDartCoreBool).thenReturn(false); + + final actual = view.getValueMapping(); + + final expected = '{' + "'${nullableField.columnName}': item.$fieldElementDisplayName" + '}'; + expect(actual, equals(expected)); + }); + + test('Get boolean value mapping', () { + when(mockDartType.isDartCoreBool).thenReturn(true); + + final actual = view.getValueMapping(); + + final expected = '{' + "'${nullableField.columnName}': item.$fieldElementDisplayName ? 1 : 0" + '}'; + expect(actual, equals(expected)); + }); + }); +} diff --git a/floor_generator/test/writer/dao_writer_test.dart b/floor_generator/test/writer/dao_writer_test.dart index 614bda2d..eec642e6 100644 --- a/floor_generator/test/writer/dao_writer_test.dart +++ b/floor_generator/test/writer/dao_writer_test.dart @@ -4,6 +4,7 @@ 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/writer/dao_writer.dart'; import 'package:source_gen/source_gen.dart'; @@ -207,6 +208,12 @@ Future _createDao(final String dao) async { .map((classElement) => EntityProcessor(classElement).process()) .toList(); - return DaoProcessor(daoClass, 'personDao', 'TestDatabase', entities) + final views = library.classes + .where((classElement) => + classElement.hasAnnotation(annotations.DatabaseView)) + .map((classElement) => ViewProcessor(classElement).process()) + .toList(); + + return DaoProcessor(daoClass, 'personDao', 'TestDatabase', entities, views) .process(); }