Implement replace API as a companion for updates

This commit is contained in:
Simon Binder 2019-03-09 16:06:39 +01:00
parent 2e7c079e4d
commit 271e3bb569
No known key found for this signature in database
GPG Key ID: B807FDF954BA00CF
12 changed files with 203 additions and 69 deletions

View File

@ -53,19 +53,19 @@ class $CategoriesTable extends Categories
id.isAcceptableValue(instance.id, isInserting) &&
description.isAcceptableValue(instance.description, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => null;
Set<GeneratedColumn> get $primaryKey => {id};
@override
Category map(Map<String, dynamic> data) {
return Category.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(Category d) {
Map<String, Variable> entityToSql(Category d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.description != null) {
if (d.description != null || includeNulls) {
map['description'] = Variable<String, StringType>(d.description);
}
return map;
@ -142,25 +142,25 @@ class $RecipesTable extends Recipes implements TableInfo<Recipes, Recipe> {
instructions.isAcceptableValue(instance.instructions, isInserting) &&
category.isAcceptableValue(instance.category, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => null;
Set<GeneratedColumn> get $primaryKey => {id};
@override
Recipe map(Map<String, dynamic> data) {
return Recipe.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(Recipe d) {
Map<String, Variable> entityToSql(Recipe d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.title != null) {
if (d.title != null || includeNulls) {
map['title'] = Variable<String, StringType>(d.title);
}
if (d.instructions != null) {
if (d.instructions != null || includeNulls) {
map['instructions'] = Variable<String, StringType>(d.instructions);
}
if (d.category != null) {
if (d.category != null || includeNulls) {
map['category'] = Variable<int, IntType>(d.category);
}
return map;
@ -227,22 +227,22 @@ class $IngredientsTable extends Ingredients
name.isAcceptableValue(instance.name, isInserting) &&
caloriesPer100g.isAcceptableValue(instance.caloriesPer100g, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => null;
Set<GeneratedColumn> get $primaryKey => {id};
@override
Ingredient map(Map<String, dynamic> data) {
return Ingredient.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(Ingredient d) {
Map<String, Variable> entityToSql(Ingredient d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.name != null) {
if (d.name != null || includeNulls) {
map['name'] = Variable<String, StringType>(d.name);
}
if (d.caloriesPer100g != null) {
if (d.caloriesPer100g != null || includeNulls) {
map['calories'] = Variable<int, IntType>(d.caloriesPer100g);
}
return map;
@ -321,15 +321,16 @@ class $IngredientInRecipesTable extends IngredientInRecipes
}
@override
Map<String, Variable> entityToSql(IngredientInRecipe d) {
Map<String, Variable> entityToSql(IngredientInRecipe d,
{bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.recipe != null) {
if (d.recipe != null || includeNulls) {
map['recipe'] = Variable<int, IntType>(d.recipe);
}
if (d.ingredient != null) {
if (d.ingredient != null || includeNulls) {
map['ingredient'] = Variable<int, IntType>(d.ingredient);
}
if (d.amountInGrams != null) {
if (d.amountInGrams != null || includeNulls) {
map['amount'] = Variable<int, IntType>(d.amountInGrams);
}
return map;

View File

@ -3,4 +3,11 @@ class InvalidDataException implements Exception {
final String message;
InvalidDataException(this.message);
@override
String toString() {
return 'InvalidDataException: $message';
}
}

View File

@ -0,0 +1,16 @@
import 'package:sally/sally.dart';
import 'package:sally/src/runtime/components/component.dart';
import 'package:sally/src/runtime/expressions/expression.dart';
class CustomExpression<D, S extends SqlType<D>> extends Expression<D, S> {
final String content;
CustomExpression(this.content);
@override
void writeInto(GenerationContext context) {
context.buffer.write(content);
}
}

View File

@ -39,8 +39,12 @@ class Variable<T, S extends SqlType<T>> extends Expression<T, S> {
@override
void writeInto(GenerationContext context) {
context.buffer.write('?');
context.introduceVariable(mapToSimpleValue(context));
if (value != null) {
context.buffer.write('?');
context.introduceVariable(mapToSimpleValue(context));
} else {
context.buffer.write('NULL');
}
}
}

View File

@ -55,21 +55,23 @@ class Migrator {
Future<void> createTable(TableInfo table) async {
final sql = StringBuffer();
// todo write primary key
// ignore: cascade_invocations
sql.write('CREATE TABLE IF NOT EXISTS ${table.$tableName} (');
var hasAutoIncrement = false;
for (var i = 0; i < table.$columns.length; i++) {
final column = table.$columns[i];
if (column is GeneratedIntColumn && column.hasAutoIncrement)
hasAutoIncrement = true;
// ignore: cascade_invocations
column.writeColumnDefinition(sql);
if (i < table.$columns.length - 1) sql.write(', ');
}
if (table.$primaryKey != null) {
if (table.$primaryKey != null && !hasAutoIncrement) {
sql.write(', PRIMARY KEY (');
final pkList = table.$primaryKey.toList(growable: false);
for (var i = 0; i < pkList.length; i++) {

View File

@ -2,12 +2,15 @@ import 'dart:async';
import 'package:sally/sally.dart';
import 'package:sally/src/runtime/components/component.dart';
import 'package:sally/src/runtime/components/where.dart';
import 'package:sally/src/runtime/expressions/custom.dart';
import 'package:sally/src/runtime/expressions/expression.dart';
class UpdateStatement<T, D> extends Query<T, D> {
UpdateStatement(QueryEngine database, TableInfo<T, D> table)
: super(database, table);
Map<String, dynamic> _updatedFields;
Map<String, Variable> _updatedFields;
@override
void writeStartPart(GenerationContext ctx) {
@ -29,10 +32,31 @@ class UpdateStatement<T, D> extends Query<T, D> {
});
}
Future<int> _performQuery() async {
final ctx = constructQuery();
final rows = await ctx.database.executor.doWhenOpened((e) async {
return await e.runUpdate(ctx.sql, ctx.boundVariables);
});
if (rows > 0) {
database.markTableUpdated(table.$tableName);
}
return rows;
}
/// Writes all non-null fields from [entity] into the columns of all rows
/// that match the set [where] and limit constraints. Warning: That also
/// means that, when you're not setting a where or limit expression
/// explicitly, this method will update all rows in the specific table.
///
/// The fields that are null on the [entity] object will not be changed by
/// this operation.
///
/// Returns the amount of rows that have been affected by this operation.
///
/// See also: [replace], which does not require [where] statements and
/// supports setting fields to null.
Future<int> write(D entity) async {
if (!table.validateIntegrity(entity, false)) {
throw InvalidDataException(
@ -47,15 +71,59 @@ class UpdateStatement<T, D> extends Query<T, D> {
return Future.value(0);
}
final ctx = constructQuery();
final rows = await ctx.database.executor.doWhenOpened((e) async {
return await e.runUpdate(ctx.sql, ctx.boundVariables);
});
return await _performQuery();
}
if (rows > 0) {
database.markTableUpdated(table.$tableName);
/// Replaces the old version of [entity] that is stored in the database with
/// the fields of the [entity] provided here. This implicitly applies a
/// [where] clause to rows with the same primary key as [entity], so that only
/// the row representing outdated data will be replaced.
///
/// If [entity] has fields with null as value, data in the row will be set
/// back to null. This behavior is different to that of [write], which ignores
/// null fields.
///
/// Returns true if a row was affected by this operation.
Future<bool> replace(D entity) async {
// We set isInserting to true here although we're in an update. This is
// because all the fields from the entity will be written (as opposed to a
// regular update, where only non-null fields will be written). If isInserted
// was false, the null fields would not be validated.
if (!table.validateIntegrity(entity, true))
throw InvalidDataException('Invalid data: $entity cannot be used to '
'replace another row as some required fields are null or invalid.');
assert(
whereExpr == null,
'When using replace on an update statement, you may not use where(...)'
'as well. The where clause will be determined automatically');
_updatedFields = table.entityToSql(entity, includeNulls: true);
final primaryKeys = table.$primaryKey.map((c) => c.$name);
// Extract values of the primary key as they are needed for the where clause
final primaryKeyValues = Map.fromEntries(_updatedFields.entries
.where((entry) => primaryKeys.contains(entry.key)));
// But remove them from the map of columns that should be changed.
_updatedFields.removeWhere((key, _) => primaryKeys.contains(key));
Expression<bool, BoolType> predicate;
for (var entry in primaryKeyValues.entries) {
// custom expression that references the column
final columnExpression = CustomExpression(entry.key);
final comparison = Comparison(columnExpression, ComparisonOperator.equal, entry.value);
if (predicate == null) {
predicate = comparison;
} else {
predicate = and(predicate, comparison);
}
}
return rows;
whereExpr = Where(predicate);
final updatedRows = await _performQuery();
return updatedRows != 0;
}
}

View File

@ -23,10 +23,14 @@ abstract class TableInfo<TableDsl, DataClass> {
/// auto-incrementing are allowed to be null as they will be set by sqlite.
bool validateIntegrity(DataClass instance, bool isInserting);
/// Maps the given data class into a map that can be inserted into sql. The
/// Maps the given data class to a [Map] that can be inserted into sql. The
/// keys should represent the column name in sql, the values the corresponding
/// values of the field.
Map<String, Variable> entityToSql(DataClass instance);
///
/// If [includeNulls] is true, fields of the [DataClass] that are null will be
/// written as a [Variable] with a value of null. Otherwise, these fields will
/// not be written into the map at all.
Map<String, Variable> entityToSql(DataClass instance, {bool includeNulls = false});
/// Maps the given row returned by the database into the fitting data class.
DataClass map(Map<String, dynamic> data);

View File

@ -96,28 +96,28 @@ class $TodosTableTable extends TodosTable
targetDate.isAcceptableValue(instance.targetDate, isInserting) &&
category.isAcceptableValue(instance.category, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => null;
Set<GeneratedColumn> get $primaryKey => {id};
@override
TodoEntry map(Map<String, dynamic> data) {
return TodoEntry.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(TodoEntry d) {
Map<String, Variable> entityToSql(TodoEntry d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.title != null) {
if (d.title != null || includeNulls) {
map['title'] = Variable<String, StringType>(d.title);
}
if (d.content != null) {
if (d.content != null || includeNulls) {
map['content'] = Variable<String, StringType>(d.content);
}
if (d.targetDate != null) {
if (d.targetDate != null || includeNulls) {
map['target_date'] = Variable<DateTime, DateTimeType>(d.targetDate);
}
if (d.category != null) {
if (d.category != null || includeNulls) {
map['category'] = Variable<int, IntType>(d.category);
}
return map;
@ -171,19 +171,19 @@ class $CategoriesTable extends Categories
id.isAcceptableValue(instance.id, isInserting) &&
description.isAcceptableValue(instance.description, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => null;
Set<GeneratedColumn> get $primaryKey => {id};
@override
Category map(Map<String, dynamic> data) {
return Category.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(Category d) {
Map<String, Variable> entityToSql(Category d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.description != null) {
if (d.description != null || includeNulls) {
map['`desc`'] = Variable<String, StringType>(d.description);
}
return map;
@ -248,22 +248,22 @@ class $UsersTable extends Users implements TableInfo<Users, User> {
name.isAcceptableValue(instance.name, isInserting) &&
isAwesome.isAcceptableValue(instance.isAwesome, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => null;
Set<GeneratedColumn> get $primaryKey => {id};
@override
User map(Map<String, dynamic> data) {
return User.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(User d) {
Map<String, Variable> entityToSql(User d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.name != null) {
if (d.name != null || includeNulls) {
map['name'] = Variable<String, StringType>(d.name);
}
if (d.isAwesome != null) {
if (d.isAwesome != null || includeNulls) {
map['is_awesome'] = Variable<bool, BoolType>(d.isAwesome);
}
return map;
@ -325,12 +325,12 @@ class $SharedTodosTable extends SharedTodos
}
@override
Map<String, Variable> entityToSql(SharedTodo d) {
Map<String, Variable> entityToSql(SharedTodo d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.todo != null) {
if (d.todo != null || includeNulls) {
map['todo'] = Variable<int, IntType>(d.todo);
}
if (d.user != null) {
if (d.user != null || includeNulls) {
map['user'] = Variable<int, IntType>(d.user);
}
return map;

View File

@ -15,4 +15,14 @@ void main() {
expect('?', ctx.sql);
expect(ctx.boundVariables, [1551297563]);
});
test('writes null directly for null values', () {
final variable = Variable.withString(null);
final ctx = GenerationContext(TodoDb(null));
variable.writeInto(ctx);
expect('NULL', ctx.sql);
expect(ctx.boundVariables, isEmpty);
});
}

View File

@ -37,6 +37,20 @@ void main() {
});
});
test('generates replace statements', () async {
await db.update(db.todosTable).replace(TodoEntry(
id: 3,
title: 'Title',
content: 'Updated content',
// category and targetDate are null
));
verify(executor.runUpdate(
'UPDATE todos SET title = ?, content = ?, '
'target_date = NULL, category = NULL WHERE id = ?;',
['Title', 'Updated content', 3]));
});
test('does not update with invalid data', () {
// The length of a title must be between 4 and 16 chars
@ -45,7 +59,7 @@ void main() {
}, throwsA(const TypeMatcher<InvalidDataException>()));
});
group('Table updates for delete statements', () {
group('Table updates for update statements', () {
test('are issued when data was changed', () async {
when(executor.runUpdate(any, any)).thenAnswer((_) => Future.value(3));

View File

@ -80,25 +80,25 @@ class $TodosTable extends Todos implements TableInfo<Todos, TodoEntry> {
targetDate.isAcceptableValue(instance.targetDate, isInserting) &&
category.isAcceptableValue(instance.category, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => <GeneratedColumn>{};
Set<GeneratedColumn> get $primaryKey => null;
@override
TodoEntry map(Map<String, dynamic> data) {
return TodoEntry.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(TodoEntry d) {
Map<String, Variable> entityToSql(TodoEntry d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.content != null) {
if (d.content != null || includeNulls) {
map['content'] = Variable<String, StringType>(d.content);
}
if (d.targetDate != null) {
if (d.targetDate != null || includeNulls) {
map['target_date'] = Variable<DateTime, DateTimeType>(d.targetDate);
}
if (d.category != null) {
if (d.category != null || includeNulls) {
map['category'] = Variable<int, IntType>(d.category);
}
return map;
@ -152,19 +152,19 @@ class $CategoriesTable extends Categories
id.isAcceptableValue(instance.id, isInserting) &&
description.isAcceptableValue(instance.description, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => <GeneratedColumn>{};
Set<GeneratedColumn> get $primaryKey => null;
@override
Category map(Map<String, dynamic> data) {
return Category.fromData(data, _db);
}
@override
Map<String, Variable> entityToSql(Category d) {
Map<String, Variable> entityToSql(Category d, {bool includeNulls = false}) {
final map = <String, Variable>{};
if (d.id != null) {
if (d.id != null || includeNulls) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.description != null) {
if (d.description != null || includeNulls) {
map['`desc`'] = Variable<String, StringType>(d.description);
}
return map;

View File

@ -63,15 +63,16 @@ class TableWriter {
}
void _writeReverseMappingMethod(StringBuffer buffer) {
// Map<String, Variable> entityToSql(User d) {
// Map<String, Variable> entityToSql(User d, {bool includeNulls = false) {
buffer
..write(
'@override\nMap<String, Variable> entityToSql(${table.dartTypeName} d) {\n')
'@override\nMap<String, Variable> entityToSql('
'${table.dartTypeName} d, {bool includeNulls = false}) {\n')
..write('final map = <String, Variable> {};');
for (var column in table.columns) {
buffer.write('''
if (d.${column.dartGetterName} != null) {
if (d.${column.dartGetterName} != null || includeNulls) {
map['${column.name.name}'] = Variable<${column.dartTypeName}, ${column.sqlTypeName}>(d.${column.dartGetterName});
}
''');
@ -138,13 +139,20 @@ class TableWriter {
void _writePrimaryKeyOverride(StringBuffer buffer) {
buffer.write('@override\nSet<GeneratedColumn> get \$primaryKey => ');
if (table.primaryKey == null) {
var primaryKey = table.primaryKey;
// If there is an auto increment column, that forms the primary key. The
// PK returned by table.primaryKey only contains column that have been
// explicitly defined as PK, but with AI this happens implicitly.
primaryKey ??= table.columns.where((c) => c.hasAI).toSet();
if (primaryKey == null) {
buffer.write('null;');
return;
}
buffer.write('{');
final pkList = table.primaryKey.toList();
final pkList = primaryKey.toList();
for (var i = 0; i < pkList.length; i++) {
final pk = pkList[i];