SQLiteでのデータ永続化

body layout

データ保存

今回はFlutterでのデータ保存の方法を解説していきます。
利用するのはSQLiteです。
AndroidやiOSでアプリ開発をしたことがある方は馴染みがあると思いますが、アプリ開発をしていない人にとってはあまり聞きなれないかもしれません。
SQLiteはアプリ向けのデータベースです。

データベースについてやSQLite、SQLについてはここでは説明しませんので、ご自身で学習をおこなってください。

データベース
SQLite
SQL

SQLiteファイルの保存場所

実装をする前にまず知っておいて欲しいのはファイルの保存場所についてです。
AndroidやiOSはそれぞれディレクトリ構成や名称に違いがあり、アプリケーションから操作できるディレクトリ名、それぞれの役割についても違います。
このまま利用しようとすると、OSを判定して、それぞれ適切な場所にSQLiteの情報を作成する必要が出てきます。
そのため、プラグインを使って解決します。

依存関係の追加

プラグインを追加するためにpubspec.ymlを編集しましょう。

  • sqfliteパッケージはSQLiteデータベースとやり取りするためのクラスと関数を提供してくれます。
  • pathパッケージは、データベースをディスクを格納する場所を定義する機能を提供してくれます。
dependencies:
  flutter:
    sdk: flutter
  sqflite:
  path:

データモデルを定義する。

メモを保存するためのテーブルを作成する前に、保存する必要があるテーブルデータを定義してみましょう。

class Memo {
  final int id;
  final String text;
  final int priority;

  Memo({this.id, this.text, this.priority});
}

データベースに接続する。

データベースへの接続を定義しましょう。

  1. getDatabasesPath()でデータベースファイルを保存するパスを取得します。
  2. openDatabase()でデータベースに接続します。
final Future<Database> database = openDatabase(
  join(await getDatabasesPath(), 'memo_database.db'),
);

Memoテーブルの作成をする。

openDatabase()の第二引数にonCreate()を定義することで、SQLiteのテーブルを作成することができます。

final Future<Database> database = openDatabase(
  join(await getDatabasesPath(), 'memo_database.db'),
  onCreate: (db, version) {
    return db.execute(
      "CREATE TABLE memo(id INTEGER PRIMARY KEY, text TEXT, priority INTEGER)",
    );
  },
  version: 1,
);

openDatabase()は他にも以下のようなことができます。

  • onConfigureでは、SQLiteの設定を行うことができます。
  • onCreateでは初期定義を行います。基本的にはこの中でテーブルを作成してください。
  • onUpgradeではデータ定義の更新を行います。アプリリリース後にデータ定義を変更したいときに利用します。
  • onDowngradeではデータ定義の更新を取り消すときに利用します。
  • readOnlyではデータベースを読み込み専用のデータとして利用したいときにtrueを設定してください。

※マイグレーションについては、こちらを確認してください。

SQLiteで利用できるデータ型は以下を確認してください。 Storage Classes and Datatypes

データの挿入

先ほど作ったMemoテーブルにデータを登録しましょう。

class Memo {
  final int id;
  final String text;
  final int priority;

  Memo({this.id, this.text, this.priority});

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'text': text,
      'priority': priority,
    };
  }
}

Future<void> insertMemo(Memo memo) async {
  final Database db = await database;
  await db.insert(
    'memo',
    memo.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}

final todo = Memo(
  id: 0, 
  text: 'Flutterで遊ぶ', 
  priority: 1,
);

await insertMemo(todo);

先ほど作ったMemoデータモデルにメソッドを追加します。

Map<String, dynamic> toMap() {
  return {
    'id': id,
    'text': text,
    'priority': priority,
  };
}

これによりMemo型からMapに変換できるようになりました。

Future<void> insertMemo(Memo memo) async {
  final Database db = await database;
  await db.insert(
    'memo',
    memo.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}

そして保存の処理はこのように書きます。 openDatabase()で作成したインスタンスに対して、insert()メソッドを使ってデータを保存します。

  await db.insert(
    'memo',
    memo.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );

insert()では、対象のテーブル名、保存するデータのMap、コンフリクト時のアルゴリズムを指定しています。
コンフリクト時のアルゴリズムとは、SQLiteが持っている機能で、INSERTやUPDATEのときにConflictが発生したらどのように振る舞うかを定義しておくことができる機能です。
この機能は多くのSQLでは取り扱っていないSQLite特有の非標準の拡張です。

以下のような種類があります。

ConflictAlgorithmSQLに付与されるコマンド詳細
rollbackOR ROLLBACKSQLステートメントを中止して現在のトランザクションをロールバックします。
abortOR ABORTSQLステートメントを中止してSQLステートメントによって行われた変更を取り消します。同じトランザクション内の前の変更は保存され、トランザクションもアクティブなままになります。SQLiteではデフォルトの動作としてこのような動きをするので注意が必要です。
failOR FAILSQLステートメントを中止しますが以前の変更を取り消すこともトランザクションを終了することもしません。
ignoreOR IGNORESQLステートメントを中止しますが後続行の処理を、何も問題がないように継続します。
replaceOR REPLACESQLステートメントと競合している対象レコードを削除し、SQLステートメントを実行します。

詳細な仕様については以下を確認してください。
Conflict

insert()メソッドを利用せずに直接SQLを書きたい場合は以下のように書くことができます。

await db.rawInsert('INSERT INTO memo (id, text, priority) VALUES (?, ? , ?)', [memo.id, memo.text, memo.priority]);

ただし先ほどのようにConflictAlgorithmを設定することはできません。

データの取得

先ほど作成したデータを取得できるようにしましょう。

Future<List<Memo>> getMemos() async {
  final Database db = await database;
  final List<Map<String, dynamic>> maps = await db.query('memo');
  return List.generate(maps.length, (i) {
    return Memo(
      id: maps[i]['id'],
      text: maps[i]['text'],
      priority: maps[i]['priority'],
    );
  });
}

まずは以下のようにqueryを定義します。

final List<Map<String, dynamic>> maps = await db.query('memo');

今回は全件取得するqueryのため何も条件をつけていませんが、通常であれば検索条件が必要です。
検索条件は以下のものが設定できます。
SQLを理解していれば内容は全て理解できると思うので説明は省略します。

{
  bool distinct,
  List<String> columns,
  String where,
  List<dynamic> whereArgs,
  String groupBy,
  String having,
  String orderBy,
  int limit,
  int offset
}

条件の指定の例だけは載せておきます。

final id = 1;
await db.query('memo', where: 'id = ?', whereArgs: [id]);

複数の条件を指定したい場合は通常のSQLと同じでANDやORで繋いでください。
また、このような書き方以外にも以下のようにも書くことができます。

await db.rawQuery('SELECT * FROM memo WHERE id = ?', [id]);

そして実行した戻り値をMemo型のリストに詰め直して返して完了です。

  return List.generate(maps.length, (i) {
    return Memo(
      id: maps[i]['id'],
      text: maps[i]['text'],
      priority: maps[i]['priority'],
    );
  });

LIKE句を使いたい場合は以下のように書くことができます。 このように書くことで「Flutter」から始まるtextにマッチします。

final text = 'Flutter';
await db.query('memo', where: 'text LIKE ?', whereArgs: ['${text}%']);

IN句を使いたい場合は以下のように書くことができます。

final ids = [1, 2]
await db.query('memo', where: 'id IN (${ids.join(', ')})');

データの更新

データの更新は以下のように行います。

Future<void> updateMemo(Memo memo) async {
  // Get a reference to the database.
  final db = await database;
  await db.update(
    'memo',
    memo.toMap(),
    where: "id = ?",
    whereArgs: [memo.id],
    conflictAlgorithm: ConflictAlgorithm.fail,
  );
}

以下のように書くことで、データの更新が行えます。

  await db.update(
    'memo',
    memo.toMap(),
    where: "id = ?",
    whereArgs: [memo.id],
    conflictAlgorithm: ConflictAlgorithm.fail,
  );

書き方はINSERTの時とほぼ一緒で、wherewhereArgsによって更新対象条件の絞り込みを行うことができます。

データの削除

データ削除は以下のように行います。

Future<void> deleteMemo(int id) async {
  final db = await database;
  await db.delete(
    'memo',
    where: "id = ?",
    whereArgs: [id],
  );
}

以下のように書くことで、データの削除が行えます。

  await db.delete(
    'memo',
    where: "id = ?",
    whereArgs: [id],
  );

書き方は UPDATEの時とほぼ一緒です、wherewhereArgsによって更新対象条件の絞り込みを行うことができます。
ただし、SQLiteの仕様上、ConflictAlgorithmを指定することはできません。

最後に

データの活用はアプリの機能の幅を広げるために必須なので、書き方をしっかり覚えておきましょう。 ただ、SQLの取り扱いはセキュリティの状態を左右するためセキュリティ知識も必要です。

INSERT、SELECT、UPDATE、DELETEそれぞれについて説明しましたが、SQLに値を渡すときは"id = ${memo.id}"のように渡すようなことはしないでください。 必ずプリペアードステートメントで記載してください。
"id = ${memo.id}"のような文字列補間を行うとSQLインジェクション攻撃が行えてしまうため、セキュリティ状態が極めて悪いアプリとなってしまいます。

import 'dart:async';

import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

void main() async {
  final database = openDatabase(
    join(await getDatabasesPath(), 'memo_database.db'),
    onCreate: (db, version) {
      return db.execute(
        "CREATE TABLE memo(id INTEGER PRIMARY KEY, text TEXT, priority INTEGER)",
      );
    },
    version: 1,
  );

  Future<void> insertMemo(Memo memo) async {
    final Database db = await database;
    await db.insert(
      'memo',
      memo.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<List<Memo>> getMemos() async {
    final Database db = await database;
    final List<Map<String, dynamic>> maps = await db.query('memo');
    return List.generate(maps.length, (i) {
      return Memo(
        id: maps[i]['id'],
        text: maps[i]['text'],
        priority: maps[i]['priority'],
      );
    });
  }

  Future<void> updateMemo(Memo memo) async {
    // Get a reference to the database.
    final db = await database;
    await db.update(
      'memo',
      memo.toMap(),
      where: "id = ?",
      whereArgs: [memo.id],
      conflictAlgorithm: ConflictAlgorithm.fail,
    );
  }

  Future<void> deleteMemo(int id) async {
    final db = await database;
    await db.delete(
      'memo',
      where: "id = ?",
      whereArgs: [id],
    );
  }

  var memo = Memo(
    id: 0,
    text: 'Flutterで遊ぶ',
    priority: 1,
  );

  await insertMemo(memo);

  print(await getMemos());

  memo = Memo(
    id: memo.id,
    text: memo.text,
    priority: memo.priority + 1,
  );
  await updateMemo(memo);

  // Print Fido's updated information.
  print(await getMemos());

  // Delete Fido from the database.
  await deleteMemo(memo.id);

  // Print the list of dogs (empty).
  print(await getMemos());
}

class Memo {
  final int id;
  final String text;
  final int priority;

  Memo({this.id, this.text, this.priority});

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'text': text,
      'priority': priority,
    };
  }
  @override
  String toString() {
    return 'Memo{id: $id, tet: $text, priority: $priority}';
  }
}

参考

Persist data with SQLite db.queryで使える要素 IN句について