前回は、マイグレーションでビューや関数を管理する方法を見ました。
今回は、SQL Server の履歴テーブルを EF Core から扱い、リポジトリとして整理する方法を見ます。
業務アプリケーションでは、「今の値」だけでなく「以前はどうだったか」を知りたいことがあります。
- 商品価格がいつ変わったか
- 顧客情報を誰かが更新した後、元の値を確認したい
- 削除前の状態を調べたい
- ある日時の在庫状態を再現したい
このような用途で、履歴テーブルは強力です。
履歴テーブルとは
履歴テーブルを有効にしたテーブルでは、現在の行とは別に、過去の行が履歴として保存されます。
たとえば Inventory テーブルの車情報を更新すると、更新前の値が履歴側に残ります。
アプリケーションからは、現在のデータだけでなく、特定時点のデータや変更履歴を問い合わせられます。
EF Core では、SQL Server の履歴テーブル機能に対応しています。
エンティティを履歴対応にする
設定は OnModelCreating() で行います。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Car>()
.ToTable("Inventory", table =>
{
table.IsTemporal(temporal =>
{
temporal.HasPeriodStart("ValidFrom");
temporal.HasPeriodEnd("ValidTo");
temporal.UseHistoryTable("InventoryHistory");
});
});
}
ValidFrom と ValidTo は、その行が有効だった期間を表す列です。
履歴テーブル名を明示しておくと、データベースを確認するときにもわかりやすくなります。
マイグレーションを作成すると、履歴対応のテーブル変更が生成されます。
dotnet ef migrations add EnableCarHistory
dotnet ef database update
既存データがあるテーブルを履歴対応にする場合は、生成された SQL を必ず確認してください。
制約や既存列との衝突があると、適用に失敗することがあります。
現在のデータを取得する
通常の問い合わせは、これまでと同じです。
var cars = await context.Cars
.OrderBy(car => car.Make)
.ToListAsync();
この場合、取得されるのは現在の行です。
履歴側の行は含まれません。
通常の画面では、ほとんどの場合この問い合わせで十分です。
すべての履歴を取得する
履歴を含めて取得するには、専用の問い合わせを使います。
var history = await context.Cars
.TemporalAll()
.Where(car => car.Id == id)
.OrderBy(car => EF.Property<DateTime>(car, "ValidFrom"))
.Select(car => new CarHistoryItem
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
ValidFrom = EF.Property<DateTime>(car, "ValidFrom"),
ValidTo = EF.Property<DateTime>(car, "ValidTo")
})
.ToListAsync();
履歴期間の列は、エンティティのプロパティとして定義していない場合でも、EF.Property<T>() で参照できます。
表示用モデルです。
public sealed class CarHistoryItem
{
public int Id { get; init; }
public string Make { get; init; } = "";
public string Color { get; init; } = "";
public string PetName { get; init; } = "";
public DateTime ValidFrom { get; init; }
public DateTime ValidTo { get; init; }
}
履歴一覧では、現在のデータと過去のデータを区別できるように、期間情報を一緒に表示するとわかりやすくなります。
特定時点のデータを取得する
ある日時の状態を取得するには、時点指定の問い合わせを使います。
public async Task<CarDetail?> GetAtAsync(int id, DateTime pointInTime)
{
return await _context.Cars
.TemporalAsOf(pointInTime)
.Where(car => car.Id == id)
.Select(car => new CarDetail
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
IsDrivable = car.IsDrivable,
DateBuilt = car.DateBuilt
})
.SingleOrDefaultAsync();
}
「昨日の終業時点でどう見えていたか」のような確認に使えます。
履歴問い合わせでは、日時の扱いが重要です。
保存時刻が UTC なのかローカル時刻なのかを、アプリケーション全体でそろえておきます。
期間を指定して取得する
特定期間に有効だった行を取得することもできます。
public async Task<List<CarHistoryItem>> GetHistoryBetweenAsync(
int id,
DateTime from,
DateTime to)
{
return await _context.Cars
.TemporalBetween(from, to)
.Where(car => car.Id == id)
.OrderBy(car => EF.Property<DateTime>(car, "ValidFrom"))
.Select(car => new CarHistoryItem
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
ValidFrom = EF.Property<DateTime>(car, "ValidFrom"),
ValidTo = EF.Property<DateTime>(car, "ValidTo")
})
.ToListAsync();
}
変更の多いデータでは、期間指定がないと履歴が大量になることがあります。
画面や API では、取得範囲を指定できるようにしておくと安全です。
履歴対応リポジトリのインターフェイス
履歴を扱う操作を、通常のリポジトリとは分けて表現します。
public interface ITemporalRepository<T>
where T : class
{
Task<List<T>> GetAllHistoryAsync(int id);
Task<T?> GetAtAsync(int id, DateTime pointInTime);
}
エンティティの主キー名が常に Id である前提なら、共通実装も可能です。
public class TemporalRepository<T> : ITemporalRepository<T>
where T : class
{
protected readonly AutoLotContext Context;
protected readonly DbSet<T> Table;
public TemporalRepository(AutoLotContext context)
{
Context = context;
Table = context.Set<T>();
}
public async Task<List<T>> GetAllHistoryAsync(int id)
{
return await Table
.TemporalAll()
.Where(entity => EF.Property<int>(entity, "Id") == id)
.ToListAsync();
}
public async Task<T?> GetAtAsync(int id, DateTime pointInTime)
{
return await Table
.TemporalAsOf(pointInTime)
.SingleOrDefaultAsync(entity =>
EF.Property<int>(entity, "Id") == id);
}
}
ただし、実務では履歴表示用のモデルへ投影することが多いです。
共通実装は便利ですが、画面に出したい形がエンティティごとに違うなら、専用リポジトリで明示した方が読みやすくなります。
車専用の履歴リポジトリ
車の履歴表示に特化したリポジトリを作ります。
public interface ICarHistoryRepository
{
Task<List<CarHistoryItem>> GetHistoryAsync(int carId);
Task<CarDetail?> GetAtAsync(int carId, DateTime pointInTime);
}
実装です。
public sealed class CarHistoryRepository : ICarHistoryRepository
{
private readonly AutoLotContext _context;
public CarHistoryRepository(AutoLotContext context)
{
_context = context;
}
public async Task<List<CarHistoryItem>> GetHistoryAsync(int carId)
{
return await _context.Cars
.TemporalAll()
.Where(car => car.Id == carId)
.OrderByDescending(car => EF.Property<DateTime>(car, "ValidFrom"))
.Select(car => new CarHistoryItem
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
ValidFrom = EF.Property<DateTime>(car, "ValidFrom"),
ValidTo = EF.Property<DateTime>(car, "ValidTo")
})
.ToListAsync();
}
public async Task<CarDetail?> GetAtAsync(int carId, DateTime pointInTime)
{
return await _context.Cars
.TemporalAsOf(pointInTime)
.Where(car => car.Id == carId)
.Select(car => new CarDetail
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
IsDrivable = car.IsDrivable,
DateBuilt = car.DateBuilt
})
.SingleOrDefaultAsync();
}
}
この形なら、呼び出し側は履歴テーブルの詳細を知る必要がありません。
「履歴を取得する」「特定時点の状態を取得する」という目的だけを見ればよくなります。
履歴テーブルを使うときの注意点
履歴テーブルは便利ですが、万能ではありません。
まず、履歴は増え続けます。
更新頻度の高いテーブルでは、保存容量や検索性能を考える必要があります。
次に、履歴は「なぜ変更されたか」までは教えてくれません。
誰が、どの画面から、どんな理由で変更したかを残したいなら、監査ログも別に検討します。
また、履歴問い合わせは通常の一覧より重くなることがあります。
画面では期間指定やページングを用意し、必要以上に広い範囲を取得しないようにします。
この記事のまとめ
履歴テーブルを使うと、現在のデータだけでなく、過去の状態も EF Core から問い合わせられます。
データアクセス層では、履歴問い合わせの詳細をリポジトリに閉じ込めると扱いやすくなります。
呼び出し側は、TemporalAll() や期間列の名前を知らなくても、必要な履歴を取得できます。
データアクセス層を作る目的は、単にコードを分けることではありません。
データベースの機能を、アプリケーションの言葉として安全に使えるようにすることです。