前回は、データアクセス層の例外とモデルの仕上げ方を見ました。
今回は、DbContext に集約できる共通ルールを扱います。
DbContext は、エンティティの集合を持つだけの型ではありません。
アプリケーションとデータベースの境界として、共通規約、データベース関数、変更追跡、保存処理のルールを置ける場所です。
文字列や数値の標準設定を決める
すべての文字列プロパティに、ある程度の最大長を設定したい場合があります。
個別に毎回書くと、設定漏れが起きやすくなります。
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<string>()
.HaveMaxLength(100);
configurationBuilder.Properties<decimal>()
.HavePrecision(18, 2);
}
個別の列だけ別の設定にしたい場合は、エンティティ設定で上書きします。
public sealed class CarConfiguration : IEntityTypeConfiguration<Car>
{
public void Configure(EntityTypeBuilder<Car> builder)
{
builder.Property(car => car.Make)
.HasMaxLength(50)
.IsRequired();
builder.Property(car => car.PetName)
.HasMaxLength(30);
}
}
標準値を決めて、例外だけを明示する。
この形にすると、設定の意図が読み取りやすくなります。
データベース関数を C# から呼び出す
データベース側に関数を用意している場合、EF Core の問い合わせから呼び出せるようにできます。
たとえば、車の表示名を作る関数があるとします。
CREATE FUNCTION dbo.GetCarDisplayName
(
@make nvarchar(50),
@color nvarchar(50),
@petName nvarchar(50)
)
RETURNS nvarchar(200)
AS
BEGIN
RETURN @make + N' ' + @color + N' ' + @petName;
END
C# 側に対応するメソッドを用意します。
public static class DatabaseFunctions
{
public static string GetCarDisplayName(
string make,
string color,
string petName)
{
throw new NotSupportedException(
"このメソッドは LINQ から SQL へ変換して使います。");
}
}
DbContext のモデル設定で対応付けます。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var method = typeof(DatabaseFunctions)
.GetMethod(nameof(DatabaseFunctions.GetCarDisplayName))!;
modelBuilder
.HasDbFunction(method)
.HasName("GetCarDisplayName")
.HasSchema("dbo");
}
問い合わせでは、通常のメソッドのように書けます。
var cars = await context.Cars
.Select(car => new
{
car.Id,
Name = DatabaseFunctions.GetCarDisplayName(
car.Make,
car.Color,
car.PetName)
})
.ToListAsync();
実行時には、SQL の関数呼び出しへ変換されます。
ただし、C# メソッド本体が実行されるわけではありません。
組み込み関数を使う
EF Core には、データベース関数を呼び出すための入口もあります。
たとえば SQL Server の LIKE を使う検索です。
var keyword = "%toy%";
var cars = await context.Cars
.Where(car => EF.Functions.Like(car.Make, keyword))
.ToListAsync();
Contains() でも検索はできますが、データベースの機能を明示したい場合は、この形が読みやすいことがあります。
大文字小文字の扱いや照合順序など、データベース側の動作に依存する検索では、どの機能を使っているかを意識することが大切です。
変更追跡イベントで状態を観察する
ChangeTracker のイベントを使うと、エンティティの追跡開始や状態変更を観察できます。
public sealed class AutoLotContext : DbContext
{
public AutoLotContext(DbContextOptions<AutoLotContext> options)
: base(options)
{
ChangeTracker.Tracked += OnTracked;
ChangeTracker.StateChanged += OnStateChanged;
}
private static void OnTracked(
object? sender,
EntityTrackedEventArgs args)
{
Console.WriteLine(
$"追跡開始: {args.Entry.Entity.GetType().Name}");
}
private static void OnStateChanged(
object? sender,
EntityStateChangedEventArgs args)
{
Console.WriteLine(
$"{args.Entry.Entity.GetType().Name}: " +
$"{args.OldState} -> {args.NewState}");
}
}
学習や診断ではとても役立ちます。
ただし、本番の業務処理をイベントに入れすぎると、処理の流れが追いづらくなります。
イベントは「観察」に向いています。
保存ルールのような重要処理は、保存処理の上書きや明示的なサービスに置く方が読みやすいです。
保存時に監査情報を設定する
作成日時や更新日時は、多くのエンティティに共通します。
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
保存前に値を設定します。
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplyAuditValues();
return base.SaveChangesAsync(cancellationToken);
}
private void ApplyAuditValues()
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
呼び出し側が毎回 UpdatedAt を設定する必要はありません。
データアクセス層で一貫して処理できます。
保存前に変更内容を確認する
保存前に、どのプロパティが変更されたかをログに出すこともできます。
private void LogChanges()
{
foreach (var entry in ChangeTracker.Entries()
.Where(entry => entry.State == EntityState.Modified))
{
foreach (var property in entry.Properties
.Where(property => property.IsModified))
{
Console.WriteLine(
$"{entry.Entity.GetType().Name}.{property.Metadata.Name}: " +
$"{property.OriginalValue} -> {property.CurrentValue}");
}
}
}
保存処理から呼び出します。
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplyAuditValues();
LogChanges();
return base.SaveChangesAsync(cancellationToken);
}
実務では、Console.WriteLine() ではなくロガーや監査テーブルへ記録します。
ここでは、変更追跡から何を取り出せるのかを見やすくするために簡単な出力にしています。
DbContext を太らせすぎない
DbContext に共通ルールを置くと、呼び出し側は楽になります。
しかし、すべてを DbContext に詰め込むと、今度は DbContext が巨大になります。
置いてよいものは、データアクセス層全体に関わる規則です。
- 共通の型設定
- マッピング設定の適用
- データベース関数の対応付け
- 保存時の監査情報
- 論理削除の共通処理
- 変更追跡の診断
画面固有の判断や、外部サービスとの通信は別の層に置きます。
DbContext は境界です。
境界に置くべきルールと、外側の業務処理に置くべきルールを分けることで、データアクセス層は扱いやすくなります。
次回は、マイグレーションでテーブル以外のデータベースオブジェクトを管理する方法を見ていきます。