はじめに
本記事は、以下のような状況からDBのテーブル定義を更新させるために調べた内容の備忘録で結論は断念していますので、同じような悩みの解決法を知りたかった人はごめんなさい。
状況共有
- Windowsのデスクトップアプリケーションを開発し公開している
- アプリケーションでは、永続化情報を保存する手法として、EFCoreのSqliteのDBを用いて、DBファイルをローカルで保存する形式をとっている
- テーブル定義にはこれ以上更新が入らない点とパフォーマンスを考慮して、マイグレーションの仕組みは入れず、その状態で公開し、開発者以外もインストールしてローカルにDBファイルを持っている状況である
課題
アプリケーションの新機能を考えた場合にどうしてもDBのテーブル定義に変更を加える必要が出てきた。(当時は良くても結局出るよねこういった仕様変更・・・)
選択できる手法は以下の2案が出された
- ①テーブル変更が入った場合は、ローカルDBを作り直すようにする
- ②マイグレーションを後から適用するようになんとかする
①は当然ユーザーのデータが消えることにつながるため可能な限り回避したい。
また、今回の問題をふまえて、DBにはValues
としてモデル上はDictionaryのKey,Valueで動的にメンバを記録して、その情報をJsonの文字列化したものを永続化・復元する定義は増やすことが確定している。
(はじめからこれが入っていればそんなに問題にならなかったのに・・・)
上記の追加も行いたいので、既存DBを消すことなく実現するために②の調査を行った。
マイグレーションを後から適用する仕組みの調査
マイグレーションの導入の具体的な手順はMicrosoftのEFCoreのドキュメントをベースとして実施した以上のことはないため割愛します。
マイグレーションを後から適用しようとした場合に課題となるのが、すでに作成されているDBには__EFMigrationsHistory
テーブルがないため、どこまでのDB変更が入ったバージョンなのか特定できない点です。
データベースに適用された移行についての詳細を Code First Migrations が格納する際に使用するテーブルです。 このデータベース テーブルは、既定で __MigrationHistory という名前が付いており、データベースに対する移行の初回適用時に作成されます。
__EFMigrationsHistory
テーブルによって本来はDBがどこまでの変更が反映されて、何が未反映なのか把握でき、適切な更新を行うことができるのですが、それがないため独自にその機構を実現する必要があります。
正直この時点で、整合性を担保することやリスクの大きさから無理ゲー感があり、私の状況でも公開しているといってもまだ社内での試行運用レベルで、市販ユーザーには影響しないタイミングであったため①で行くと判断がされました。
マイグレーションを後から適用するようになんとかする
ここからは、本当に調査してここまではできそうだが、これ以上は課題やメンテナンス性に難があるとした内容のただの備忘録です。
テーブル定義を更新すること自体は可能
すでに公開しているDBのテーブル定義のバージョンをV1.0.0
として、
テーブルの列の追加とテーブル自体も1つ増やす新テーブル定義のバージョンをV1.0.1
とします。
ここでマイグレーションファイルをV1.0.0
までのテーブル変更履歴と、V1.0.0
→V1.0.1
の差分のテーブル変更履歴の2つ用意し、後者の差分のマイグレーションファイルだけを既存DBにDbContext.Database.Migrate();
して利用すれば、テーブル定義が更新されエラーなくアプリケーションで利用することができます。
上記方法の課題
-
DBの新規作成時にもMigrateすると
V1.0.0
→V1.0.1
の差分の更新をしようとするが、新規作成時は最新のテーブル定義からDbContextを用意しているので、すでにテーブルがあるとしてエラーになるMicrosoft.Data.Sqlite.SqliteException: 'SQLite Error 1: 'table "NewTable" already exists'.'.
-
V1.0.0
→V1.0.1
しかテーブル変更が発生していなければ良いが、V1.0.2
やそれ以降にテーブル定義に変更が入ったとすると、適用元のバージョンを把握して、適切に変更点を反映するように独自に設計しなければならない。
上記のどちらも実装側でDBの状態を把握し、バージョンを特定して、新規作成ならMigrateせずにEnsureCreatedするだとか、バージョンごとに必要な変更点だけを反映するようにしなければなりません。
Migrateは、プロジェクトのMigrations
フォルダの上から順に実行しようとするので、必要な変更のマイグレーションファイルだけを判断してMigrations
フォルダに配置するよう実装して、Migrateすれば一応形にはなります・・・
よって、既存DBを消さずにバージョンアップ自体は可能な仕組みにできますが、今後DB更新のたびにここのロジックを保守していく必要があり、割に合いません。
結論
結論としては、はじめから設計しておこうねとしか言いようがありませんが、パフォーマンスの面では、マイグレーション自体はしないようにできるならしないほうが良いので、テーブル定義に可能な限り変更が入らないようにValues
のようなテーブル定義そのままに新しいメンバ定義を追加できる仕組みは最低限入れておこうという学びです。
以下、Valuesを用するテーブルを持つDbContextの一例です。
/// <summary>
/// データベースのコンテキスト
/// </summary>
internal class SampleDbContext : DbContext
{
public DbSet<Human> Humans { get; set; }
private string m_DatabasePath;
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="databasePath"></param>
public SampleDbContext(string databasePath)
{
// データベースファイルの保存先パスを取得する
m_DatabasePath = databasePath;
}
/// <summary>
/// データベースのコンテキストのオプション
/// DBファイルを指定した保存先パスに保存する
/// </summary>
/// <param name="options"></param>
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite($"Data Source={m_DatabasePath}");
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<tDataContext>().ToTable("Contexts");
// HumanとDataContextのリレーションシップを設定
modelBuilder.Entity<Human>()
.HasOne(r => r.Context)
.WithOne()
.HasForeignKey<DataContext>(c => c.Id);
}
}
public class Human
{
// ...
public DataContext? Context { get; set; }
}
public class DataContext
{
#region フィールド
/// <summary>
/// 値
/// </summary>
private Dictionary<string, string> m_Values = new Dictionary<string, string>();
#endregion
#region プロパティ
/// <summary>
/// ユニークなId
/// </summary>
public string Id { get; private set; } = Guid.NewGuid().ToString();
/// <summary>
/// 値の文字列
/// </summary>
public string ValuesString
{
get { return JsonUtil.Serialize(m_Values); }
set { m_Values = JsonUtil.Deserialize<Dictionary<string, string>>(value) ?? new Dictionary<string, string>(); }
}
#endregion
#region 公開メソッド
/// <summary>
/// 型を指定して値の取得する。
/// </summary>
/// <typeparam name="T">型</typeparam>
/// <param name="key">値のキー</param>
/// <param name="defaultValue">値が取得できなかった場合の値</param>
/// <returns>取得する値</returns>
public T? GetValue<T>(string key, T? defaultValue = default)
{
if (m_Values.TryGetValue(key, out string? valueString))
{
// Tがnullable型であるかをチェック
Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
try
{
// valueStringをT型に変換する
object convertedValue = Convert.ChangeType(valueString, targetType);
return (T)convertedValue;
}
catch (FormatException)
{
// フォーマットが無効な場合はdefaultValueを返す
return defaultValue;
}
catch (InvalidCastException)
{
// キャストが失敗した場合はdefaultValueを返す
return defaultValue;
}
}
return defaultValue;
}
/// <summary>
/// 値を設定する。
/// </summary>
/// <param name="key">値のキー</param>
/// <param name="value">値</param>
public void SetValue(string key, object value)
{
var valueString = value == null ? string.Empty : value.ToString()!;
if (m_Values.ContainsKey(key))
{
m_Values[key] = valueString;
}
else
{
m_Values.Add(key, valueString);
}
}
#endregion
}