Implicitly create type converters for enums, Dart api (#478)

This commit is contained in:
Simon Binder 2020-05-12 21:47:11 +02:00
parent 4611ecc3c8
commit 0f2ff8c97a
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
16 changed files with 364 additions and 22 deletions

View File

@ -3,6 +3,7 @@
- Update companions now implement `==` and `hashCode`
- New `containsCase` method for text in `package:moor/extensions/moor_ffi.dart`
- The `toCompanion` method is back for data classes, but its generation can be disabled with a build option
- New `intEnum` column method to automatically map between an enum and an int
## 3.0.2

View File

@ -62,6 +62,14 @@ abstract class Table {
@protected
IntColumnBuilder integer() => _isGenerated();
/// Creates a column to store an `enum` class [T].
///
/// In the database, the column will be represented as an integer
/// corresponding to the enums index. Note that this can invalidate your data
/// if you add another value to the enum class.
@protected
IntColumnBuilder intEnum<T>() => _isGenerated();
/// Use this as the body of a getter to declare a column that holds strings.
/// Example (inside the body of a table class):
/// ```

View File

@ -52,7 +52,7 @@ void main() {
'INSERT INTO todos (content) VALUES (?)',
'UPDATE users SET name = ?;',
'UPDATE users SET name = ? WHERE name = ?;',
'UPDATE categories SET `desc` = ? WHERE id = ?;',
'UPDATE categories SET `desc` = ?, priority = 0 WHERE id = ?;',
'DELETE FROM categories WHERE 1;',
'DELETE FROM todos WHERE id = ?;',
],

View File

@ -33,8 +33,12 @@ class Users extends Table with AutoIncrement {
class Categories extends Table with AutoIncrement {
TextColumn get description =>
text().named('desc').customConstraint('NOT NULL UNIQUE')();
IntColumn get priority =>
intEnum<CategoryPriority>().withDefault(const Constant(0))();
}
enum CategoryPriority { low, medium, high }
class SharedTodos extends Table {
IntColumn get todo => integer()();
IntColumn get user => integer()();

View File

@ -7,6 +7,19 @@ part of 'todos.dart';
// **************************************************************************
// ignore_for_file: unnecessary_brace_in_string_interps, unnecessary_this
class _$GeneratedConverter$0 extends TypeConverter<CategoryPriority, int> {
const _$GeneratedConverter$0();
@override
CategoryPriority mapToDart(int fromDb) {
return fromDb == null ? null : CategoryPriority.values[fromDb];
}
@override
int mapToSql(CategoryPriority value) {
return value?.index;
}
}
class TodoEntry extends DataClass implements Insertable<TodoEntry> {
final int id;
final String title;
@ -336,7 +349,9 @@ class $TodosTableTable extends TodosTable
class Category extends DataClass implements Insertable<Category> {
final int id;
final String description;
Category({@required this.id, @required this.description});
final CategoryPriority priority;
Category(
{@required this.id, @required this.description, @required this.priority});
factory Category.fromData(Map<String, dynamic> data, GeneratedDatabase db,
{String prefix}) {
final effectivePrefix = prefix ?? '';
@ -346,6 +361,8 @@ class Category extends DataClass implements Insertable<Category> {
id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']),
description:
stringType.mapFromDatabaseResponse(data['${effectivePrefix}desc']),
priority: $CategoriesTable.$converter0.mapToDart(
intType.mapFromDatabaseResponse(data['${effectivePrefix}priority'])),
);
}
@override
@ -357,6 +374,10 @@ class Category extends DataClass implements Insertable<Category> {
if (!nullToAbsent || description != null) {
map['desc'] = Variable<String>(description);
}
if (!nullToAbsent || priority != null) {
final converter = $CategoriesTable.$converter0;
map['priority'] = Variable<int>(converter.mapToSql(priority));
}
return map;
}
@ -366,6 +387,9 @@ class Category extends DataClass implements Insertable<Category> {
description: description == null && nullToAbsent
? const Value.absent()
: Value(description),
priority: priority == null && nullToAbsent
? const Value.absent()
: Value(priority),
);
}
@ -375,6 +399,7 @@ class Category extends DataClass implements Insertable<Category> {
return Category(
id: serializer.fromJson<int>(json['id']),
description: serializer.fromJson<String>(json['description']),
priority: serializer.fromJson<CategoryPriority>(json['priority']),
);
}
factory Category.fromJsonString(String encodedJson,
@ -388,57 +413,72 @@ class Category extends DataClass implements Insertable<Category> {
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'description': serializer.toJson<String>(description),
'priority': serializer.toJson<CategoryPriority>(priority),
};
}
Category copyWith({int id, String description}) => Category(
Category copyWith({int id, String description, CategoryPriority priority}) =>
Category(
id: id ?? this.id,
description: description ?? this.description,
priority: priority ?? this.priority,
);
@override
String toString() {
return (StringBuffer('Category(')
..write('id: $id, ')
..write('description: $description')
..write('description: $description, ')
..write('priority: $priority')
..write(')'))
.toString();
}
@override
int get hashCode => $mrjf($mrjc(id.hashCode, description.hashCode));
int get hashCode =>
$mrjf($mrjc(id.hashCode, $mrjc(description.hashCode, priority.hashCode)));
@override
bool operator ==(dynamic other) =>
identical(this, other) ||
(other is Category &&
other.id == this.id &&
other.description == this.description);
other.description == this.description &&
other.priority == this.priority);
}
class CategoriesCompanion extends UpdateCompanion<Category> {
final Value<int> id;
final Value<String> description;
final Value<CategoryPriority> priority;
const CategoriesCompanion({
this.id = const Value.absent(),
this.description = const Value.absent(),
this.priority = const Value.absent(),
});
CategoriesCompanion.insert({
this.id = const Value.absent(),
@required String description,
this.priority = const Value.absent(),
}) : description = Value(description);
static Insertable<Category> custom({
Expression<int> id,
Expression<String> description,
Expression<int> priority,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (description != null) 'desc': description,
if (priority != null) 'priority': priority,
});
}
CategoriesCompanion copyWith({Value<int> id, Value<String> description}) {
CategoriesCompanion copyWith(
{Value<int> id,
Value<String> description,
Value<CategoryPriority> priority}) {
return CategoriesCompanion(
id: id ?? this.id,
description: description ?? this.description,
priority: priority ?? this.priority,
);
}
@ -451,6 +491,10 @@ class CategoriesCompanion extends UpdateCompanion<Category> {
if (description.present) {
map['desc'] = Variable<String>(description.value);
}
if (priority.present) {
final converter = $CategoriesTable.$converter0;
map['priority'] = Variable<int>(converter.mapToSql(priority.value));
}
return map;
}
}
@ -480,8 +524,17 @@ class $CategoriesTable extends Categories
$customConstraints: 'NOT NULL UNIQUE');
}
final VerificationMeta _priorityMeta = const VerificationMeta('priority');
GeneratedIntColumn _priority;
@override
List<GeneratedColumn> get $columns => [id, description];
GeneratedIntColumn get priority => _priority ??= _constructPriority();
GeneratedIntColumn _constructPriority() {
return GeneratedIntColumn('priority', $tableName, false,
defaultValue: const Constant(0));
}
@override
List<GeneratedColumn> get $columns => [id, description, priority];
@override
$CategoriesTable get asDslTable => this;
@override
@ -502,6 +555,7 @@ class $CategoriesTable extends Categories
} else if (isInserting) {
context.missing(_descriptionMeta);
}
context.handle(_priorityMeta, const VerificationResult.success());
return context;
}
@ -517,6 +571,9 @@ class $CategoriesTable extends Categories
$CategoriesTable createAlias(String alias) {
return $CategoriesTable(_db, alias);
}
static TypeConverter<CategoryPriority, int> $converter0 =
const _$GeneratedConverter$0();
}
class User extends DataClass implements Insertable<User> {

View File

@ -73,7 +73,11 @@ void main() {
});
test('generated data classes can be converted to companions', () {
final entry = Category(id: 3, description: 'description');
final entry = Category(
id: 3,
description: 'description',
priority: CategoryPriority.low,
);
final companion = entry.toCompanion(false);
expect(companion.runtimeType, CategoriesCompanion);
@ -82,6 +86,7 @@ void main() {
equals(CategoriesCompanion.insert(
description: 'description',
id: const Value(3),
priority: const Value(CategoryPriority.low),
)),
);
});

View File

@ -218,4 +218,16 @@ void main() {
));
expect(id, 3);
});
test('applies implicit type converter', () async {
await db.into(db.categories).insert(CategoriesCompanion.insert(
description: 'description',
priority: const Value(CategoryPriority.medium),
));
verify(executor.runInsert(
'INSERT INTO categories (`desc`, priority) VALUES (?, ?)',
['description', 1],
));
});
}

View File

@ -23,7 +23,8 @@ void main() {
verify(executor.runSelect(
'SELECT t.id AS "t.id", t.title AS "t.title", '
't.content AS "t.content", t.target_date AS "t.target_date", '
't.category AS "t.category", c.id AS "c.id", c.`desc` AS "c.desc" '
't.category AS "t.category", c.id AS "c.id", c.`desc` AS "c.desc", '
'c.priority AS "c.priority" '
'FROM todos t LEFT OUTER JOIN categories c ON c.id = t.category;',
argThat(isEmpty)));
});
@ -43,6 +44,7 @@ void main() {
't.category': 3,
'c.id': 3,
'c.desc': 'description',
'c.priority': 2,
}
]);
});
@ -65,7 +67,13 @@ void main() {
));
expect(
row.readTable(categories), Category(id: 3, description: 'description'));
row.readTable(categories),
Category(
id: 3,
description: 'description',
priority: CategoryPriority.high,
),
);
verify(executor.runSelect(argThat(contains('DISTINCT')), any));
});
@ -167,20 +175,29 @@ void main() {
when(executor.runSelect(any, any)).thenAnswer((_) async {
return [
{'c.id': 3, 'c.desc': 'Description', 'c2': 11}
{'c.id': 3, 'c.desc': 'Description', 'c.priority': 1, 'c3': 11}
];
});
final result = await query.getSingle();
verify(executor.runSelect(
'SELECT c.id AS "c.id", c.`desc` AS "c.desc", LENGTH(c.`desc`) AS "c2" '
'SELECT c.id AS "c.id", c.`desc` AS "c.desc", c.priority AS "c.priority"'
', LENGTH(c.`desc`) AS "c3" '
'FROM categories c;',
[],
));
expect(result.readTable(categories),
equals(Category(id: 3, description: 'Description')));
expect(
result.readTable(categories),
equals(
Category(
id: 3,
description: 'Description',
priority: CategoryPriority.medium,
),
),
);
expect(result.read(descriptionLength), 11);
});
@ -205,20 +222,28 @@ void main() {
when(executor.runSelect(any, any)).thenAnswer((_) async {
return [
{'c.id': 3, 'c.desc': 'desc', 'c2': 10}
{'c.id': 3, 'c.desc': 'desc', 'c.priority': 0, 'c3': 10}
];
});
final result = await query.getSingle();
verify(executor.runSelect(
'SELECT c.id AS "c.id", c.`desc` AS "c.desc", COUNT(t.id) AS "c2" '
'SELECT c.id AS "c.id", c.`desc` AS "c.desc", '
'c.priority AS "c.priority", COUNT(t.id) AS "c3" '
'FROM categories c INNER JOIN todos t ON t.category = c.id '
'GROUP BY c.id HAVING COUNT(t.id) >= ?;',
[10]));
expect(result.readTable(todos), isNull);
expect(result.readTable(categories), Category(id: 3, description: 'desc'));
expect(
result.readTable(categories),
Category(
id: 3,
description: 'desc',
priority: CategoryPriority.low,
),
);
expect(result.read(amountOfTodos), 10);
});

View File

@ -28,7 +28,8 @@ void main() {
verify(mockExecutor.runCustom(
'CREATE TABLE IF NOT EXISTS categories '
'(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, '
'`desc` VARCHAR NOT NULL UNIQUE);',
'`desc` VARCHAR NOT NULL UNIQUE, '
'priority INTEGER NOT NULL DEFAULT 0);',
[]));
verify(mockExecutor.runCustom(

View File

@ -156,4 +156,27 @@ void main() {
..markTablesUpdated({db.todosTable});
});
});
test('applies implicit type converter', () async {
when(executor.runSelect(any, any)).thenAnswer((_) {
return Future.value([
{
'id': 1,
'desc': 'description',
'priority': 2,
}
]);
});
final category = await db.select(db.categories).getSingle();
expect(
category,
Category(
id: 1,
description: 'description',
priority: CategoryPriority.high,
),
);
});
}

View File

@ -1,6 +1,7 @@
part of 'parser.dart';
const String startInt = 'integer';
const String startEnum = 'intEnum';
const String startString = 'text';
const String startBool = 'boolean';
const String startDateTime = 'dateTime';
@ -9,6 +10,7 @@ const String startReal = 'real';
const Set<String> starters = {
startInt,
startEnum,
startString,
startBool,
startDateTime,
@ -185,6 +187,29 @@ class ColumnParser {
sqlType: columnType);
}
if (foundStartMethod == startEnum) {
if (converter != null) {
base.step.reportError(ErrorInDartCode(
message: 'Using $startEnum will apply a custom converter by default, '
"so you can't add an additional converter",
affectedElement: getter.declaredElement,
severity: Severity.warning,
));
}
final enumType = remainingExpr.typeArgumentTypes[0];
try {
converter = UsedTypeConverter.forEnumColumn(enumType);
} on InvalidTypeForEnumConverterException catch (e) {
base.step.errors.report(ErrorInDartCode(
message: "Can't use $startEnum with "
'${e.invalidType.getDisplayString()}: ${e.reason}',
affectedElement: getter.declaredElement,
severity: Severity.error,
));
}
}
if (foundDefaultExpression != null && clientDefaultExpression != null) {
base.step.reportError(
ErrorInDartCode(
@ -217,6 +242,7 @@ class ColumnParser {
startBool: ColumnType.boolean,
startString: ColumnType.text,
startInt: ColumnType.integer,
startEnum: ColumnType.integer,
startDateTime: ColumnType.datetime,
startBlob: ColumnType.blob,
startReal: ColumnType.real,

View File

@ -81,7 +81,7 @@ class MoorOptions {
this.generateConnectConstructor = false,
this.legacyTypeInference = false,
this.eagerlyLoadDartAst = false,
this.dataClassToCompanions,
this.dataClassToCompanions = true,
this.modules = const [],
});

View File

@ -1,3 +1,4 @@
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:meta/meta.dart';
import 'package:moor_generator/src/model/table.dart';
@ -11,10 +12,15 @@ class UsedTypeConverter {
/// The table using this type converter.
MoorTable table;
/// Whether this type converter is implicitly declared for enum mappings,
/// which means that the implementation of the converter needs to be
/// generated as well.
final bool isForEnum;
/// The expression that will construct the type converter at runtime. The
/// type converter constructed will map a [mappedType] to the [sqlType] and
/// vice-versa.
final String expression;
String expression;
/// The type that will be present at runtime.
final DartType mappedType;
@ -35,5 +41,37 @@ class UsedTypeConverter {
UsedTypeConverter(
{@required this.expression,
@required this.mappedType,
@required this.sqlType});
@required this.sqlType,
this.isForEnum = false});
factory UsedTypeConverter.forEnumColumn(DartType enumType) {
if (enumType.element is! ClassElement) {
throw InvalidTypeForEnumConverterException('Not a class', enumType);
}
final creatingClass = enumType.element as ClassElement;
if (!creatingClass.isEnum) {
throw InvalidTypeForEnumConverterException('Not an enum', enumType);
}
return UsedTypeConverter(
expression: 'bogus expression for enum value',
mappedType: enumType,
sqlType: ColumnType.integer,
isForEnum: true,
);
}
}
class InvalidTypeForEnumConverterException implements Exception {
final String reason;
final DartType invalidType;
InvalidTypeForEnumConverterException(this.reason, this.invalidType);
@override
String toString() {
return 'Invalid type for enum converter: '
'${invalidType.getDisplayString()}. Reason: $reason';
}
}

View File

@ -1,3 +1,4 @@
import 'package:analyzer/dart/element/type.dart';
import 'package:moor/moor.dart';
// ignore: implementation_imports
import 'package:moor/src/runtime/executor/stream_queries.dart';
@ -16,6 +17,44 @@ class DatabaseWriter {
DatabaseWriter(this.db, this.scope);
void write() {
// Write generated convertesr
final enumConverters =
db.tables.expand((t) => t.converters).where((c) => c.isForEnum);
final generatedConvertersForType = <DartType, String>{};
var amountOfGeneratedConverters = 0;
for (final converter in enumConverters) {
String classForConverter;
if (generatedConvertersForType.containsKey(converter.mappedType)) {
classForConverter = generatedConvertersForType[converter.mappedType];
} else {
final id = amountOfGeneratedConverters++;
classForConverter = '_\$GeneratedConverter\$$id';
final buffer = scope.leaf();
final dartType = converter.mappedType.getDisplayString();
final superClass = converter.displayNameOfConverter;
buffer
..writeln('class $classForConverter extends $superClass {')
..writeln('const $classForConverter();')
..writeln('@override')
..writeln('$dartType mapToDart(int fromDb) {')
..writeln('return fromDb == null ? null : $dartType.values[fromDb];')
..writeln('}')
..writeln('@override')
..writeln('int mapToSql($dartType value) {')
..writeln('return value?.index;')
..writeln('}')
..writeln('}');
generatedConvertersForType[converter.mappedType] = classForConverter;
}
converter.expression = 'const $classForConverter()';
}
// Write referenced tables
for (final table in db.tables) {
TableWriter(table, scope.child()).writeInto();

View File

@ -0,0 +1,57 @@
import 'package:moor_generator/moor_generator.dart';
import 'package:moor_generator/src/analyzer/errors.dart';
import 'package:moor_generator/src/analyzer/runner/results.dart';
import 'package:test/test.dart';
import '../utils.dart';
void main() {
TestState state;
setUpAll(() async {
state = TestState.withContent({
'foo|lib/main.dart': '''
import 'package:moor/moor.dart';
enum Fruits {
apple, orange, banana
}
class NotAnEnum {}
class ValidUsage extends Table {
IntColumn get fruit => intEnum<Fruits>()();
}
class InvalidNoEnum extends Table {
IntColumn get fruit => intEnum<NotAnEnum>()();
}
''',
});
await state.analyze('package:foo/main.dart');
});
test('parses enum columns', () {
final file =
state.file('package:foo/main.dart').currentResult as ParsedDartFile;
final table =
file.declaredTables.singleWhere((t) => t.sqlName == 'valid_usage');
expect(
table.converters,
contains(isA<UsedTypeConverter>()
.having((e) => e.isForEnum, 'isForEnum', isTrue)),
);
});
test('fails when used with a non-enum class', () {
final errors = state.file('package:foo/main.dart').errors.errors;
expect(
errors,
contains(isA<MoorError>().having((e) => e.message, 'message',
allOf(contains('Not an enum'), contains('NotAnEnum')))),
);
});
}

View File

@ -0,0 +1,46 @@
import 'package:moor_generator/src/analyzer/options.dart';
import 'package:moor_generator/src/analyzer/runner/results.dart';
import 'package:moor_generator/src/writer/database_writer.dart';
import 'package:moor_generator/src/writer/writer.dart';
import 'package:test/test.dart';
import '../analyzer/utils.dart';
void main() {
test('does not generate multiple converters for the same enum', () async {
final state = TestState.withContent({
'foo|lib/a.dart': '''
import 'package:moor/moor.dart';
enum MyEnum { foo, bar, baz }
class TableA extends Table {
IntColumn get col => intEnum<MyEnum>()();
}
class TableB extends Table {
IntColumn get another => intEnum<MyEnum>()();
}
@UseMoor(tables: [TableA, TableB])
class Database {
}
''',
});
final file = await state.analyze('package:foo/a.dart');
final db = (file.currentResult as ParsedDartFile).declaredDatabases.single;
final writer = Writer(const MoorOptions());
DatabaseWriter(db, writer.child()).write();
expect(
writer.writeGenerated(),
allOf(
contains(r'_$GeneratedConverter$0'),
isNot(contains(r'_$GeneratedConverter$1')),
),
);
});
}