こんにちは。
テックリードのTerukiです。
Oh my teethではEF Coreを使って効率的にデータベースアクセスを伴う開発をやっていますが、結合周りでいろいろと工夫をしていたりしていなかったりするので紹介します。
結合が絡むコードはGroupJoinやSelectManyなどでややこしくなりがちですが、この記事で紹介する内容を覚えておけば複雑な結合も比較的読みやすくなるかなと思っています。
この記事はサンプルとして以下のEntityを使っていきます。適宜参照いただければなと。
MySQLを使ってテストしていますが、ほとんどのデータベースでも同じことができるかと思います。
ただ、SQLiteはかなり機能が制限されていそうです。
class User {
public int Id { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<Reservation> Reservations { get; set; }
}
class Reservation {
public Guid EventId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public int UserId { get; set; }
public User User { get; set; }
}
class ReservationData {
public Guid EventId { get; set; }
public string Data { get; set; }
}
class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options) {
public DbSet<User> Users => Set<User>();
public DbSet<Reservation> Reservations => Set<Reservation>();
public DbSet<ReservationData> ReservationData => Set<ReservationData>();
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>()
.HasMany(u => u.Reservations)
.WithOne(r => r.User)
.HasForeignKey(r => r.UserId);
}
}
前置き
EF CoreやORM自体に賛否がありますが、Oh my teethでは開発効率を重視して生SQLを一切触らずに開発をしています。
コードレビュー時に実際に出力されるであろうSQLを考慮してORMのデメリットを補っています。
(尚、チームメンバーにSQLを知らないメンバーは居ません)
Include
EF Coreで結合する場合にまず出てくるのがIncludeですよね。
Userに紐づくReservationを取ってくるにはこうなります。
var userWithReservations = await dbContext.Users
.Include(u => u.Reservations)
.ToListAsync()
.ConfigureAwait(false);
これを実行すると発行されるSQLはこちら。
(読みやすいように手動でフォーマットしています)
SELECT
`u`.`Id`,
`u`.`CreatedAt`,
`u`.`Name`,
`r`.`EventId`,
`r`.`EndDate`,
`r`.`StartDate`,
`r`.`UserId`
FROM `Users` AS `u`
LEFT JOIN `Reservations` AS `r`
ON `u`.`Id` = `r`.`UserId`
ORDER BY `u`.`Id`
シンプルLEFT JOINです。これくらいは余裕ですね。
フィルタ付きInclude
ちょっとややこしくしてみます。
全ユーザーの過去のReservationを含むリストを取ってきます。
var userWithPastReservations = await dbContext.Users
.Include(u => u.Reservations.Where(w => w.StartDate <= DateTimeOffset.UtcNow))
.ToListAsync()
.ConfigureAwait(false);
SQLがこちら。
SELECT
`u`.`Id`,
`u`.`CreatedAt`,
`u`.`Name`,
`r0`.`EventId`,
`r0`.`EndDate`,
`r0`.`StartDate`,
`r0`.`UserId`
FROM `Users` AS `u`
LEFT JOIN (
SELECT
`r`.`EventId`,
`r`.`EndDate`,
`r`.`StartDate`,
`r`.`UserId`
FROM `Reservations` AS `r`
WHERE `r`.`StartDate` <= UTC_TIMESTAMP()
) AS `r0`
ON `u`.`Id` = `r0`.`UserId`
ORDER BY `u`.`Id`
ちょっと長いですが無駄のないクエリになっているように見えます。
尚、SQLiteではエラーになりました
Selectを使って結合
Includeができるのは外部キー制約をEF Core上で設定したものだけです。
実際のデータベースでは、諸々の理由で外部キーを設定できない時がありますよね。
これらのテーブルの主キーは共にEventIdなので外部キーを張ることができないようです。
今回はReservationとReservationDataを取ってきます。
var reservationsWithData = await dbContext.Reservations
.Select(s => new {
reservation = s,
reservationData = dbContext.ReservationData
.Where(r => r.EventId == s.EventId).ToList(),
})
.ToListAsync()
.ConfigureAwait(false);
そんなことできるの?!という感じですが、発行されるSQLがこちらです。
SELECT
`r`.`EventId`,
`r`.`EndDate`,
`r`.`StartDate`,
`r`.`UserId`,
`r0`.`EventId`,
`r0`.`Data`
FROM `Reservations` AS `r`
LEFT JOIN `ReservationData` AS `r0`
ON `r`.`EventId` = `r0`.`EventId`
ORDER BY `r`.`EventId`
謎のORDER BYが付いていますが、非常に綺麗なLEFT JOINです。
Selectしているのでトラッキングさせることはできないですが、集計目的なら非常に役立ちます。
ToListが同期メソッドなので直感的に違和感がありますが、実際には実行されないExpressionなので気にしないようにします。すぐに慣れます。
ToList部分をSingleOrDefaultなどにすることもできますが、ROW_NUMBERなどのウィンドウ関数が出現するので今回のように1対1と分かっている場合でもToListでやるのが良さそうです。
もちろん、1対nの場合でも使用可能です。
Userに紐づく情報を大量にLEFT JOINしたいようなユースケースではとても相性が良さそうです。
ややこしい結合処理を書くのは大変ですが、これらを知っているのと知らないのではかなりEF Coreの評価が変わってくるかなと思います。
Selectで結合するやり方は公式ドキュメントを探しても見つけられなかったのですが、Pomelo.EntityFrameworkCore.MySqlやNpgsqlでも問題なく動作しました。
頭の片隅に入れておくと良さそうです。