Update example: Can now update items

This commit is contained in:
Simon Binder 2019-03-03 22:03:11 +01:00
parent 062deccb12
commit d818942c8d
No known key found for this signature in database
GPG Key ID: B807FDF954BA00CF
19 changed files with 243 additions and 81 deletions

View File

@ -265,6 +265,8 @@ Please note that a workaround for most on this list exists with custom statement
These aren't sorted by priority. If you have more ideas or want some features happening soon,
let us know by creating an issue!
- Specify primary keys
- Support an simplified update that doesn't need an explicit where based on the primary key
- Data classes: Generate a `copyWith` method.
- Simple `COUNT(*)` operations (group operations will be much more complicated)
- Support default values and expressions
- Allow using DAOs or some other mechanism instead of having to put everything in the main

View File

@ -48,8 +48,10 @@ class EditAction {
/// number of addition and removal operations between the two lists. It has
/// O(N + D^2) time performance, where D is the minimum amount of edits needed
/// to turn a into b.
List<EditAction> diff<T>(List<T> a, List<T> b) {
final snakes = impl.calculateDiff(impl.DiffInput(a, b));
List<EditAction> diff<T>(List<T> a, List<T> b,
{bool Function(T a, T b) equals}) {
final defaultEquals = equals ?? (T a, T b) => a == b;
final snakes = impl.calculateDiff(impl.DiffInput<T>(a, b, defaultEquals));
final actions = <EditAction>[];
var posOld = a.length;

View File

@ -11,9 +11,8 @@ import 'package:sally/src/runtime/statements/update.dart';
/// This comes in handy to structure large amounts of database code better: The
/// migration logic can live in the main [GeneratedDatabase] class, but code
/// can be extracted into [DatabaseAccessor]s outside of that database.
abstract class DatabaseAccessor<T extends GeneratedDatabase> extends DatabaseConnectionUser
with QueryEngine {
abstract class DatabaseAccessor<T extends GeneratedDatabase>
extends DatabaseConnectionUser with QueryEngine {
@protected
final T db;
@ -154,7 +153,9 @@ abstract class GeneratedDatabase extends DatabaseConnectionUser
GeneratedDatabase(SqlTypeSystem types, QueryExecutor executor,
{StreamQueryStore streamStore})
: super(types, executor, streamQueries: streamStore);
: super(types, executor, streamQueries: streamStore) {
executor.databaseInfo = this;
}
/// Creates a migrator with the provided query executor. We sometimes can't
/// use the regular [GeneratedDatabase.executor] because migration happens

View File

@ -7,23 +7,18 @@ class UpdateStatement<T, D> extends Query<T, D> {
UpdateStatement(QueryEngine database, TableInfo<T, D> table)
: super(database, table);
/// The object to update. The non-null fields of this object will be written
/// into the rows matched by [whereExpr] and [limitExpr].
D _updateReference;
Map<String, dynamic> _updatedFields;
@override
void writeStartPart(GenerationContext ctx) {
// TODO support the OR (ROLLBACK / ABORT / REPLACE / FAIL / IGNORE...) thing
final map = table.entityToSql(_updateReference)
..remove((_, value) => value == null);
ctx.buffer.write('UPDATE ${table.$tableName} SET ');
var first = true;
map.forEach((columnName, variable) {
_updatedFields.forEach((columnName, variable) {
if (!first) {
ctx.writeWhitespace();
ctx.buffer.write(', ');
} else {
first = false;
}
@ -39,12 +34,19 @@ class UpdateStatement<T, D> extends Query<T, D> {
/// means that, when you're not setting a where or limit expression
/// explicitly, this method will update all rows in the specific table.
Future<int> write(D entity) async {
_updateReference = entity;
if (!table.validateIntegrity(_updateReference, false)) {
if (!table.validateIntegrity(entity, false)) {
throw InvalidDataException(
'Invalid data: $entity cannot be written into ${table.$tableName}');
}
_updatedFields = table.entityToSql(entity)
..remove((_, value) => value == null);
if (_updatedFields.isEmpty) {
// nothing to update, we're done
return Future.value(0);
}
final ctx = constructQuery();
final rows = await ctx.database.executor.doWhenOpened((e) async {
return await e.runUpdate(ctx.sql, ctx.boundVariables);

View File

@ -38,10 +38,13 @@ class Range {
class DiffInput<T> {
final List<T> from;
final List<T> to;
final bool Function(T a, T b) equals;
DiffInput(this.from, this.to);
DiffInput(this.from, this.to, this.equals);
bool areItemsTheSame(int fromPos, int toPos) => from[fromPos] == to[toPos];
bool areItemsTheSame(int fromPos, int toPos) {
return equals(from[fromPos], to[toPos]);
}
}
List<Snake> calculateDiff(DiffInput input) {

View File

@ -265,6 +265,8 @@ Please note that a workaround for most on this list exists with custom statement
These aren't sorted by priority. If you have more ideas or want some features happening soon,
let us know by creating an issue!
- Specify primary keys
- Support an simplified update that doesn't need an explicit where based on the primary key
- Data classes: Generate a `copyWith` method.
- Simple `COUNT(*)` operations (group operations will be much more complicated)
- Support default values and expressions
- Allow using DAOs or some other mechanism instead of having to put everything in the main

View File

@ -0,0 +1,14 @@
import 'dart:async';
import 'package:sally_example/database/database.dart';
class TodoAppBloc {
final Database db;
Stream<List<TodoEntry>> get allEntries => db.allEntries();
TodoAppBloc() : db = Database();
void addEntry(TodoEntry entry) {
db.addEntry(entry);
}
}

View File

@ -8,8 +8,6 @@ part 'database.g.dart';
class Todos extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 4, max: 16).nullable()();
TextColumn get content => text()();
DateTimeColumn get targetDate => dateTime().nullable()();
@ -53,11 +51,13 @@ class Database extends _$Database {
// select all categories and load how many associated entries there are for
// each category
return customSelectStream(
'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" FROM categories c;',
readsFrom: {todos, categories}).map((rows) {
'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" FROM categories c;',
readsFrom: {todos, categories})
.map((rows) {
// when we have the result set, map each row to the data class
return rows
.map((row) => CategoryWithCount(Category.fromData(row.data, this), row.readInt('amount')))
.map((row) => CategoryWithCount(
Category.fromData(row.data, this), row.readInt('amount')))
.toList();
});
}
@ -73,4 +73,14 @@ class Database extends _$Database {
Future deleteEntry(TodoEntry entry) {
return (delete(todos)..where((t) => t.id.equals(entry.id))).go();
}
Future updateContent(int id, String content) {
return (update(todos)..where((t) => t.id.equals(id)))
.write(TodoEntry(content: content));
}
Future updateDate(int id, DateTime dueDate) {
return (update(todos)..where((t) => t.id.equals(id)))
.write(TodoEntry(targetDate: dueDate));
}
}

View File

@ -8,19 +8,16 @@ part of 'database.dart';
class TodoEntry {
final int id;
final String title;
final String content;
final DateTime targetDate;
final int category;
TodoEntry(
{this.id, this.title, this.content, this.targetDate, this.category});
TodoEntry({this.id, this.content, this.targetDate, this.category});
factory TodoEntry.fromData(Map<String, dynamic> data, GeneratedDatabase db) {
final intType = db.typeSystem.forDartType<int>();
final stringType = db.typeSystem.forDartType<String>();
final dateTimeType = db.typeSystem.forDartType<DateTime>();
return TodoEntry(
id: intType.mapFromDatabaseResponse(data['id']),
title: stringType.mapFromDatabaseResponse(data['title']),
content: stringType.mapFromDatabaseResponse(data['content']),
targetDate: dateTimeType.mapFromDatabaseResponse(data['target_date']),
category: intType.mapFromDatabaseResponse(data['category']),
@ -28,8 +25,7 @@ class TodoEntry {
}
@override
int get hashCode =>
((((id.hashCode) * 31 + title.hashCode) * 31 + content.hashCode) * 31 +
targetDate.hashCode) *
(((id.hashCode) * 31 + content.hashCode) * 31 + targetDate.hashCode) *
31 +
category.hashCode;
@override
@ -37,7 +33,6 @@ class TodoEntry {
identical(this, other) ||
(other is TodoEntry &&
other.id == id &&
other.title == title &&
other.content == content &&
other.targetDate == targetDate &&
other.category == category);
@ -50,11 +45,6 @@ class $TodosTable extends Todos implements TableInfo<Todos, TodoEntry> {
GeneratedIntColumn get id =>
GeneratedIntColumn('id', false, hasAutoIncrement: true);
@override
GeneratedTextColumn get title => GeneratedTextColumn(
'title',
true,
);
@override
GeneratedTextColumn get content => GeneratedTextColumn(
'content',
false,
@ -70,8 +60,7 @@ class $TodosTable extends Todos implements TableInfo<Todos, TodoEntry> {
true,
);
@override
List<GeneratedColumn> get $columns =>
[id, title, content, targetDate, category];
List<GeneratedColumn> get $columns => [id, content, targetDate, category];
@override
Todos get asDslTable => this;
@override
@ -79,7 +68,6 @@ class $TodosTable extends Todos implements TableInfo<Todos, TodoEntry> {
@override
bool validateIntegrity(TodoEntry instance, bool isInserting) =>
id.isAcceptableValue(instance.id, isInserting) &&
title.isAcceptableValue(instance.title, isInserting) &&
content.isAcceptableValue(instance.content, isInserting) &&
targetDate.isAcceptableValue(instance.targetDate, isInserting) &&
category.isAcceptableValue(instance.category, isInserting);
@ -96,9 +84,6 @@ class $TodosTable extends Todos implements TableInfo<Todos, TodoEntry> {
if (d.id != null) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.title != null) {
map['title'] = Variable<String, StringType>(d.title);
}
if (d.content != null) {
map['content'] = Variable<String, StringType>(d.content);
}

View File

@ -9,8 +9,12 @@ part 'todos_dao.g.dart';
class TodosDao extends DatabaseAccessor<Database> with _TodosDaoMixin {
TodosDao(Database db) : super(db);
Stream<List<TodoEntry>> todosWithoutCategory() {
return null;
Stream<List<TodoEntry>> todosInCategory(Category category) {
if (category == null) {
return (select(todos)..where((t) => isNull(t.category))).watch();
} else {
return (select(todos)..where((t) => t.category.equals(category.id)))
.watch();
}
}
}
}

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:sally_example/database/database.dart';
import 'package:sally_example/bloc.dart';
import 'widgets/homescreen.dart';
void main() => runApp(MyApp());
@ -14,18 +14,18 @@ class MyApp extends StatefulWidget {
// We use this widget to set up the material app and provide an InheritedWidget that
// the rest of this simple app can then use to access the database
class MyAppState extends State<MyApp> {
Database _db;
TodoAppBloc bloc;
@override
void initState() {
_db = Database();
bloc = TodoAppBloc();
super.initState();
}
@override
Widget build(BuildContext context) {
return DatabaseProvider(
db: _db,
return BlocProvider(
bloc: bloc,
child: MaterialApp(
title: 'Sally Demo',
theme: ThemeData(
@ -37,17 +37,16 @@ class MyAppState extends State<MyApp> {
}
}
class DatabaseProvider extends InheritedWidget {
final Database db;
class BlocProvider extends InheritedWidget {
final TodoAppBloc bloc;
DatabaseProvider({@required this.db, Widget child}) : super(child: child);
BlocProvider({@required this.bloc, Widget child}) : super(child: child);
@override
bool updateShouldNotify(DatabaseProvider oldWidget) {
return oldWidget.db != db;
bool updateShouldNotify(BlocProvider oldWidget) {
return oldWidget.bloc != bloc;
}
static Database provideDb(BuildContext ctx) =>
(ctx.inheritFromWidgetOfExactType(DatabaseProvider) as DatabaseProvider)
.db;
static TodoAppBloc provideBloc(BuildContext ctx) =>
(ctx.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider).bloc;
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:sally_example/bloc.dart';
import 'package:sally_example/database/database.dart';
import 'package:sally_example/main.dart';
import 'package:sally_example/widgets/todo_card.dart';
@ -13,12 +14,12 @@ class HomeScreen extends StatefulWidget {
}
}
/// Shows a list of todo entries and displays a text input to add another one
/// Shows a list of todos and displays a text input to add another one
class HomeScreenState extends State<HomeScreen> {
// we only use this to reset the input field at the bottom when a entry has been added
// we only use this to reset the input field at the bottom when a entry has been added
final TextEditingController controller = TextEditingController();
Database get db => DatabaseProvider.provideDb(context);
TodoAppBloc get bloc => BlocProvider.provideBloc(context);
@override
Widget build(BuildContext context) {
@ -29,9 +30,14 @@ class HomeScreenState extends State<HomeScreen> {
// A SallyAnimatedList automatically animates incoming and leaving items, we only
// have to tell it what data to display and how to turn data into widgets.
body: SallyAnimatedList<TodoEntry>(
stream: db.allEntries(), // we want to show an updating stream of all todo entries
stream: bloc
.allEntries, // we want to show an updating stream of all entries
// consider items equal if their id matches. Otherwise, we'd get an
// animation of an old item leaving and another one coming in every time
// the content of an item changed!
equals: (a, b) => a.id == b.id,
itemBuilder: (ctx, item, animation) {
// When a new item arrives, it will expand verticallly
// When a new item arrives, it will expand vertically
return SizeTransition(
key: ObjectKey(item.id),
sizeFactor: animation,
@ -46,15 +52,15 @@ class HomeScreenState extends State<HomeScreen> {
sizeFactor: animation,
axis: Axis.vertical,
child: AnimatedBuilder(
animation: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: TodoCard(item),
builder: (context, child) {
return Opacity(
opacity: animation.value,
child: child,
);
}
),
animation:
CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: TodoCard(item),
builder: (context, child) {
return Opacity(
opacity: animation.value,
child: child,
);
}),
);
},
),
@ -95,7 +101,7 @@ class HomeScreenState extends State<HomeScreen> {
if (controller.text.isNotEmpty) {
// We write the entry here. Notice how we don't have to call setState()
// or anything - sally will take care of updating the list automatically.
db.addEntry(TodoEntry(content: controller.text));
bloc.addEntry(TodoEntry(content: controller.text));
controller.clear();
}
}

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:sally_example/database/database.dart';
import 'package:sally_example/main.dart';
import 'package:sally_example/widgets/todo_edit_dialog.dart';
import 'package:intl/intl.dart';
/// Card that displays a todo entry and an icon button to delete that entry
final DateFormat _format = DateFormat.yMMMd();
/// Card that displays an entry and an icon button to delete that entry
class TodoCard extends StatelessWidget {
final TodoEntry entry;
@ -10,20 +14,70 @@ class TodoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget dueDate;
if (entry.targetDate == null) {
dueDate = const Text(
'No due date set',
style: TextStyle(color: Colors.grey, fontSize: 12),
);
} else {
dueDate = Text(
_format.format(entry.targetDate),
style: const TextStyle(fontSize: 12),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
Expanded(child: Text(entry.content)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(entry.content),
dueDate,
],
),
),
IconButton(
icon: const Icon(Icons.calendar_today),
color: Colors.green,
onPressed: () async {
final dateTime = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2019),
lastDate: DateTime(3038));
await BlocProvider.provideBloc(context)
.db
.updateDate(entry.id, dateTime);
},
),
IconButton(
icon: const Icon(Icons.edit),
color: Colors.blue,
onPressed: () {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => TodoEditDialog(
entry: entry,
),
);
},
),
IconButton(
icon: const Icon(Icons.delete),
color: Colors.red,
onPressed: () {
// We delete the entry here. Again, notice how we don't have to call setState() or
// inform the parent widget. The animated list will take care of this automatically.
DatabaseProvider.provideDb(context).deleteEntry(entry);
BlocProvider.provideBloc(context).db.deleteEntry(entry);
},
)
],

View File

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:sally_example/database/database.dart';
import 'package:sally_example/main.dart';
class TodoEditDialog extends StatefulWidget {
final TodoEntry entry;
const TodoEditDialog({Key key, this.entry}) : super(key: key);
@override
_TodoEditDialogState createState() => _TodoEditDialogState();
}
class _TodoEditDialogState extends State<TodoEditDialog> {
final TextEditingController textController = TextEditingController();
@override
void initState() {
textController.text = widget.entry.content;
super.initState();
}
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Edit entry'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: textController,
decoration: InputDecoration(
hintText: 'What needs to be done?',
helperText: 'Content of entry',
),
),
],
),
actions: [
FlatButton(
child: const Text('Cancel'),
textColor: Colors.red,
onPressed: () {
Navigator.pop(context);
},
),
FlatButton(
child: const Text('Save'),
onPressed: () {
final entry = widget.entry;
if (textController.text.isNotEmpty) {
BlocProvider.provideBloc(context)
.db
.updateContent(entry.id, textController.text);
}
Navigator.pop(context);
},
),
],
);
}
}

View File

@ -9,6 +9,7 @@ environment:
dependencies:
flutter:
sdk: flutter
intl:
cupertino_icons: ^0.1.2
rxdart: 0.20.0
sally_flutter:

View File

@ -14,10 +14,17 @@ class SallyAnimatedList<T> extends StatefulWidget {
final ItemBuilder<T> itemBuilder;
final RemovedItemBuilder<T> removedItemBuilder;
/// A function that decides whether two items are considered equal. By
/// default, `a == b` will be used. A customization is useful if the content
/// of items can change (e.g. when a title changes, you'd only want to change
/// one text and not let the item disappear to show up again).
final bool Function(T a, T b) equals;
SallyAnimatedList(
{@required this.stream,
@required this.itemBuilder,
@required this.removedItemBuilder});
@required this.removedItemBuilder,
this.equals});
@override
_SallyAnimatedListState<T> createState() {
@ -56,7 +63,7 @@ class _SallyAnimatedListState<T> extends State<SallyAnimatedList<T>> {
listState.insertItem(i);
}
} else {
final editScript = diff(_lastSnapshot, data);
final editScript = diff(_lastSnapshot, data, equals: widget.equals);
for (var action in editScript) {
if (action.isDelete) {

View File

@ -1 +0,0 @@
void insertIntoSortedList<T>(List<T> list, T entry, {int compare(T a, T b)}) {}

View File

@ -45,7 +45,8 @@ class DaoGenerator extends GeneratorForAnnotation<UseDao> {
'DatabaseAccessor<${dbImpl.displayName}> {\n');
for (var table in tableTypes) {
final infoType = tableInfoNameForTableClass(table.element as ClassElement);
final infoType =
tableInfoNameForTableClass(table.element as ClassElement);
final getterName = ReCase(table.name).camelCase;
buffer.write('$infoType get $getterName => db.$getterName;\n');

View File

@ -18,4 +18,5 @@ class SpecifiedTable {
{this.fromClass, this.columns, this.sqlName, this.dartTypeName});
}
String tableInfoNameForTableClass(ClassElement fromClass) => '\$${fromClass.name}Table';
String tableInfoNameForTableClass(ClassElement fromClass) =>
'\$${fromClass.name}Table';