Entity FrameworkにはAddOrUpdate
メソッドがあるが、主キーのみを制約調査対象にしているので、主キーが自動生成インデックスでユニーク制約をかけたデータをSeed
しようとすると失敗してしまう。そこで、カスタムのメソッドを作ることにした。
これはおそらくEFCoreのUpdateRange
などでも発生しうる問題なので、そちらでも役立つように思われる。
段階1 拡張メソッド定義
ユニーク列を一緒に引数に渡すAddOrUpdateWithKeys
を定義した。ユニーク制約をクラス内に直接埋め込むことができない(これもプログラミング言語でデータベース言語をやろうとしているので致し方なかろう)のでいちいち式を書くしかないが、データ型の大本をインターフェイスで定義し、プロパティとしてユニーク制約を表現すれば、それを読み込むことでFluent APIでも流用が効くし、一元管理がしやすくなる。ソースジェネレータを用いれば属性を使って自動でラムダ式生成をすることもできるが、残念ながら筆者の環境が古いため検証できていない。複数主キーを生成したり、それを用いてFluent API用の外部キーを生成すればコードの見通しが改善される。というより極めればFluent API自体を完全自動生成できるようにならないか? いつかやってみたい。
/// <summary>
/// ユニーク制約のプロパティ定義です。
/// </summary>
/// <typeparam name="TEntity">エンティティの型。</typeparam>
public interface IUniqueConstraint<TEntity> where TEntity : class
{
/// <summary>
/// ユニーク制約を表す式です。
/// </summary>
Expression<Func<TEntity, object>> UniqueConstraint { get; }
// C# 11ならstatic abstract Expression<Func<TEntity, object>> UniqueConstraint { get; }
// とした方が都合が良い。
}
これを実装するクラス側でユニーク制約を持たせる。現状の実装だとクラスのインスタンスから問い合わせなければならないが、C# 11ならstatic
かつabstract
なプロパティを定義できるのでinstanceを利用しなくて済むぞ。
public class Product : IUniqueConstraint<Product>
{
[Key]
public string ID { get; set; }
public string Name { get; set; } // ユニーク制約
public int Price { get; set; }
public Expression<Func<Product, object>> UniqueConstraint
=> product => product.Name;
// C#11ならUniqueConstraintにstaticを付けたい
}
メソッドとしてはこんな感じである。
public static class MigrationExtension
{
public static void AddOrUpdateWithKeys<TEntity>(this DbSet<TEntity> database,
IEnumerable<TEntity> entities)
where TEntity : class, IUniqueConstraint<TEntity>
{
if (entities == null || entities.Count() < 1)
{
return;
}
// ここでWhere条件を生成するためのインタフェイスを作成する
var visitor = EntityEqualityConditionPickerFactory
.Create(entities.First().UniqueConstraint);
// C# 11ならTEntity.UniqueConstraintとすることができる
//(ただしUniquConstraintはstatic abstractとする)
foreach (TEntity entity in entities)
{
// Where条件をここで生成する
var condition = visitor.Compose(entity);
var entry = database.FirstOrDefault(condition);
// 3項演算子による分岐をするには引数を取らないといけない
var dummy = entry == null ? database.Add(entity)
: SetEntity(database, entity, entry);
}
}
private static TEntity SetEntity<TEntity>(DbSet<TEntity> database,
TEntity entity, TEntity entry)
where TEntity : class
{
// 比較的よくあるリフレクション。
var typeinfo = typeof(TEntity);
var properties = typeinfo.GetProperties();
foreach (var property in properties)
{
// キーを更新しようとするとポシャるので有無をチェックする
var shouldBeSkipped = property.GetCustomAttribute<KeyAttribute>() != null
|| property.GetCustomAttribute<NotMappedAttribute>() != null
|| property.GetCustomAttribute<ExternalTableAttribute>() != null;
if (!shouldBeSkipped)
{
var value = property.GetValue(entity);
if (value != null)
{
property.SetValue(entry, value);
}
}
}
return entry;
}
}
ここでExternalTableAttributeは他のテーブルへの単体リンクを表す空の属性である。
AddOrUpdate
をオーバーロードしようとすると通常版とユニーク制約対応版両方適用できてしまうためコンパイラが困惑してしまう。
段階2 等価条件生成クラス作成
等価条件を生成するためのクラスがもつインターフェイスはごく簡単である。
/// <summary>
/// エンティティの等価条件生成用クラスのメソッド定義です。
/// </summary>
/// <typeparam name="TEntity">エンティティ。</typeparam>
public interface IEntityEqualityConditionPicker<TEntity> where TEntity : class
{
/// <summary>
/// エンティティの等価条件を生成します。
/// </summary>
/// <param name="entity">等価条件となるエンティティ。</param>
/// <returns>エンティティの等価条件。</returns>
Expression<Func<TEntity, bool>> Compose(TEntity entity);
}
正直名前が長い…がネーミングも難しい。Jeff Bayさんによればメソッド名が長い場合「責務の配置を間違えているか、必要なクラスを抽出できていないのかもしれません」とのことだが。
問題はここからである。Join
のキーと同様に入力できるとユーザーには分かりやすい。すなわちユニーク列が1つだけの場合entity => entity.Unique
、複数あるのならentity => new { entity.Unique1, entity.Unique2 }
という具合だ。この時、ラムダ式の右辺の型はExpresson
からさらに分かれて3種類あることをご存じだろうか。
複数の場合はNewExpression
しかない。new
しているし、オブジェクトを生成しているのは分かるので問題ないだろう。しかし列を1つだけ選ぶ場合は、参照型か価型かで型が変わってしまう。前者はMemberExpression
、後者はUnaryExpression
である。なぜこうなるかというと、値型の場合はobject
に変換する際にボクシングが発生するからである。つまり変換処理の単項演算子が暗黙の内に挿入されるのだ。これに気づかないで30分ぐらい悩んだ。
一番簡単な解決策はユニーク列が1つだけでも構わずにnew
してしまうことだが、それではユーザーに余計な手を煩わせてしまう。また、直接Expressionを触れるのは禍根を残すことになる。そのためインターフェイスで処理を抽象化して、ファクトリメソッドを利用することにした。
public static class EntityEqualityConditionPickerFactory
{
public static IEntityEqualityConditionPicker<TEntity> Create<TEntity>(
Expression<Func<TEntity, object>> keySelector) where TEntity : class
{
// 引数の取得式は変わると対応が取れなくなって詰むのでこの時点で取得する
// 2つ引数があると問題なのでエラーを吐くようにする
var paramName = keySelector.Parameters.Single();
var body = keySelector.Body;
if (body is MemberExpression)
{
var expression = body as MemberExpression;
return new SingleKeyEntityEqualityConditionPicker<TEntity>
(expression, paramName);
}
if (body is UnaryExpression)
{
var expression = body as UnaryExpression;
return new SingleKeyEntityEqualityConditionPicker<TEntity>
(expression, paramName);
}
if (body is NewExpression)
{
var expression = body as NewExpression;
return new PolyKeyEntityEqualityConditionPicker<TEntity>
(expression, paramName);
}
throw new ArgumentException("プロパティ生成式に変換できませんでした。");
}
}
またクラス名が長い。申し訳無い。
段階2a. 単項式の場合
基本となるのでまずは式を示す。
public class SingleKeyEntityEqualityConditionPicker<TEntity>
: IEntityEqualityConditionPicker<TEntity> where TEntity : class
{
// プロパティ情報。名前や値取得で使う
private PropertyInfo Info;
// 式の中身。"entity => entity.parameter"
private MemberExpression Member;
// エンティティを取得するための式
private readonly ParameterExpression EntityParameter;
public SingleKeyEntityEqualityConditionPicker(MemberExpression body,
ParameterExpression parameter)
{
Info = (PropertyInfo)body.Member;
EntityParameter = parameter;
Member = Expression.Property(EntityParameter, Info.Name);
}
public SingleKeyEntityEqualityConditionPicker(UnaryExpression body,
ParameterExpression parameter)
: this((MemberExpression)body.Operand, parameter) { }
public Expression<Func<TEntity, bool>> Compose(TEntity entity)
{
// エンティティの対応プロパティを定数にアサインする
var constant = Expression.Constant(Info.GetValue(entity));
return Expression.Lambda<Func<TEntity, bool>>(
Expression.Equal(Member, constant),
EntityParameter
);
}
}
Expression
の基礎演習としてはなかなか骨のあるクラスだった。オブジェクト指向エクササイズをするなら、Member
をCompose
時に生成する変数にすると良かろう。また、ご覧の通りUnaryExpression
をParameterExpression
に変換するのは造作も無い。
段階2b. 多項式の場合
こちらはでかくなるので、有利な形になるように「条件生成を一括して行うクラス」と「プロパティごとに条件生成するクラス」に分けた。
まずは一括クラスから。
public class PolyKeyEntityEqualityConditionPicker<TEntity>
: IEntityEqualityConditionPicker<TEntity> where TEntity : class
{
// プロパティをまとめて保存する辞書
private PropertyDictionary Dictionary;
private readonly ParameterExpression EntityParameter;
public PolyKeyEntityEqualityConditionPicker(NewExpression expression,
ParameterExpression parameter)
{
EntityParameter = parameter;
Dictionary = new PropertyDictionary(expression, EntityParameter);
}
public Expression<Func<TEntity, bool>> Compose(TEntity entity)
{
var aggregate = Dictionary
.KeysEqual(entity) // 条件をまとめて取得
.Select(func => func.Body) // 式の右辺を選んで…
// 全部AND条件で結合
.Aggregate((func1, func2) => Expression.AndAlso(func1, func2));
return Expression.Lambda<Func<TEntity, bool>>(aggregate, EntityParameter);
}
}
プロパティごとに条件生成するクラスはこうなる。
public class PropertyDictionary
{
// プロパティとパラメータの対応
private Dictionary<PropertyInfo, MemberExpression> Dictionary;
private ParameterExpression EntityParameter;
public PropertyDictionary(NewExpression body, ParameterExpression parameter)
{
EntityParameter = parameter;
// メンバーごとにエンティティとの対応を抽出
var properties = body.Members
.Select(member => Expression.Property(EntityParameter, member.Name));
// メンバーと式を辞書にする
Dictionary = properties.ToDictionary(property =>
(PropertyInfo)property.Member,
property => property);
}
public IEnumerable<Expression<Func<TEntity, bool>>>
KeysEqual<TEntity>(TEntity entity)
where TEntity : class
{
foreach (KeyValuePair<PropertyInfo, MemberExpression> property in Dictionary)
{
var constant = Expression.Constant(property.Key.GetValue(entity));
yield return Expression.Lambda<Func<TEntity, bool>>(
Expression.Equal(property.Value, constant),
EntityParameter
);
}
}
}
KeysEqual
の中身は、foreach
を回してyield return
していることを除けば、SingleKeyEntityEqualityConditionPicker
と変わりない。この辺りを抽出できれば締まるのだが、とりあえず動いたので問題無い。
オブジェクト指向エクササイズを実践する際は、ファーストクラスコレクションはコレクション保持のクラス変数以外のフィールドを持つことを禁じているので、KeysEqual
の引数としてEntityParameter
を渡すことになるだろう。
まとめ
粗削りではあるが、クラス設計の練習としてはいい感じの難易度で、分からなくなる時もあったけれどやりがいが感じられてよかった。これをコピペすればEntity Frameworkでユニーク制約があった時に失敗しなくて済む。
参考文献
考え方としてはここの方法が参考になった。こちらではシンプルに条件式を突き合わせているが、汎用性が無いため何個もユニーク制約がある場合は大変なので、このクラスを作ったまでだ。
UnaryExpression
のプロパティ名取得方法。
UnaryExpression
になるラムダ式右辺とそうでないものの違い。