diff --git a/README.md b/README.md index 0165cea9..ab585d5e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sally/lib/diff_util.dart b/sally/lib/diff_util.dart index 431c46d3..5035d2c9 100644 --- a/sally/lib/diff_util.dart +++ b/sally/lib/diff_util.dart @@ -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 diff(List a, List b) { - final snakes = impl.calculateDiff(impl.DiffInput(a, b)); +List diff(List a, List b, + {bool Function(T a, T b) equals}) { + final defaultEquals = equals ?? (T a, T b) => a == b; + final snakes = impl.calculateDiff(impl.DiffInput(a, b, defaultEquals)); final actions = []; var posOld = a.length; diff --git a/sally/lib/src/runtime/database.dart b/sally/lib/src/runtime/database.dart index 88a8f780..e0613be6 100644 --- a/sally/lib/src/runtime/database.dart +++ b/sally/lib/src/runtime/database.dart @@ -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 extends DatabaseConnectionUser - with QueryEngine { - +abstract class DatabaseAccessor + 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 diff --git a/sally/lib/src/runtime/statements/update.dart b/sally/lib/src/runtime/statements/update.dart index 0a9ac6d4..ef01e00d 100644 --- a/sally/lib/src/runtime/statements/update.dart +++ b/sally/lib/src/runtime/statements/update.dart @@ -7,23 +7,18 @@ class UpdateStatement extends Query { UpdateStatement(QueryEngine database, TableInfo 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 _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 extends Query { /// means that, when you're not setting a where or limit expression /// explicitly, this method will update all rows in the specific table. Future 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); diff --git a/sally/lib/src/utils/android_diffutils_port.dart b/sally/lib/src/utils/android_diffutils_port.dart index 5e2d5d89..bfe188d8 100644 --- a/sally/lib/src/utils/android_diffutils_port.dart +++ b/sally/lib/src/utils/android_diffutils_port.dart @@ -38,10 +38,13 @@ class Range { class DiffInput { final List from; final List 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 calculateDiff(DiffInput input) { diff --git a/sally_flutter/README.md b/sally_flutter/README.md index 0165cea9..ab585d5e 100644 --- a/sally_flutter/README.md +++ b/sally_flutter/README.md @@ -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 diff --git a/sally_flutter/example/lib/bloc.dart b/sally_flutter/example/lib/bloc.dart new file mode 100644 index 00000000..d5ae29e5 --- /dev/null +++ b/sally_flutter/example/lib/bloc.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:sally_example/database/database.dart'; + +class TodoAppBloc { + final Database db; + Stream> get allEntries => db.allEntries(); + + TodoAppBloc() : db = Database(); + + void addEntry(TodoEntry entry) { + db.addEntry(entry); + } +} diff --git a/sally_flutter/example/lib/database/database.dart b/sally_flutter/example/lib/database/database.dart index 84f29f0d..e5e0db71 100644 --- a/sally_flutter/example/lib/database/database.dart +++ b/sally_flutter/example/lib/database/database.dart @@ -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)); + } } diff --git a/sally_flutter/example/lib/database/database.g.dart b/sally_flutter/example/lib/database/database.g.dart index b186651c..5ba6cadc 100644 --- a/sally_flutter/example/lib/database/database.g.dart +++ b/sally_flutter/example/lib/database/database.g.dart @@ -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 data, GeneratedDatabase db) { final intType = db.typeSystem.forDartType(); final stringType = db.typeSystem.forDartType(); final dateTimeType = db.typeSystem.forDartType(); 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 { 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 { true, ); @override - List get $columns => - [id, title, content, targetDate, category]; + List get $columns => [id, content, targetDate, category]; @override Todos get asDslTable => this; @override @@ -79,7 +68,6 @@ class $TodosTable extends Todos implements TableInfo { @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 { if (d.id != null) { map['id'] = Variable(d.id); } - if (d.title != null) { - map['title'] = Variable(d.title); - } if (d.content != null) { map['content'] = Variable(d.content); } diff --git a/sally_flutter/example/lib/database/todos_dao.dart b/sally_flutter/example/lib/database/todos_dao.dart index f04fe345..72e0441b 100644 --- a/sally_flutter/example/lib/database/todos_dao.dart +++ b/sally_flutter/example/lib/database/todos_dao.dart @@ -9,8 +9,12 @@ part 'todos_dao.g.dart'; class TodosDao extends DatabaseAccessor with _TodosDaoMixin { TodosDao(Database db) : super(db); - Stream> todosWithoutCategory() { - return null; + Stream> 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(); + } } - -} \ No newline at end of file +} diff --git a/sally_flutter/example/lib/main.dart b/sally_flutter/example/lib/main.dart index 445629c8..9c2e797c 100644 --- a/sally_flutter/example/lib/main.dart +++ b/sally_flutter/example/lib/main.dart @@ -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 { - 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 { } } -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; } diff --git a/sally_flutter/example/lib/widgets/homescreen.dart b/sally_flutter/example/lib/widgets/homescreen.dart index d4ac45b3..2811f3b1 100644 --- a/sally_flutter/example/lib/widgets/homescreen.dart +++ b/sally_flutter/example/lib/widgets/homescreen.dart @@ -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 { - // 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 { // 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( - 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 { 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 { 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(); } } diff --git a/sally_flutter/example/lib/widgets/todo_card.dart b/sally_flutter/example/lib/widgets/todo_card.dart index 79191d25..03b65ecf 100644 --- a/sally_flutter/example/lib/widgets/todo_card.dart +++ b/sally_flutter/example/lib/widgets/todo_card.dart @@ -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: [ - 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); }, ) ], diff --git a/sally_flutter/example/lib/widgets/todo_edit_dialog.dart b/sally_flutter/example/lib/widgets/todo_edit_dialog.dart new file mode 100644 index 00000000..4c5f0f25 --- /dev/null +++ b/sally_flutter/example/lib/widgets/todo_edit_dialog.dart @@ -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 { + 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); + }, + ), + ], + ); + } +} diff --git a/sally_flutter/example/pubspec.yaml b/sally_flutter/example/pubspec.yaml index 88cb7605..54ef8c05 100644 --- a/sally_flutter/example/pubspec.yaml +++ b/sally_flutter/example/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + intl: cupertino_icons: ^0.1.2 rxdart: 0.20.0 sally_flutter: diff --git a/sally_flutter/lib/src/animated_list.dart b/sally_flutter/lib/src/animated_list.dart index 0220b38a..f9002c99 100644 --- a/sally_flutter/lib/src/animated_list.dart +++ b/sally_flutter/lib/src/animated_list.dart @@ -14,10 +14,17 @@ class SallyAnimatedList extends StatefulWidget { final ItemBuilder itemBuilder; final RemovedItemBuilder 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 createState() { @@ -56,7 +63,7 @@ class _SallyAnimatedListState extends State> { 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) { diff --git a/sally_flutter/lib/src/utils.dart b/sally_flutter/lib/src/utils.dart deleted file mode 100644 index 91355b40..00000000 --- a/sally_flutter/lib/src/utils.dart +++ /dev/null @@ -1 +0,0 @@ -void insertIntoSortedList(List list, T entry, {int compare(T a, T b)}) {} diff --git a/sally_generator/lib/src/dao_generator.dart b/sally_generator/lib/src/dao_generator.dart index 94b89861..95d96b0c 100644 --- a/sally_generator/lib/src/dao_generator.dart +++ b/sally_generator/lib/src/dao_generator.dart @@ -45,7 +45,8 @@ class DaoGenerator extends GeneratorForAnnotation { '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'); diff --git a/sally_generator/lib/src/model/specified_table.dart b/sally_generator/lib/src/model/specified_table.dart index 5366b0c0..d8392b14 100644 --- a/sally_generator/lib/src/model/specified_table.dart +++ b/sally_generator/lib/src/model/specified_table.dart @@ -18,4 +18,5 @@ class SpecifiedTable { {this.fromClass, this.columns, this.sqlName, this.dartTypeName}); } -String tableInfoNameForTableClass(ClassElement fromClass) => '\$${fromClass.name}Table'; \ No newline at end of file +String tableInfoNameForTableClass(ClassElement fromClass) => + '\$${fromClass.name}Table';