scalikeJDBC One-to-X APIを使いこなす(基本編)


なんの記事だい?

scalikeJDBCの One-to-X API を使いこなすためのまとめ記事です。
scalikeJDBCでselect文相当のコードを書くこと自体はそんなに難しくないのですが、
欲しいDBモデルに変換していく作業( .one().toMany().map()とかそのあたりのコードです)をわりと色々な感じでかけるため、
改めて基礎を抑えたく記事にしてみました。
リレーショナルモデルにおける「結合」の基本と照らし合わせながら、scalikeJDBCの書き方をまとめてみたつもりです👧

内部結合の場合

Corporatesの一要素は複数のuserを持つかもしれない。
また、Usersの一要素は一つのcorporateを持つような関係のテーブルがあるとします。

(リレーショナルモデルの考え方に基づき、集合論としてイメージを持ちたいので、一行と言わず一要素と言うことにします!)

そもそも「結合」とは、こういう新しい集合を返す操作ですね!!!

これをscalikeJDBCで書くならばどうなるのか?というところですが、
まず結合した結果としてどんな値が欲しいか考えます。
それによって、select文の後にする処理が変わってきます。

  1. 起点にしたテーブルの値が欲しい
  2. 結合したテーブルの値も欲しい

それぞれのパターンについて、実装例を記します。

1. 起点にしたテーブルの値が欲しい

例えば、ユーザーが一人でもいる企業のリストが欲しいとします。
Corporatesを起点にしてUsersをjoinすれば、userが一人も紐づいていないcorporate要素は結合されずに結果から落とされ、欲しかった企業のリストが取得できます。

この場合、以下のコードのようにselect文後にmapの中で
rs: WrappedResultSet (ある集合の一要素に値する型。のちに定義記載。)
Corporates型 (DBモデルの型)に変換してあげればOkです。

withSQL[Corporates] {
  select
    .from(Corporates.as(corporatesTable))
    .join(Users.as(usersTable))
    .on(corporatesTable.corporateId, usersTable.corporateId)
}.map {
  rs => Corporates(corporatesTable)(rs)
}.list()
  .apply()

list()はselect文で抽出できた複数の要素をリストで返します。(最初の一つの要素だけ返すメソッドとかもある)

ログを見てみると、以下のようなクエリになっています。
ちなみに実際にクエリが発行されるのは、apply()が実行された時です。

select *(省略) from corporates corporatesTable
inner join users usersTable
on corporatesTable.corporate_id = usersTable.corporate_id;

また、select文の結果として得られた各要素を利用し、オリジナルのクラスとして結果を返すこともできます。

{ select文 }
.map { rs =>
   val corporate = Corporates(corporatesTable)(rs)
   CorporateNameModel(s"株式会社 ${corporate.name}")
}.list()
  .apply()

2. 結合したテーブルの値も欲しい

ユーザーに所属企業情報も含めたリストが欲しいとします。
Usersを起点としてCorporatesをjoinすれば、Usersの一要素は必ず一つのcorporateを持ちます。
つまり、select文の結果として、必ずユーザーと企業が1:1で取得できているでしょう。
そう仮定した上で、select文の後は以下のように書いていきます。

withSQL[Users] {
  select
    .from(Users.as(usersTable))
    .join(Corporates.as(corporatesTable))
    .on(corporatesTable.corporateId, usersTable.corporateId)
}.one(Users(usersTable))
  .toOne(Corporates(corporatesTable))
  .map((user, corporate) => // userとcorporateが一要素ずつ詰まったタプル、これはとてもリレーショナルモデル的...
    CorporateUserModel(corporate.id, user.id, user.name...)
  )
  .list()
  .apply()

1:1なので、.one().toOne()と書けばOkなのが、感覚的に書けてとてもグッドですね👍

外部結合の場合

外部結合する場合は、Corporatesが起点ですね。

Corporatesの一要素は、userを持っているかもしれないし、持っていないかもしれません。
なので、 corporate:userが 1:[0 ~ n] の関係ですね。

select文を書く動機として、主に2種類考えられると思います。
1. 結合したテーブルの値がlistで欲しい (1:[0 ~ n])
2. 結合したテーブルの値がOptionで欲しい (1:[0 ~ 1])

1. 結合したテーブルの値がlistで欲しい (1:[0 ~ n])

アプリケーションの要件的に言い換えると、「会社とそれにひもづくユーザーの一覧が欲しい、ユーザーが一人もいない場合も一覧に入れて欲しい。」という感じですね。
こういう場合は以下のようなコードになります。

withSQL[Corporates] {
  select
    .from(Corporates.as(corporatesTable))
    .leftJoin(Users.as(usersTable))
    .on(corporatesTable.corporateId, usersTable.corporateId)
}.one(Corporates(corporatesTable))
  .toMany(
    rs => rs.longOpt(usersTable.resultName.corporateId).map(_ => Users(usersTable)(rs))
  )
  .map((corporate, users) => (corporate, users)) // 一つのcorporateに対して、userがlistで付いてくる
  .list()
  .apply()

one-to-manyの関係なので、 one().toMany() とそのままコードに落とせば欲しい形になります!

toManyの中では、 rs: WrappedResultSet => で結合したUsersの要素が一要素ずつ渡されます。

WrappedResultSet の定義は以下で、 java.sql.ResultSet をラップしたものになります。

case class WrappedResultSet(underlying: ResultSet, cursor: ResultSetCursor, index: Int)
// indexにはその要素の"行数"が確保されていたりする

leftJoinなので、もしかするとそのUsers一要素の全カラムの値が NULL かもしれないですね。(😇)
なので、 longOpt("カラム名") で値にアクセスしてみて、
もし「これはNULLで結合()されちゃった要素ですねえ」となればNoーを返し、
そうでなければその要素をSome(Users型)に変換して返しています。

one().toMany()を通過するとon句でつなげた部分がよしなに束ねられ、
mapしたときには (corporate: Corporates, users: Seq[Users) というタプルになっています👏

図で表すとこんな感じです。

また、外部結合する集合が二つ以上あるときは、 toManies() (複数形)を使うべきなので注意です。

ちなみに、toManies()に渡すことのできる引数の上限は 9個まで です😂
私のチームが開発しているアプリケーションで9個以上leftJoinしているところがあり、その制約を頑張って回避したことがある(leftJoinだからといってむやみにtoManiesに渡しちゃダメだよ、というだけの話だが・・・)のでそれも後々記事にしようと思います。

2. 結合したテーブルの値がOptionで欲しい (1:[0 ~ 1])

仮に、一つの会社はユーザーを一人までしか持たない(アドミンユーザーならあり得そう)設計だったとします。
その場合、上記の .one().toMany() でかいてしまうと、mapの中でusersに対してheadOptionしなければならない気がしてきますね・・・。
そういう時は、 toOptionalOne を使います。

withSQL[Corporates] {
  select
    .from(Corporates.as(corporatesTable))
    .leftJoin(Users.as(usersTable))
    .on(corporatesTable.corporateId, usersTable.corporateId)
}.one(Corporates(corporatesTable))
  .toOptionalOne(
    rs => rs.longOpt(usersTable.resultName.corporateId).map(_ => Users(usersTable)(rs))
  .map((corporate, userOpt) => (corporate, userOpt))
  .list()
  .apply()

toOptionalOne()に渡している部分のコードはは、先ほどのtoMany()のときと同じです。
万が一、「一つの会社にユーザー二人いるやないかい・・・!」(DB設計的にはあり得てしまうと思うので)となったときには、以下のような実行時例外がはかれます。

scalikejdbc.IllegalRelationshipException: one-to-one relation is expected but it seems to be a one-to-many relationship.

便利なようで、ちょっと怖い。
one-to-manyがアプリケーション仕様的にあり得ないのであれば、例外処理をしておきたいですね。

さいごに

[one-to-one-to-one]や[one-to-many-to-one]や[one-to-many-to-many]を簡潔に書くなり、同じテーブルを2回joinするなり、サブクエリを書くなり、ひとくせあったな〜と感じた例を紹介しきれなかったので、また次回 [応用編] ということで記事にしたいと思います。