ユーザ一覧をRole付きで取得する(C# ASP)

.NET6でASP.NET COREのIdentityを利用してサイトを作り始めたのですが、管理画面の一つとしてユーザロールを割り当てるページを作成しようとした際にかなり手を焼いたので記録に残す。
何が難しかったのかっていうとEntityFrameworkの使い方なんですけどね。普通に生Query書けばすぐに終わるのに、EntityFrameworkでどうやって書くんだい?ってのが鬼門でした。

やりたいこと

roleをプロパティとして持ったユーザの配列を取得したい。

var usersWithRoles = getUsersWithRoles();


// ⇂これの配列を取得したい
user = {
    name: "hoge",
    roles: ['admin','user',,,,]
}

Identityを利用

ASP.NET Identityという枠組みを利用するため、簡単に取得する方法がすでに定義されてるんじゃないの?思ったんですが、残念ながらありません。

1つのユーザのRolesを取得する方法や1つのRoleから紐付くUsesを取得する方法はあれど、マージした状態で取得する方法はデフォルトでは用意されていません。

またASP.NET Identityをデフォルト設定のままで使うとUsers – Roles間のNavigation Propertyがないため、EntityFrameworkで扱う際に面倒なことになります(なりました)。
どうやら昔の.NET Framework時代にはIdentityUserのクラスにもRolesというNavigation Propertyがあったようなのですが、いつの頃からか削除されたようです。

IdentityUser / IdentityRoleを変更する

EntityFrameworkでリレーションを扱うためにはNavigation Propertyがほぼ必須です。
なのでデフォルトのIdentityContextを書き換えます。

public class HogeContext : IdentityDbContext<ApplicationUser, ApplicationRole, string>
{
    public HogeContext(DbContextOptions<HogeContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<ApplicationUser>().HasMany(u => u.Roles).WithMany(r => r.Users)
            .UsingEntity<IdentityUserRole<string>>(
                userRole => userRole.HasOne<ApplicationRole>().WithMany().HasForeignKey(ur => ur.RoleId).IsRequired(),
                userRole => userRole.HasOne<ApplicationUser>().WithMany().HasForeignKey(ur => ur.UserId).IsRequired());
    }
}
public class ApplicationUser : IdentityUser
{

    public ICollection<ApplicationRole> Roles { get; set; }
}
public class ApplicationRole : IdentityRole
{
    public ApplicationRole(string name) : base(name)
    {
    }

    public ICollection<ApplicationUser> Users { get; set; }
}

IdentityUserなどを継承した場合、ApplicaitionUserのような名称にするのが一般的だそうです。
単にUserクラスでいい気がしますが、ネットで見かけるコードも概ね似たような命名をされてます。

んで、Program.csやらStartup.csやらにIdentityを利用するためのコードを書き足します。

builder.Services.AddIdentity<ApplicationUser, ApplicationRole>().AddRoleManager<RoleManager<ApplicationRole>>()
        .AddEntityFrameworkStores<HogeContext>().AddDefaultTokenProviders();

EntityFrameworkでの呼び出し方

var usersWithRoles = await this.context.Users.Include(x => x.Roles).ToListAsync();

はい、これだけですね。
EntityFrameworkはなんて便利なんだー で話を終わってもいいのですが、
実はここにたどり着くまでにワタシ何時間も掛けてしまいました。。。

嵌った理由

最大の理由はNavigation Propertyを利用せずに取得しようとしたからです。
生のSQLで書く場合には外部キーのカラムが自明なので、適当にusers UserRoles Rolesの3つのテーブルをjoinしてあとはarray_aggするなり、オブジェクトにしてから組みなおすなり、何とでもなるだろうと思ってました。が、EntityFrameworkでNavigation Propertyのない関連テーブルを紐付けることが異常に面倒でした。

※以下、ApplicaitionUserとIdentityRoleにNavigation Propertyがない状態でのお話です。

EntityFramework自体に不慣れだったこともあり、まずは要素を配列にまとめる方法を探すところから始めました。
どうも調べてみるとGroupJoinなる便利メソッドがあり、公式にそのまんまやりたいこと通りのパターンが乗っていました。Navigation Propertyが使われいることが気にはなったものの、一致判定をするカラムが指定さえできれば問題なさそうなコードに見えたため、これを流用してみました。

var query = people.GroupJoin(pets,
                    person => person,
                    pet => pet.Owner,
                    (person, petCollection) =>
                        new
                        {
                            OwnerName = person.Name,
                            Pets = petCollection.Select(pet => pet.Name)
                        });
Enumerable.GroupJoin メソッド (System.Linq)
キーが等しいかどうかに基づいて2つのシーケンスの要素を相互に関連付け、その結果をグループ化します。

んで書いたコードがこちら。

var users = await this.context.Users.GroupJoin(
        this.context.UserRoles,
        user => user.Id,
        userRole => userRole.UserId,
        (user, userRoles) => new { UserId = user.Id, UseName = user.UserName, Roles = userRoles.Select(x => x.RoleId) })
    .ToListAsync();

まずはUsersとUserRolesの紐付けだけをやってみたのですが、
could not be translated!!!!

System.InvalidOperationException: The LINQ expression 'DbSet<ApplicationUser>()
    .GroupJoin(
        inner: DbSet<IdentityUserRole<string>>(), 
        outerKeySelector: user => user.Id, 
        innerKeySelector: relation => relation.UserId, 
        resultSelector: (user, roles) => new { 
            UserId = user.Id, 
            UseName = user.UserName, 
            Roles = roles
                .AsQueryable()
                .Select(x => x.RoleId)
         })' 
could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

いやいや、別に変なことは書いてないやろ。
Linqで書くのになじめないのでクエリ式で以下のように書いてみても結果は同じ。

var query =
    from user in this.context.Users
    join userRole in this.context.UserRoles on user.Id equals userRole.UserId into userRoles
    select new { UserId = user.Id, UseName = user.UserName, Roles = userRoles.Select(x => x.RoleId)  };

ってことはそもそもこのGroupJoin(クエリ式の into Hoge)の部分はNavigation Property必須?
あってもなくても結合ロジック的には関係なくね???って長時間悩みました。

外部キーは設定されているのに、Navigation Propertyがない、という理由だけで特定のコードが実行できなくなる?DBの構造として等価なのに、EntityFramework内のObjectプロパティの有無だけで機能できないなんて融通が利かなさすぎませんかね?
じゃあNavigation Propertyのないテーブル同士を結合してやりくりする時はどうしたらいいんだ?

最終的にたどり着いた答えがこちら。
GroupJoin使わない方法ですね。

var query = 
    from user in this.context.Users
    let userRoles = this.context.UserRoles.Where(userRoles => user.Id == userRoles.UserId).ToList()
    select new { User = user, UserRoles = userRoles };

とても参考になったStackOverflowを張っておきます。
結局のところ、概念的には最初に書いた書き方で間違ってはない。
ただ、EF COREの実装がGroupJoinで条件を指定できるような実装になってないっぽいですね。

Query with `groupjoin` cannot be translated although it's documened as being supported
Idon'tunderstandwhythisdoesn'ttranslate.Itseemstobeexactlytheusecasedescribedhere.TheLINQexpressionDbSet<A>().GroupJoin(inner:DbSet<B>(),

Role名を取得するにはUserRolesとRolesをjoinする必要があるので、
こんな感じでrelation tableを作って使います。

var relationTable = from userRoles in this.context.UserRoles
                    join role in this.context.Roles on userRoles.RoleId equals role.Id
                    select new { UserId = userRoles.UserId, RoleName = role.Name };

var query = from user in this.context.Users
                let roles = relationTable.Where(relation => user.Id == relation.UserId).ToList()
                select new { User = user, Roles = roles.Select(x => x.RoleName) };

var usersWithRoles = await query.ToListAsync();

コメント