Flutter で sqfentity を使って SQLite のレコードをアクティブレコードとして扱う

Flutter上で大量のデータを管理・検索しようと思ったため、SQLiteを使ってみます。

生のSQL文を書くより、ORマッパーを使いたいと思ったため、アクティブレコードパターンっぽくレコードを扱える sqfentity ライブラリを使います。

SQLiteのDBファイルの作成、DDL文の発行などはすべてライブラリがやってくれますので、生SQLを一切触らずにアプリで利用できます。

![Kapture 2019-12-10 at 16.47.04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/b5315465-34ca-0160-578d-26a99b9ebe1b.gif)

簡単なデモアプリのコード https://github.com/ytyng/todo-sqf-flutter

# sqfentity

https://pub.dev/packages/sqfentity

依存ライブラリに build_runner が入っています。このライブラリは、テーブルの定義を書いて、そこからbuild_runner でモデルコードを生成し、アプリでそれを使います。

最初は、ひと手間かかる感じが少し面倒そうだったのでコードジェネレートに少し抵抗があったのですが、生成済みのコードは機能リファレンスのように使えるため、なかなか使い勝手は良いです。

なお、DBの内容はアプリを再起動しても消えませんが、アプリをアンインストールすると消えます。

# セットアップ
## 新規 Flutterプロジェクトの作成

空のリポジトリを作成し、その中に新たな Flutter プロジェクトを作ります。

```
mkdir todo-sqf-flutter
cd todo-sqf-flutter

flutter create app
```

作った todo-sqf-flutter ディレクトリを、Flutter プラグイン組み込み済みの Android Studio で開きます。


右上 Add Configuration

![Screenshot 2019-12-10 11.56.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/2496e208-8623-3a9f-891f-fdbfafcdd3db.png)

`+` → ○ more items → Flutter 

![Screenshot 2019-12-10 11.56.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/12d18c58-f4f5-f164-35d5-ec7057dc6265.png)


dart entry point は app/lib/main.dart

![Run_Debug_Configurations.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/297ad7dd-16c9-24e5-cf5f-c908327396a2.png)


Dart SDK Not found になる場合は、 Fix → Flutterプラグインの場所を指定  (Dart でなくてよい)

虫マークをクリックして起動

カウントアップのデモアプリが起動する。


## SQFEntity の組み込み

pubspeck.yaml に 追加

```yaml:pubspeck.yaml
dependencies:
  ...

  sqfentity: ^1.2.2+10
  sqfentity_gen: ^1.2.0+8

dev_dependencies:
  ...

  build_runner: ^1.6.5
  build_verify: ^1.1.0
```

インストール

```
flutter pub get
```

# モデルの作成

lib/models.dart を作成

```dart:lib/models.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'package:sqfentity/sqfentity.dart';
import 'package:sqfentity_gen/sqfentity_gen.dart';

part 'models.g.dart';


@SqfEntityBuilder(todoDbModel)
const todoDbModel = SqfEntityModel(
    modelName: 'TodoDbModel', // optional
    databaseName: 'todo.db',
    databaseTables: [todo],
    bundledDatabasePath: null
);


const todo = SqfEntityTable(
    tableName: 'todo',
    primaryKeyName: 'id',
    primaryKeyType: PrimaryKeyType.integer_auto_incremental,
    useSoftDeleting: true,
    fields: [
      SqfEntityField('title', DbType.text),
      SqfEntityField('active', DbType.bool, defaultValue: true),
    ]);
```

フィールドに、id (PK) と title を持つ、単純なモデルです。

(active というフィールドを作りましたがでもアプリ内では結局使いませんでした)


# コードの生成

```
flutter packages pub run build_runner build --delete-conflicting-outputs
```

このようなモデルを含むコードが生成されます

```dart:lib/models.g.dart
// BEGIN ENTITIES
// region Todo
class Todo {
  Todo({this.id, this.title, this.active, this.isDeleted}) {
    _setDefaultValues();
  }
  Todo.withFields(this.title, this.active, this.isDeleted) {
    _setDefaultValues();
  }
  Todo.withId(this.id, this.title, this.active, this.isDeleted) {
    _setDefaultValues();
  }
  Todo.fromMap(Map<String, dynamic> o) {
    id = o['id'] as int;
    title = o['title'] as String;
    active = o['active'] != null ? o['active'] == 1 : null;
    isDeleted = o['isDeleted'] != null ? o['isDeleted'] == 1 : null;
  }
  ...
```

## アプリに組み込む

### main.dart を修正

使用前に、initializeDB をコールします。

```
void main() async {
  final bool isInitialized = await MyDbModel().initializeDB();
  if (isInitialized == true) {
    runApp(MyApp());
  }
}
```

起動します。ログは、起動時に毎回出ます

```
Syncing files to device iPhone 7...
flutter: init() -> modelname: null, tableName:todo
flutter: >>>>>>>>>>>>>>>>>>>>>>>>>>>> SqfEntityTableBase of [todo](id) init() successfuly
flutter: todo.db created successfully
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (created table)
flutter: SQFENTITIY: The database is ready for use
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (No added new columns)
flutter: SQFENTITIY: The database is ready for use
```


### Insert

```dart
Todo(title: textEditingController.text, active: true).save();
```

リアクティブではないので、実施後手動でウィジェットのリビルドが必要そうです。

### Update

```dart
Todo(id: widget.todoId, title: textEditingController.text,
     active: true).save();
```

save() メソッドは、UPSERT として機能します

### Select


```dart
Todo().select().orderByDesc('id').toList()
```

空引数でクラスインスタンスを作ってから絞り込んでいきます。

戻り値はFuture ですので、単純にウィジェットに表示するだけなら、 FutureBuilder や StreamBuilder 使うと良さそうです。

### Delete

```dart
Todo().select().id.equals(widget.todoId).delete()
```

# スキーママイグレーション

フィールドの追加であれば、models.dart を修正し、build_runner してアプリを再起動すれば、自動的に ALTER TABLE してくれる。データは消えない。


```
flutter: init() -> modelname: null, tableName:todo
flutter: >>>>>>>>>>>>>>>>>>>>>>>>>>>> SqfEntityTableBase of [todo](id) init() successfuly
flutter: SQFENTITIY: alterTableQuery => [ALTER TABLE todo ADD COLUMN created datetime]
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (Added new columns)
flutter: SQFENTITIY: The database is ready for use
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (No added new columns)
flutter: SQFENTITIY: The database is ready for use
```

フィールドの型の変更は例外が出る。サポート外だと思ったほうが良いかも。アプリをアンインストールすればDBファイルが削除されるので、起動できるようになる。

```
[VERBOSE-2:ui_dart_state.cc(148)] Unhandled Exception: Exception: SQFENTITIY DATABASE INITIALIZE ERROR: The type of column [product_id(DbType.integer)] does not match the exiting column [product_id(DbType.text)]
#0      checkTableColumns (package:sqfentity/sqfentity.dart:680:9)
#1      SqfEntityModelProvider.initializeDB (package:sqfentity/sqfentity.dart:508:15)
<asynchronous suspension>
#2      SqfEntityProvider.db (package:sqfentity/sqfentity.dart:58:22)
<asynchronous suspension>
#3      SqfEntityProvider.execDataTable (package:sqfentity/sqfentity.dart:172:36)
<asynchronous suspension>
#4      SqfEntityModelProvider.initializeDB (package:sqfentity/sqfentity.dart:485:14)
<asynchronous suspension>
#5      run (package:mzv/app.dart:40:49)
<asynchronous suspension>
#6      main (package:mzv/main/local.dart:20:3)
<asynchronous suspension>
#7      _runMainZoned.<anonymous closure>.<anonymous closure> (dart:ui/hooks.dart:229:25)
#8      _rootRun (dart:async/zone.dart:1124:13)
#9      _CustomZone.run (dart<…>
```

その他、公式APIドキュメントに豊富な機能が紹介されています。

https://pub.dev/documentation/sqfentity/latest/


# 書いたコード
```dart:main.dart
import 'package:flutter/material.dart';

import 'models.dart';

void main() async {
  final bool isInitialized = await TodoDbModel().initializeDB();
  if (isInitialized == true) {
    runApp(MyApp());
  }
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'Todo App',
        theme: ThemeData(
          primarySwatch: Colors.green,
        ),
        home: MyScaffold(),
      );
}

class MyScaffold extends StatelessWidget {
  final todoWidgetKey = GlobalKey<_TodoState>();

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: Text('Todo'),
        ),
        body: TodoWidget(key: todoWidgetKey),
        floatingActionButton: Builder(builder: (BuildContext context) {
          return FloatingActionButton(
            onPressed: () async {
              await showDialog(
                  context: context,
                  builder: (BuildContext context) {
                    return EditDialog(
                      todoWidgetKey: todoWidgetKey,
                    );
                  });
            },
            child: Icon(Icons.add),
          );
        }),
      );
}

class TodoWidget extends StatefulWidget {
  const TodoWidget({
    Key key,
  }) : super(key: key);

  @override
  _TodoState createState() => _TodoState();
}

class _TodoState extends State<TodoWidget> {
  void update() {
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: Todo().select().orderByDesc('id').toList(),
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          if (snapshot.hasData) {
            return ListView(
              children: snapshot.data.map<Widget>((Todo item) {
                return GestureDetector(
                  onTap: () async {
                    await showDialog(
                        context: context,
                        builder: (BuildContext context) {
                          return EditDialog(
                            title: item.title,
                            todoId: item.id,
                            todoWidgetKey: widget.key,
                          );
                        });
                  },
                  child: Container(
                    decoration: BoxDecoration(
                      border: Border(
                          bottom:
                              BorderSide(width: 1.0, color: Colors.black26)),
                    ),
                    padding: EdgeInsets.all(16),
                    child: Text(item.title),
                  ),
                );
              }).toList(),
            );
          } else {
            return Text('wait');
          }
        });
  }
}

class EditDialog extends StatefulWidget {
  final int todoId;
  final String title;
  final GlobalKey<_TodoState> todoWidgetKey;
  final List<Widget> children;

  const EditDialog({
    Key key,
    this.todoId,
    this.title,
    this.todoWidgetKey,
    this.children,
  }) : super(key: key);

  @override
  _EditDialogState createState() => _EditDialogState();
}

class _EditDialogState extends State<EditDialog> {
  final textEditingController = TextEditingController();

  @override
  initState() {
    super.initState();
    if (widget.title != null) {
      textEditingController.text = widget.title;
    }
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
        content: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
      TextField(
        controller: textEditingController,
        autofocus: widget.todoId == null,
        decoration: InputDecoration(
          labelText: 'Task?',
        ),
      ),
      ...widget.todoId == null
          ? buttonsForCreate(context)
          : buttonsForUpdate(context)
    ]));
  }

  List<Widget> buttonsForCreate(BuildContext context) => <Widget>[
        buildButton(
            context: context,
            labelText: '登録',
            onPressed: () {
              if (textEditingController.text.length > 0) {
                Todo(title: textEditingController.text, active: true).save();
                widget.todoWidgetKey.currentState.update();
              }
              Navigator.of(context).pop();
            })
      ];

  List<Widget> buttonsForUpdate(BuildContext context) => <Widget>[
        buildButton(
            context: context,
            labelText: '更新',
            onPressed: () {
              if (textEditingController.text.length > 0) {
                Todo(
                        id: widget.todoId,
                        title: textEditingController.text,
                        active: true)
                    .save();
                widget.todoWidgetKey.currentState.update();
              }
              Navigator.of(context).pop();
            }),
        buildButton(
            context: context,
            labelText: '消去',
            buttonColor: Colors.red,
            onPressed: () {
              Todo().select().id.equals(widget.todoId).delete();
              widget.todoWidgetKey.currentState.update();
              Navigator.of(context).pop();
            })
      ];
}

Widget buildButton(
    {BuildContext context,
    String labelText,
    VoidCallback onPressed,
    Color buttonColor}) {
  return Padding(
    padding: const EdgeInsets.only(top: 10),
    child: ButtonTheme(
      minWidth: double.infinity,
      height: 50,
      child: RaisedButton(
        color: buttonColor ?? Theme.of(context).primaryColor,
        onPressed: onPressed,
        child: Text(labelText, style: TextStyle(color: Colors.white)),
      ),
    ),
  );
}
```