Write a TODO App to learn Moutter, a Flutter database tool

Write a TODO App to learn Moutter, a Flutter database tool

Flutter's database storage, the official document: https://flutter.dev/docs/cookbook/persistence/sqlite
is written directly to manipulate the SQLite database.

Is there any package that can help developers to store database more conveniently like Android Room?

Moor is for this purpose: https://pub.dev/packages/moor .
Its name is to reverse Room. It is a third-party package.

To learn how to use it, I made a small todo app: https://github.com/mengdd/more_todo .

This article is a working record.

TL;DR

Make TODO app with Moor:

  • Basic use: dependent addition, database and table creation, basic operations on tables.
  • Problem solving: insert data attention type; file organization of multiple tables.
  • Commonly used functions: foreign keys and joins, database upgrades, conditional queries.

Code: Todo app: https://github.com/mengdd/more_todo

Moor basic use

There is an official document here:
Moor Getting Started

Step 1: add dependencies

pubspec.yamlin:

dependencies:
  flutter:
    sdk: flutter

  moor: ^2.4.0
  moor_ffi: ^0.4.0
  path_provider: ^1.6.5
  path: ^1.6.4
  provider: ^4.0.4

dev_dependencies:
  flutter_test:
    sdk: flutter
  moor_generator: ^2.4.0
  build_runner: ^1.8.1

Here I am using the current (2020.4) latest version, after that please update the version number of each package to the latest version.

Explanation of each package:

* moor: This is the core package defining most apis
* moor_ffi: Contains code that will run the actual queries
* path_provider and path: Used to find a suitable location to store the database. Maintained by the Flutter and Dart team
* moor_generator: Generates query code based on your tables
* build_runner: Common tool for code-generation, maintained by the Dart team

It is now recommended to use moor_ffiinstead moor_flutter.

Some examples on the Internet are used moor_flutter, so when looking at those examples, some places may not be right.

Step 2: Define the database and table

Create a new file, for example todo_database.dart:

import 'dart:io';

import 'package:moor/moor.dart';
import 'package:moor_ffi/moor_ffi.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

part 'todo_database.g.dart';

// this will generate a table called "todos" for us. The rows of that table will
// be represented by a class called "Todo".
class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get title => text().withLength(min: 1, max: 50)();

  TextColumn get content => text().nullable().named('description')();

  IntColumn get category => integer().nullable()();

  BoolColumn get completed => boolean().withDefault(Constant(false))();
}

@UseMoor(tables: [Todos])
class TodoDatabase extends _$TodoDatabase {
  // we tell the database where to store the data with this constructor
  TodoDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;
}

LazyDatabase _openConnection() {
  // the LazyDatabase util lets us find the right location for the file async.
  return LazyDatabase(() async {
    // put the database file, called db.sqlite here, into the documents folder
    // for your app.
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.sqlite'));
    return VmDatabase(file, logStatements: true);
  });
}

Several knowledge points:

  • To add part 'todo_database.g.dart';, wait a minute to generate this file.
  • The class defined here is Todosthat the generated specific entity class will remove s, that is Todo. If you want to specify the name of the generated class, you can add a comment @DataClassName("Category")to the class , for example:, the generated class will be called "Category".
  • Convention: $ is the prefix of the class name of the generated class. It .g.dartis the generated file.

Step 3: Generate code

run:

flutter packages pub run build_runner build

or:

flutter packages pub run build_runner watch

To build one-time (build) or continuous (watch).

If it does not go well, it may be necessary to add --delete-conflicting-outputs:

flutter packages pub run build_runner watch --delete-conflicting-outputs

After running successfully, generate the todo_database.g.dartfile.

All errors reported in the code should disappear.

Step 4: Add the method of adding, deleting, modifying and checking

For a simple example, write the method directly in the database class:

@UseMoor(tables: [Todos])
class TodoDatabase extends _$TodoDatabase {
  // we tell the database where to store the data with this constructor
  TodoDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  Future<List<Todo>> getAllTodos() => select(todos).get();

  Stream<List<Todo>> watchAllTodos() => select(todos).watch();

  Future insertTodo(TodosCompanion todo) => into(todos).insert(todo);

  Future updateTodo(Todo todo) => update(todos).replace(todo);

  Future deleteTodo(Todo todo) => delete(todos).delete(todo);
}

Database queries can not only return Future but also Stream, keeping continuous observation of the data.

Note here that the inserted method uses the Companion object. We will talk about why later.

The above method writes all the database operation methods together, and it is obviously not good after more codes. The
improved method is to write DAO:
https://moor.simonbinder.eu/docs/advanced-features/daos/

It will be changed later.

Step 5: Provide data to the UI

Providing data access methods involves the state management of the program. There
are many methods, an article has been written before: https://www.cnblogs.com/mengdd/p/flutter-state-management.html

Here we first choose a simple method to directly use Provider to provide database objects, wrapped in the outer layer of the program:

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Provider(
      create: (_) => TodoDatabase(),
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(),
      ),
    );
  }
}

when needed:

TodoDatabase database = Provider.of<TodoDatabase>(context, listen: false);

Take the database object, and then you can call its methods.

Then there is the question of how to use the UI, and I won't say more here.

Tag: v0.1.1is the simplest method in my code .
You can checkout the past to see the implementation of this simplest version.

Step 6: Improvement: Extract method to DAO, refactor

The method of adding, deleting, modifying and checking is extracted from the database and written in DAO:

part 'todos_dao.g.dart';

// the _TodosDaoMixin will be created by moor. It contains all the necessary
// fields for the tables. The <MyDatabase> type annotation is the database class
// that should use this dao.
@UseDao(tables: [Todos])
class TodosDao extends DatabaseAccessor<TodoDatabase> with _$TodosDaoMixin {
  // this constructor is required so that the main database can create an instance
  // of this object.
  TodosDao(TodoDatabase db) : super(db);

  Future<List<Todo>> getAllTodos() => select(todos).get();

  Stream<List<Todo>> watchAllTodos() => select(todos).watch();

  Future insertTodo(TodosCompanion todo) => into(todos).insert(todo);

  Future updateTodo(Todo todo) => update(todos).replace(todo);

  Future deleteTodo(Todo todo) => delete(todos).delete(todo);
}

Run the command line to regenerate it (not needed if it is a watch).

In fact, this is generated:

part of 'todos_dao.dart';

mixin _$TodosDaoMixin on DatabaseAccessor<TodoDatabase> {
  $TodosTable get todos => db.todos;
}

The todos here are the table objects.

So if it is not to change the table and only change the method implementation in DAO, there is no need to regenerate.

At this time, the part we provided to the UI has to be changed.

Previously, the Provider directly provided the database object. Although it can be directly replaced with the DAO object, there will be many DAOs. If you provide it this way, the code will soon be messed up.

There are many ways to solve this problem. This is an architectural design problem.

Let me briefly encapsulate here:

class DatabaseProvider {
  TodosDao _todosDao;

  TodosDao get todosDao => _todosDao;

  DatabaseProvider() {
    TodoDatabase database = TodoDatabase();
    _todosDao = TodosDao(database);
  }
}

The outermost layer is changed to provide this:

    return Provider(
      create: (_) => DatabaseProvider(),
//...
    );

When you use it, you can get DAO out and use it.

If there are other DAOs, you can add them.

Troubleshooting

Companion object should be used when inserting

Method of inserting data:
If you write like this:

Future insertTodo(Todo todo) => into(todos).insert(todo);

Just pitted.

Because by definition, our id is automatically generated and incremented:

IntColumn get id => integer().autoIncrement()();

But the generated Todo class contains all non-empty fields @required:

Todo(
  {@required this.id,
  @required this.title,
  this.content,
  this.category,
  @required this.completed});

To create a new instance and insert it, I can't specify this incrementing id myself. (Is it too much tricky to query and then manually increment by myself. Generally, weird practices that are not intuitive are wrong.)

You can see in these two issues, the author's explanation also uses the Companion object:

So the insert method was finally written like this:

Future insertTodo(TodosCompanion todo) => into(todos).insert(todo);

Another way of writing is this:

 Future insertTodo(Insertable<Todo> todo) => into(todos).insert(todo);

adding data:

final todo = TodosCompanion(
  title: Value(input),
  completed: Value(false),
);
todosDao.insertTodo(todo);

When constructing objects here, you only need to Valuewrap the required values . What is not provided will be Value.absent().

The table definition must be written together with the database class? What about multiple tables?

There must be multiple tables in the actual project, I think one table and one file is better.

So when I naively created a new categories.dartfile for my new data table, such as Category , it inherited the Table class and also specified the name of the generated file.

part 'categories.g.dart';

@DataClassName('Category')
class Categories extends Table {
//...
}

This line is red in the code after running the build:

part 'categories.g.dart';

This file was not generated.

After checking, it is found that the Categoryclass is still generated in the databse.g.dart file.

Discussion about this issue: https://github.com/simolus3/moor/issues/480

There are two ideas for the solution:

  • Simple solution: The source code is still written separately, but all the generated code is put together.

Remove the part statement.

@DataClassName('Category')
class Categories extends Table {
//...
}

The generated code is still in the generated file of the method database, but our source files seem to be separated.
When the specific data type is used later, the imported file is still the corresponding class of the database file.

  • Use .moorfiles.

Advanced requirements

Foreign keys and join

The requirement to associate two tables is quite common.

For example, our todo instance, after adding the Category class, want to put the todo in a different category, if there is no category, it is placed in the inbox as uncategorized.

Moor does not directly support foreign keys, but customStatementis implemented through them.

Here, this column in the Todos class, plus custom restrictions, is associated with the categories table:

IntColumn get category => integer()
  .nullable()
  .customConstraint('NULL REFERENCES categories(id) ON DELETE CASCADE')();

Use the primary key id .

This specifies that it can be null twice: once nullable(), and once in the statement.

In fact customConstraint, the former will be covered. But we still need the former, which is used to indicate that the field can be null in the generated class.

In addition, when the category is deleted, the corresponding todo is also deleted.

Foreign keys are not enabled by default, and need to be run:

customStatement('PRAGMA foreign_keys = ON');

For the join query part, first wrap the two classes into the third class.

class TodoWithCategory {
  final Todo todo;
  final Category category;

  TodoWithCategory({@required this.todo, @required this.category});
}

After that, change TODO's DAO. Note that a table is added here, so it needs to be regenerated.

The previous query method was changed to this:

Stream<List<TodoWithCategory>> watchAllTodos() {
final query = select(todos).join([
  leftOuterJoin(categories, categories.id.equalsExp(todos.category)),
]);

return query.watch().map((rows) {
  return rows.map((row) {
    return TodoWithCategory(
      todo: row.readTable(todos),
      category: row.readTable(categories),
    );
  }).toList();
});
}

The result returned by join is List<TypedResult>, here is converted with the map operator.

Database upgrade

Database upgrade, add new tables and columns when the database is upgraded.

Since foreign keys are not enabled by default, they must be enabled.

PS: The category in Todo has been created before.
The existing columns cannot be modified during migration. So the table can only be discarded and rebuilt.

@UseMoor(tables: [Todos, Categories])
class TodoDatabase extends _$TodoDatabase {
  // we tell the database where to store the data with this constructor
  TodoDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
        onUpgrade: (migrator, from, to) async {
          if (from == 1) {
            migrator.deleteTable(todos.tableName);
            migrator.createTable(todos);
            migrator.createTable(categories);
          }
        },
        beforeOpen: (details) async {
          await customStatement('PRAGMA foreign_keys = ON');
        },
      );
}

I did not expect being given: Unhandled Exception: SqliteException: near "null": syntax error,
mistakes are drop table of phrase:

Moor: Sent DROP TABLE IF EXISTS null; with args []

Say todos.tableName is null.

The design purpose of this get was originally used to specify a custom name:
https://pub.dev/documentation/moor/latest/moor_web/Table/tableName.html

Because I didn't set a custom name, null is returned here.

Here I changed to:

migrator.deleteTable(todos.actualTableName);

Conditional query

Check a certain category:

  Stream<List<TodoWithCategory>> watchTodosInCategory(Category category) {
    final query = select(todos).join([
      leftOuterJoin(categories, categories.id.equalsExp(todos.category)),
    ]);

    if (category != null) {
      query.where(categories.id.equals(category.id));
    } else {
      query.where(isNull(categories.id));
    }

    return query.watch().map((rows) {
      return rows.map((row) {
        return TodoWithCategory(
          todo: row.readTable(todos),
          category: row.readTable(categories),
        );
      }).toList();
    });
  }

The combination of multiple conditions &, such as the above query combination is not complete:

query.where(
        categories.id.equals(category.id) & todos.completed.equals(false));

to sum up

Moor is a third-party package that is used to help local storage of Flutter programs. Since SQL statement query is open, any customization is fine. The author is very enthusiastic and can see his detailed responses under many issues.

This article is to make a TODO app to practice using moor. It
includes basic additions, deletions and changes, foreign keys, database upgrades, etc.

Code: https://github.com/mengdd/more_todo

reference

Finally, welcome to pay attention to WeChat public number: Paladin Wind
WeChat public account

Guess you like

Origin www.cnblogs.com/mengdd/p/flutter-todo-app-database-using-moor.html