0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

EntityFrameworkでユニーク制約を含んだテーブルをAddOrUpdateする

Last updated at Posted at 2022-10-06

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の基礎演習としてはなかなか骨のあるクラスだった。オブジェクト指向エクササイズをするなら、MemberCompose時に生成する変数にすると良かろう。また、ご覧の通りUnaryExpressionParameterExpressionに変換するのは造作も無い。

段階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になるラムダ式右辺とそうでないものの違い。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?