💽

[Flutter] sqfliteのopenDatabase詳解

2024/12/18に公開

sqfliteを利用しようとした際に、基本的なDatabaseインスタンス管理等の実装パターンについて悩んだので、調査結果についてのメモになります。
主にopenDatabaseの処理フローと、それを踏まえマイグレーションなどを考えた実装パターンの個人的なメモを記載します。

sqfliteについて

sqfliteは、FlutterアプリケーションでSQLiteデータベースを利用するための人気のパッケージです。

主な機能

sqfliteパッケージには以下のような主要な機能があります

  • データの挿入、取得、更新、削除(CRUD操作)
  • クエリの実行
  • トランザクション管理
  • テーブルの作成と管理
  • データベースの自動バージョン管理
  • 背景スレッドでのデータベース操作(iOSおよびAndroid)

簡単な使用方法

  1. インストール

pubspec.yamlファイルに以下を追加します

dependencies:
  sqflite: ^2.2.8+4
  path: ^1.8.0

その後、flutter pub getコマンドを実行してパッケージをインストールします。

  1. データベースの作成

以下のようなコードでデータベースを作成し、テーブルを定義できます

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

Future<Database> openDatabase() async {
  String databasesPath = await getDatabasesPath();
  String dbPath = join(databasesPath, 'my_database.db');

  // 今回、取り扱うのは主にここ
  return await openDatabase(
    dbPath,
    version: 1,
    onCreate: (Database db, int version) async {
      await db.execute(
        "CREATE TABLE Users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
      );
    },
  );
}
  1. データの操作

sqfliteでは、insert(), query(), update(), delete()などのメソッドを使用してデータを操作できます

今回は、2のデータベースの作成時に必要なopenDatabase処理について取り扱います

openDatabaseについて

openDatabase メソッドは、指定されたパスでデータベースを開き、各DB操作を行う Databaseインスタンスを取得します。
まずopenDatabaseの定義は下記になっており、該当DBを指定するpath以外に、DBの設定・作成時に呼び出されるコールバック関数とインスタンスの扱いを決めるプロパティを指定できます。

Future<Database> openDatabase(String path,
    {int? version,
    OnDatabaseConfigureFn? onConfigure,
    OnDatabaseCreateFn? onCreate,
    OnDatabaseVersionChangeFn? onUpgrade,
    OnDatabaseVersionChangeFn? onDowngrade,
    OnDatabaseOpenFn? onOpen,
    bool? readOnly = false,
    bool? singleInstance = true}) {
    ...
}

openDatabaseで指定可能なパラメーター

各パラメーターについて、コードコメントからの抜粋です

  • version : 開かれるデータベースのスキーマバージョンを指定します。これによって、 onCreateonUpgrade、および onDowngrade のいずれが呼び出されるかが決まります。
  • onConfigure : データベースを開く際に最初に呼び出されるコールバックです。これを用いて、外部キーの有効化や先行書き込みログの有効化などのデータベース初期化を行うことができます。
  • onCreate : openDatabase を呼び出す前にデータベースが存在しなかった場合に呼び出されます。この機会を利用して、スキーマに従って必要なテーブルをデータベース内に作成できます。
  • onUpgrade : onCreate が指定されていない。またはデータベースがすでに存在し、version が最後のデータベースバージョンより高い場合に呼び出されます。
    • onCreateが指定されていないケースでは、oldVersionパラメータを0として呼び出されます。
  • onDowngrade : version が最後のデータベースバージョンより低い場合にのみ呼び出されます。これは稀なケースで、データベースが古いバージョンのコードによって操作されるようなことは避けるべきとしてます。
  • onOpen : 呼び出される最後のオプションのコールバックです。データベースのバージョンが設定された後、openDatabase が戻る前に呼び出されます。
  • readOnly : trueの場合、他のすべてのパラメータは無視され、データベースはそのままで開かれます。(デフォルトはfalse)
  • singleInstance : 指定されたパスに対して単一のデータベースインスタンスが返されます。同じパスでの後続の openDatabase 呼び出しは同じインスタンスを返し、その呼び出し時のコールバックのような他のすべてのパラメータは無視されます。(デフォルトはtrue)

コールバック処理のフロー

複数のコールバック関数を指定すると、インスタンスの作成時は以下の順序で呼び出されます。

  1. onConfigure
  2. onCreate または onUpgrade または onDowngrade
  3. onOpen

2の onCreate , onUpgrade , onDowngrade は排他的に呼び出され一回のインスタンス作成時にはいずれかのみが呼び出されます。

定義を書き出してみると、マイグレーションを順に行うのであれば onCreate を定義せずに onUpgrade のみでも担保可能かもしれません。
また、上記では記載してませんが singleInstance で2回目にインスタンス取得が起こる場合は、全コールバック関数は無視されます。

コード例

Databaseのインスタンス管理を行うDataBaseConnectorクラスと、マイグレーション処理を行う DatabaseMigrationクラスを下記のように実装しました。
バージョン管理は DatabaseMigration クラスで行い、Batchなど依存関係のあるまとまった処理は行わないシンプルな想定で書いてます。

データベース作成処理 : DataBaseConnectorクラス

import './database_migration.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';

class DataBaseConnector {
  final String dbName;
  late Future<Database> database;

  DataBaseConnector({
    required this.dbName,
  }) {
    assert(dbName.endsWith('.db'));
    debugPrint('DataBaseConnector($dbName)');
    init();
  }

  init() {
    debugPrint('DataBaseConnector.init($dbName)');
    // getDatabasesPath():デフォルトのデータベース保存用フォルダのパスを取得
    var databasesPath = getDatabasesPath();
    // 取得したパスから本アプリ用にて生成するDB名を指定
    String path = '$databasesPath/$dbName';
    database = openDatabase(
      path,
      // 0より大きい32ビット整数
      version: DatabaseMigration.getLatestVersion(),
      onConfigure: (Database db) async {
        // DBが開かれた際に最初に呼び出される
        debugPrint('onConfigureDatabase');
      },
      // [version] が指定されている場合、[onCreate]、[onUpgrade]、および [onDowngrade] が呼び出される
      // これらの関数は相互排他的で、状況に応じて一つの関数だけが呼び出される
      onCreate: (Database db, int version) async {
        // [openDatabase]を呼び出す前にデータベースが存在しなかった場合に呼び出される
        debugPrint('onCreateDatabase($version)');
        for (var migration in DatabaseMigration.values) {
          for (var sqlCommand in migration.sqlCommands) {
            await db.execute(sqlCommand);
          }
        }
      },
      onUpgrade: (Database db, int oldVersion, int newVersion) async {
        // データベースがすでに存在し、[version]が最後のデータベースバージョンより高い場合に呼び出される
        // onCreateが指定されていない場合には、oldVersion = 0として呼び出される
        debugPrint('onUpgradeDatabase($oldVersion, $newVersion)');
        final List<DatabaseMigration> targetMigrations =
            DatabaseMigration.getCompareMigrations(oldVersion, newVersion);
        for (var migration in targetMigrations) {
          for (var sqlCommand in migration.sqlCommands) {
            await db.execute(sqlCommand);
          }
        }
      },
      onDowngrade: (Database db, int oldVersion, int newVersion) async {
        // データベースがすでに存在し、[version]が最後のデータベースバージョンより低い場合にのみ呼び出されます
        debugPrint('onDowngradeDatabase($oldVersion, $newVersion)');
      },
      onOpen: (Database db) async {
        // DBが開かれる直前に呼び出される
        debugPrint('onOpenDatabase');
        // isDebugModeの場合、データベースの内容を確認する
        _debugSchemaPrint(db);
      },
      // デフォルトで、trueだが後学のために明示的に記載
      // 指定されたパスに対して単一のデータベースインスタンスが返される。
      // 同じパスでの後続の[openDatabase]呼び出しは同じインスタンスを返し、
      // その呼び出し時のコールバックのような他のすべてのパラメータは無視される。
      singleInstance: true,
    );
  }

  Future<void> close() async {
    database.then((db) => db.close());
  }

  void _debugSchemaPrint(Database db) async {
    if (kDebugMode) {
      final List<Map<String, dynamic>> schema =
          await db.rawQuery("SELECT * FROM sqlite_master WHERE type='table'");
      for (var table in schema) {
        print('Table Name: ${table['name']}');
        print('SQL: ${table['sql']}');
      }
    }
  }
}

マイグレーション処理管理 : DatabaseMigrationクラス

enum DatabaseMigration {
  v1(version: 1, sqlCommands: [
    '''
    CREATE TABLE Company (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT
    );
    '''
  ]),
  v2(version: 2, sqlCommands: [
    'ALTER TABLE Company ADD description TEXT',
    '''
    CREATE TABLE Test2 (
        id INTEGER PRIMARY KEY,
        name TEXT,
        value INTEGER,
        num REAL
    );
    ''',
  ]),
  ;

  final int version;
  final List<String> sqlCommands;

  const DatabaseMigration({
    required this.version,
    required this.sqlCommands,
  });

  static int getLatestVersion() {
    return values.last.version;
  }

  static List<DatabaseMigration> getCompareMigrations(
    int oldVersion,
    int currentVersion,
  ) {
    assert(oldVersion >= 0);
    assert(currentVersion >= 1);
    return values.where((migration) {
      return migration.version > oldVersion &&
          migration.version <= currentVersion;
    }).toList();
  }
}

参考

部分的には、公式に下記のガイドが提供されているので合わせて参考にしてみてください

Discussion