前回は、グローバルフィルター、生 SQL、投影、データベース生成値など、問い合わせ周辺の便利機能を見ました。
今回は、より実務寄りの機能を扱います。
同時実行制御、接続回復、値変換、シャドウプロパティ、SQL Server の履歴テーブル対応です。
これらは最初の学習では少し地味に見えます。
しかし、業務システムを長く運用する上では非常に重要です。
同時実行制御とは
複数のユーザーが同じデータを編集する場合を考えます。
ユーザーAが車データを開く
ユーザーBも同じ車データを開く
ユーザーAが愛称を変更して保存する
ユーザーBが古い内容のまま色を変更して保存する
このとき、ユーザーAの変更が意図せず上書きされる可能性があります。
これを防ぐために、同時実行制御を使います。
RowVersion を使う
SQL Server では rowversion 列を使った楽観的同時実行制御がよく使われます。
モデルにバージョン列を追加します。
public sealed class Car
{
public int Id { get; set; }
public string Make { get; set; } = "";
public string Color { get; set; } = "";
public string PetName { get; set; } = "";
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
設定です。
modelBuilder.Entity<Car>()
.Property(car => car.RowVersion)
.IsRowVersion();
更新時に、EF Core は取得時の RowVersion を条件に含めます。
別のユーザーが先に更新していると、対象行が見つからず例外になります。
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
Console.WriteLine("他のユーザーによってデータが更新されています。");
}
競合時の対応
競合が起きた場合の対応方針は、業務によって異なります。
- ユーザーへ再読み込みを促す
- データベースの値を優先する
- 自分の変更を再適用する
- 差分を表示して選ばせる
現在値、元の値、データベース値を取得できます。
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
Console.WriteLine("対象データは削除されています。");
continue;
}
Console.WriteLine("データベース上の値:");
foreach (var property in entry.Metadata.GetProperties())
{
object? currentValue = entry.CurrentValues[property];
object? databaseValue = databaseValues[property];
Console.WriteLine($"{property.Name}: current={currentValue}, database={databaseValue}");
}
}
}
同時実行制御は、単に例外を捕まえるだけでなく、ユーザー体験としてどう扱うかが重要です。
接続回復
クラウド環境やネットワーク越しのデータベースでは、一時的な接続失敗が起こることがあります。
SQL Server プロバイダーでは、接続回復を有効にできます。
builder.Services.AddDbContext<AutoLotContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("AutoLot"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
});
});
一時的なエラーに対して、自動的に再試行できます。
ただし、再試行してよい処理かどうかは考える必要があります。
外部 API 呼び出しや副作用のある処理と組み合わせると、意図せず二重実行になる可能性があります。
値変換
値変換を使うと、C# 側の型とデータベース側の保存形式を変換できます。
たとえば、列挙型を文字列として保存する例です。
public enum CarStatus
{
Available,
Sold,
Maintenance
}
public sealed class Car
{
public int Id { get; set; }
public string Make { get; set; } = "";
public CarStatus Status { get; set; }
}
設定です。
modelBuilder.Entity<Car>()
.Property(car => car.Status)
.HasConversion<string>();
データベースには "Available"、"Sold" のような文字列として保存されます。
数値として保存するより読みやすくなりますが、列挙値の名前変更には注意が必要です。
独自の値変換
値オブジェクトを保存形式へ変換することもできます。
public readonly record struct ProductCode(string Value);
public sealed class Product
{
public int Id { get; set; }
public ProductCode Code { get; set; }
}
設定です。
modelBuilder.Entity<Product>()
.Property(product => product.Code)
.HasConversion(
code => code.Value,
value => new ProductCode(value));
C# 側では ProductCode として扱い、データベースには文字列として保存できます。
値変換は便利ですが、複雑な変換を入れすぎるとクエリ変換や保守性に影響します。
単純で意味の明確な変換に使うのが良いです。
シャドウプロパティ
シャドウプロパティは、C# のクラスには存在しないが、EF Core モデル上には存在するプロパティです。
たとえば、全エンティティに作成日時を持たせたいが、ドメインクラスには出したくない場合です。
modelBuilder.Entity<Car>()
.Property<DateTime>("CreatedAt")
.HasDefaultValueSql("SYSUTCDATETIME()");
値を設定するには、Entry() からアクセスします。
Car car = new Car
{
Make = "Honda",
Color = "Blue",
PetName = "Vega"
};
context.Cars.Add(car);
context.Entry(car).Property("CreatedAt").CurrentValue = DateTime.UtcNow;
await context.SaveChangesAsync();
問い合わせでも使えます。
List<Car> cars = await context.Cars
.OrderBy(car => EF.Property<DateTime>(car, "CreatedAt"))
.ToListAsync();
シャドウプロパティは、監査列や外部キー列で使われることがあります。
ただし、存在がコードから見えにくくなるため、チームでの共有が大切です。
SQL Server の履歴テーブル対応
SQL Server には、テーブルの変更履歴を保持する機能があります。
EF Core でも、履歴テーブルを扱う設定ができます。
設定例です。
modelBuilder.Entity<Car>()
.ToTable("Inventory", tableBuilder =>
{
tableBuilder.IsTemporal();
});
履歴テーブル名などを指定することもできます。
modelBuilder.Entity<Car>()
.ToTable("Inventory", tableBuilder =>
{
tableBuilder.IsTemporal(temporalBuilder =>
{
temporalBuilder.UseHistoryTable("InventoryHistory");
temporalBuilder.HasPeriodStart("ValidFrom");
temporalBuilder.HasPeriodEnd("ValidTo");
});
});
過去時点のデータを問い合わせる例です。
DateTime pointInTime = DateTime.UtcNow.AddDays(-1);
List<Car> cars = await context.Cars
.TemporalAsOf(pointInTime)
.ToListAsync();
監査、履歴参照、誤更新の調査などで役立ちます。
実務機能を使うときの考え方
EF Core には多くの便利機能があります。
ただし、すべてを最初から使う必要はありません。
導入の目安です。
- 論理削除が必要ならグローバルフィルター
- 複数ユーザー編集があるなら同時実行制御
- 一時的な接続失敗が想定されるなら接続回復
- 値オブジェクトや列挙型を自然に扱いたいなら値変換
- 監査列をモデルに出したくないならシャドウプロパティ
- 変更履歴が必要なら履歴テーブル
機能を使う目的が明確だと、設計がぶれにくくなります。
まとめ
EF Core は、単に LINQ でデータベースを扱うための道具ではありません。
同時実行制御、接続回復、値変換、シャドウプロパティ、履歴テーブルなど、業務アプリケーションを支える機能も備えています。
大切なのは、機能を知った上で、必要な場面にだけ導入することです。
EF Core の抽象化を活かしつつ、データベースの制約や運用を意識できると、長く保守しやすいデータアクセス層を作れます。