FluentMigratorでSQLiteに対してマイグレーションを行う

FluentMigratorでSQLiteに対してマイグレーションを行う

はじめに

今回の記事では、FluentMigrator というライブラリを使用して、SQLite に対してマイグレーションを実行する方法について解説します。

実際にアプリケーション作成しながら解説します。

開発環境

  • Visual Studio 2022
  • .NET6

FluentMigrator とは?

FluentMigrator とは、C#でデータベースマイグレーションを行うためのライブラリで、データベースのテーブルの変更などを C#のコードで管理することが出来ます。主要なデータベースエンジン(SQL Server、MySQL、PostgreSQL など)をサポートしています。

コードで管理する利点として以下のような点が挙げられます。

  • 同じコードを使用することで、開発環境のデータベースの構造の一貫性を保つことが出来る(誰が実行しても同じものが作成出来る)
  • マイグレーションは特定のバージョンへロールバックすることが出来るので、変更の履歴が追跡可能になる
  • これらのツールを使用することで、手動での変更プロセスを自動化し、ヒューマンエラーを減らすことが出来る

以下のリポジトリのDataGrid-Sample プロジェクトで使用されているダミーデータを、SQLite に置き換えて表示できるようにします。そのデータベースのテーブルを作成する過程を FluentMigrator で管理したいと思います。

FluentMigrator は、Nuget パッケージからインストールすることが出来ます。

作成するアプリケーションの概要

作成するアプリケーションの完成イメージです。

今回はコンソールアプリケーションを使用してマイグレーションの実行を行いたいと思います。

fluent-migrator.gif

コンソールアプリケーション内でコマンドの入力を受け付けるようにします。

マイグレーションファイルの作成

プロジェクト内にUsersTable.csファイルを作成し、Migrationクラスを継承させます。その際にエディタの保管機能で、自動でFluentMigrator名前空間がusingされると思いますが、自動でされなければ手動で追記して下さい。

"ProductTable.cs"
using FluentMigrator; // 追加

namespace DataGrid.Sample.Migrator
{
    // 一意な識別子を持つ Migration 属性を定義する必要がある
    [Migration(1)]
    public sealed class UsersTable : Migration // Migrationを継承
    {
        // 実装が強制されるメソッド
        public override void Up()
        {
            throw new NotImplementedException();
        }

        // 実装が強制されるメソッド
        public override void Down()
        {
            throw new NotImplementedException();
        }
    }
}

UpDownメソッドを以下のように実装します。

"ProductTable.cs"
using FluentMigrator;

namespace DataGrid.Sample.Migrator
{
    [Migration(1)]
    public sealed class UsersTable : Migration
    {
        /// <summary>
        /// マイグレーション実行時に作成するテーブルを定義
        /// </summary>
        public override void Up()
        {
            Create.Table("users")
                // PrimaryKey() -> 主キーに設定
                //  Identity() -> オートインクリメントされるように指定
                .WithColumn("id").AsInt32().PrimaryKey().Identity()
                .WithColumn("first_name").AsString()
                .WithColumn("last_name").AsString()
                .WithColumn("age").AsInt32()
                .WithColumn("birthday").AsDateTime()
                .WithColumn("gender").AsInt16()
                .WithColumn("email").AsString()
                .WithColumn("phone_number").AsString()
                .WithColumn("created_at").AsDateTime()
                .WithColumn("updated_at").AsDateTime();
        }

        /// <summary>
        /// ロールバック時に削除するテーブルを定義
        /// </summary>
        public override void Down()
        {
            Delete.Table("users");
        }
    }
}

マイグレーション実行時に作成されるテーブルとカラム、カラムの型などを定義しました。

詳しくは割愛しますがSQLite は動的に型を決定して格納するため、ここで指定した型は参考情報程度でしかなく同じカラムであってもレコードごとに違う型の値を格納できてしまいます。(SQLite 3.37:2021-11-27 リリースから厳格な型指定が出来るようになっています。)

続いて、Program.csに作成したマイグレーションファイルを実行するプログラムを実装します。

"Program.cs"
using DataGrid.Sample.Migrator;
using FluentMigrator.Runner;
using Microsoft.Extensions.DependencyInjection;
using System.Configuration;

/// <summary>
/// サービスのプロバイダーを作成
/// </summary>
/// <param name="sourcePath">データソースのパス</param>
/// <returns>サービスプロバイダーのインスタンス</returns>
static IServiceProvider CreateServices(string sourcePath)
{
    return new ServiceCollection()
        // 共通のFluentMigratorサービスを追加する
        .AddFluentMigratorCore()
        .ConfigureRunner(rb => rb
            // FluentMigrator に SQLite サポートを追加する。
            .AddSQLite()
            // 接続文字列を設定する
            .WithGlobalConnectionString($"Data Source={sourcePath}")
            // マイグレーションを含むアセンブリを定義する
            .ScanIn(typeof(UsersTable).Assembly).For.Migrations())
        // FluentMigrator の方法でコンソールへのロギングを有効にする。
        .AddLogging(lb => lb.AddFluentMigratorConsole())
        // サービスプロバイダーの構築
        .BuildServiceProvider(false);
}

/// <summary>
/// SQLiteのリソースファイルをコピーする
/// </summary>
static void CopySQLiteResource(string sourcePath)
{
    // 出力先のフォルダ
    string destFolderPath = ConfigurationManager.AppSettings.Get("OutputPath");

    // ファイル名を含めた出力先のパス
    string destFilePath = Path.Combine(destFolderPath, ConfigurationManager.AppSettings.Get("DBResource"));

    try
    {

        // コピー先のフォルダが存在しなければ作成する
        if (!File.Exists(destFolderPath))
        {
            Directory.CreateDirectory(destFolderPath);
        }

        // コピー元のファイルが存在するか確認
        if (File.Exists(sourcePath))
        {
            // ファイルをコピー
            // 第三引数の true は、目的のファイルが存在する場合に上書きすることを意味する
            File.Copy(sourcePath, Path.Combine(destFilePath), true);

        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"コピー時にエラーが発生しました。\n {ex.Message}");;
    }

}

// 実行ファイル(.exe)が実行されている直下のフォルダに存在するデータベースファイルのフルパスを取得
var dataSource = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
                              ConfigurationManager.AppSettings.Get("DBResource"));

var serviceProvider = CreateServices(dataSource);

using (var scope = serviceProvider.CreateScope())
{
    var runner = serviceProvider.GetRequiredService<IMigrationRunner>();

    Console.WriteLine("Please specify a command: 'up' or 'down'.");

    // コンソール画面でユーザーからの入力を待つ
    var input = Console.ReadLine();

    switch (input)
    {
        case "up":
            // マイグレーションを実行する
            runner.MigrateUp();
            break;

        case "down":
            // ロールバックを実行(バージョンを指定)
            runner.MigrateDown(0);
            break;

        default:
            Console.WriteLine("Invalid command. Please use 'up' or 'down'.");
            break;
    }

}

// マイグレーションが実行されたファイルを指定した場所へコピーする
CopySQLiteResource(dataSource);

CreateServicesメソッドでデータベースへの接続文字列や SQLite を扱うための初期化処理が実行されています。

その後、コンソールでユーザーからの入力を待機し、マイグレーションかロールバックを実行します。

console-input

マイグレーション実行後は、指定したフォルダへファイルをコピーします。そのコピーしたファイルをアプリケーションから使用するようにします。

App.config の作成

データベースファイルのコピー先を構成ファイルから指定します。ビルド後もファイルからコピー先を変更できるようにします。

プロジェクトを右クリックしてメニューを開きファイルを追加します。

app-config.png

app-config_02.png

App.configに以下のコードを記述します。

"App.config"
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <add key="OutputPath" value="C:\#DB\" />
        <add key="DBResource" value="SampleMigrator.db" />
    </appSettings>
</configuration>

C#側のコードで呼び出す際は、ConfigurationManager.AppSettings.Get("キーを指定")を使用します。

C:\#DB\というパスを呼び出したければkeyOutputPathConfigurationManager.AppSettings.Getメソッドの引数に指定します。

App.configファイルにビルドアクションは必要ありません。

ビルド後に、出力先のパスの変更方法は後ほど解説します。

データベースファイルの作成

次はデータベースファイルを作成します。コマンドラインを開きます。

create-db-file.png

恐らくカレントフォルダはソリューションのトップに居るので、プロジェクトフォルダに移動しファイルを作成します。

source\repos\Sample-XAML> cd DataGrid.Sample.Migrator

source\repos\Sample-XAML\DataGrid.Sample.Migrator> New-Item SampleMigrator.db

作成したファイルのプロパティを開き、ビルド時に出力ディレクトリに常にコピーされるようにします。

build-cooy.png

ここまで作成したら一度実行してみます。

execute-migration.gif

プログラムが正常に終了し、C:\#DB\フォルダ内にSampleMigrator.dbファイルが作成されていることを確認してください。

作成されたファイルの確認

DBeaverを使用して正しくテーブルが作成されているか確認します。

DBeaver の設定は、接続設定で先ほど作成したファイルを指定するだけです。

dbeaver-sqlite.png

usersテーブルが正しく作成されていることが確認できした。

dbeaver-sqlite_02.png

次に、作成されたテーブル情報を確認します。

SQL エディタを開いて以下のコマンドを実行します。

-- テーブル定義を確認
SELECT * FROM sqlite_master;

出力結果の 3 行目のusersに注目します。

dbeaver-sqlite_03.png

CREATE TABLE "users" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "first_name" TEXT NOT NULL,
    "last_name" TEXT NOT NULL,
    "age" INTEGER NOT NULL,
    "birthday" DATETIME NOT NULL,
    "gender" INTEGER NOT NULL,
    "email" TEXT NOT NULL,
    "phone_number" TEXT NOT NULL,
    "created_at" DATETIME NOT NULL,
    "updated_at" DATETIME NOT NULL
)

テーブルの定義が確認出来ました。

カラムにNullable()メソッドを使用しない限り生成されるカラムはデフォルトで NOT NULL 制約が付与されます。

ロールバックの動作も確認

プログラムを実行してロールバックが正常に実行されるか確認します。

execute-rollback.png

F5 キーで、データベース情報を更新してusersテーブルが削除されていることを確認して下さい。

execute-rollback_02.png

カスタム属性でバージョン管理する

UsersTable.csファイル内のコードでMigration(1)と指定していましたが、これはマイグレーションが実行される順番を管理する一意な番号になります。

バージョン番号が低いマイグレーションが先に適用され、バージョン番号が高いマイグレーションがその後に適用されます。

CustomMigrationAttribute.csファイルを作成し、以下のように記述します。

"CustomMigrationAttribute.cs"
namespace DataGrid.Sample.Migrator
{
    public sealed class CustomMigrationAttribute : FluentMigrator.MigrationAttribute
    {
        /// <summary>
        /// マイグレーションの作者を取得する
        /// </summary>
        public string Author { get; }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="branchNumber">ブランチ番号</param>
        /// <param name="year">年</param>
        /// <param name="month">月</param>
        /// <param name="day">日</param>
        /// <param name="hour">時間</param>
        /// <param name="minute">分</param>
        /// <param name="author">マイグレーションの作者</param>
        public CustomMigrationAttribute(int branchNumber, int year, int month, int day, int hour, int minute, string author)
            : base(CalculateValue(branchNumber, year, month, day, hour, minute))
        {
            Author = author;
        }

        /// <summary>
        /// ブランチ番号、年、月、日、時間、および分から値を計算する
        /// </summary>
        /// <param name="branchNumber">ブランチ番号</param>
        /// <param name="year">年</param>
        /// <param name="month">月</param>
        /// <param name="day">日</param>
        /// <param name="hour">時間</param>
        /// <param name="minute">分</param>
        /// <returns>計算結果の値</returns>
        private static long CalculateValue(int branchNumber, int year, int month, int day, int hour, int minute)
        {
            // 各値に重み付けをする計算を実行
            // branchNumber: 1兆
            // year: 1億
            // month: 100万
            // day: 1万
            // hour: 100
            // minute: デフォルト値
            return branchNumber * 1000000000000L + year * 100000000L + month * 1000000L + day * 10000L + hour * 100L + minute;
        }
    }
}

CalculateValueメソッド内では、渡ってきた引数に重み付けの計算を行い、マイグレーションの順序を管理するバージョン番号を生成しています。

ブランチ番号や年などの値が変更されるとバージョン番号に与える影響も大きくなります。

作成した属性をUsersTable.csで以下のように反映させます。

"UsersTable.cs"
using FluentMigrator;

namespace DataGrid.Sample.Migrator
{
-   [Migration(1)]
+   [CustomMigration(author: "H.Nishihara", branchNumber: 12, year: 2023, month: 7, day: 19, hour: 17, minute: 29)]
    public sealed class UsersTable : Migration
    {
        /// <summary>
        /// マイグレーション実行時に作成するテーブルを定義
        /// </summary>
        public override void Up()
        {
            Create.Table("users")
                // PrimaryKey() -> 主キーに設定
                //  Identity() -> オートインクリメントされるように指定
                .WithColumn("id").AsInt32().PrimaryKey().Identity()
                .WithColumn("name").AsString()
                .WithColumn("age").AsInt32()
                .WithColumn("birthday").AsDateTime()
                .WithColumn("gender").AsInt16()
                .WithColumn("email").AsString()
                .WithColumn("phone_number").AsString()
                .WithColumn("created_at").AsDateTime()
                .WithColumn("updated_at").AsDateTime();
        }

        /// <summary>
        /// ロールバック時に削除するテーブルを定義
        /// </summary>
        public override void Down()
        {
            Delete.Table("users");
        }
    }
}

マイグレーションを実行して、VersionInfoというテーブル内を確認します。

これはMigration(1)としていた場合の画像です。

versioninfo.png

以下の画像はカスタム属性でバージョン管理番号を指定した場合のものです。

versioninfo_02.png

指定した番号が割り振られていることが分かります。

複数のマイグレーションファイルを管理する場合

次に、マイグレーションファイルが複数作成された場合を見ていきます。

ProductTable.csというファイルを作成し、以下のように記述します。テーブルの内容は適当なもので構いません。

"ProductTable.cs"
using FluentMigrator;

namespace DataGrid.Sample.Migrator
{
    // UsersTableより branchNumber をプラス1する
    [CustomMigration(author: "H.Nishihara", branchNumber: 13, year: 2023, month: 7, day: 19, hour: 17, minute: 29)]
    public sealed class ProductTable : Migration
    {

        public override void Up()
        {
            Create.Table("products")
                .WithColumn("id").AsInt32().PrimaryKey().Identity()
                .WithColumn("name").AsString()
                .WithColumn("price").AsInt32()
                .WithColumn("created_at").AsDateTime()
                .WithColumn("updated_at").AsDateTime();
        }

        public override void Down()
        {
            Delete.Table("products");
        }
    }
}

UsersTableテーブルのbranchNumber12でしたが、ProductTableでは13としました。

マイグレーションを実行します。

versioninfo_03.png

UsersTableテーブルが作成された後にProductTableが作成されていることが分かります。

VersionInfoテーブルも確認して見ましょう。

versioninfo 04

ProductTableテーブルのバージョン情報が格納されていることが分かります。

アセンブリ単位でスキャンされる

Program.csファイル内のCreateServicesメソッドで以下のように定義していました。

"Program.cs"
static IServiceProvider CreateServices(string sourcePath)
{
        // 省略

        // マイグレーションを含むアセンブリを定義する
        .ScanIn(typeof(UsersTable).Assembly).For.Migrations())

        // 省略
}

FluentMigratorでは同じアセンブリ(プロジェクト単位)でマイグレーションファイルをスキャンします。

assembly.png

(通常は一つのプロジェクトがコンパイルされるとアセンブリ(.dll または.exe ファイル)が生成されます。)

つまり、UsersTable.csファイルと同じプロジェクト内であればマイグレーションファイルは自動でスキャンされます。

そのため、ProductTableCreateServicesメソッド内のコードを変更しなくても自動的に実行されました。

データをインサートする

FluentMigratorにはテストデータを自動生成してくれる機能はありません。個人情報テストジェネレーターで作成した csv ファイルを読込みインサートすることにします。

dummyData.csvファイルをプロジェクトに追加し、ファイルのプロパティで出力ディレクトリにコピーを常にコピーするに変更します。

そして、csv ファイルを読み込むための static クラスを生成します。

"CSVClient.cs"
using System.Text;

namespace DataGrid.Sample.Migrator
{
    public static class CSVClient
    {
        /// <summary>
        /// 現在処理中の行が1行目か判定する
        /// </summary>
        private static bool _isFirstLine = true;

        /// <summary>
        /// アプリケーションが実行されているディレクトリを取得し、指定した文字列を結合させる
        /// </summary>
        private static string _currentDomain = AppDomain.CurrentDomain.BaseDirectory;

        public static List<List<string>> GetInsertData()
        {
            var entities = new List<List<string>>();

            try
            {
                Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
                using (var reader = new StreamReader(_currentDomain + "dummyData.csv", Encoding.GetEncoding("Shift_JIS")))
                {
                    // 1.whileで1行ずつ読み込む
                    // 2.最初の行はスキップする
                    // 3.split(",") でカンマを基準に各要素を配列に変換する
                    // 4.UserEntityに必要な要素だけインデックスを指定して取り出す

                    while (reader.Peek() >= 0)
                    {
                        var line = reader.ReadLine();

                        if (_isFirstLine)
                        {
                            _isFirstLine = false;

                            continue;
                        }

                        var strArray = line.Split(",");


                        entities.Add(new List<string>
                        {
                            strArray[0],
                            strArray[3],
                            strArray[4],
                            strArray[5],
                            strArray[7],
                            strArray[8]
                        });
                    }

                    return entities;
                }

            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);

                return new List<List<string>>();
            }

        }
    }
}

処理の内容としては以前 DataGrid の使い方に関して解説した記事の内容とほぼ同じです。

UsersTable.csにデータをインサートする処理を追加します。

"UsersTable.cs"
using FluentMigrator;

namespace DataGrid.Sample.Migrator
{
    [CustomMigration(author: "H.Nishihara", branchNumber: 12, year: 2023, month: 7, day: 19, hour: 17, minute: 29)]
    public sealed class UsersTable : Migration
    {
        /// <summary>
        /// マイグレーション実行時に作成するテーブルを定義
        /// </summary>
        public override void Up()
        {
            Create.Table("users")
                // AsInt32 = int
                // AsInt64 = long
                // AsInt16 = short
                .WithColumn("id").AsInt32().PrimaryKey().Identity()
                // AsString = string
                .WithColumn("name").AsString()
                .WithColumn("age").AsInt32()
                // AsDateTime = System.DateTime
                .WithColumn("birthday").AsDateTime()
                .WithColumn("gender").AsInt16()
                .WithColumn("email").AsString()
                .WithColumn("phone_number").AsString()
                .WithColumn("created_at").AsDateTime()
                .WithColumn("updated_at").AsDateTime();

            // データをインサートする
            var entities = CSVClient.GetInsertData();

            if(entities.Count > 0)
            {
                foreach (var entity in entities)
                {
                    Insert.IntoTable("users").Row(new
                    {
                        name = entity[0],
                        age = entity[1],
                        birthday = entity[2],
                        gender = entity[3] == "女" ? 2 : 1,
                        email = entity[4],
                        phone_number = entity[5],
                        created_at = DateTime.Now,
                        updated_at = DateTime.Now
                    });
                }
            }
        }

        /// <summary>
        /// ロールバック時に削除するテーブルを定義
        /// </summary>
        public override void Down()
        {
            Delete.Table("users");
        }
    }
}

マイグレーションを実行してデータがインサートされているか確認します。

SELECT * FROM users;

seeder.png

データがインサートされていることを確認できました。

ビルド後の出力先ファイル変更方法

一通りデータベースのマイグレーションが行える様になりました。

最後に、ビルド後にマイグレーションを実行したデータベースファイルの出力先フォルダの変更方法について確認します。

App.configで指定した設定値は、ビルド後は[プロジェクト名].dll.configというファイルに出力されています。ですので、今回のプロジェクト名で言うとDataGrid.Sample.Migrator.dll.configというファイルになります。

ビルドのモードをReleaseに変更してビルドを実行します。

release.png

プロジェクトからエクスプロイーラーを開き、bin → Release → net6.0 とフォルダを移動します。

release_02.png

release_03.png

DataGrid.Sample.Migrator.dll.configを適当なエディタで開き出力先フォルダを変更してみます。

"DataGrid.Sample.Migrator.dll.config"
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
-       <add key="OutputPath" value="C:\#DB" />
+       <add key="OutputPath" value="C:\#DB11111" />
        <add key="DBResource" value="SampleMigrator.db" />
    </appSettings>
</configuration>

変更後、DataGrid.Sample.Migrator.exeを起動してマイグレーションを実行します。出力先が指定したフォルダに変更されていることを確認します。

release_04.png

最後に

今回はコンソールアプリケーションとして簡素に作成しましたが、小規模なシステムやデータベースで良い場合などは有効な技術選定になるのではないでしょうか。

大規模なシステムだと Entity Framework の恩恵を受けたほうが良いと思いますが…。