• 3
    いいね
  • 0
    コメント

はじめに

Scala Advent Calendar 10日目の記事。Skinny ORMという、ScalaのScalikeJDBCをベースにしたORMのjoin定義について、コードを見ながら解説していく。

joinの基本

SkinnyORMでjoinをするときは、まずそのテーブルの結合が、以下のどのパターンに当てはまるか考える。

  • belongsTo
  • hasOne
  • hasMany(hasManyThrough)

そのうえでCRUDMapperにjoin定義を書けば良い。たとえばStation毎に1つのLineに所属するようなTableを考えると、次のようなものになる。

case class Line(id: Long, name: String)

object Line extends SkinnyCRUDMapperWithId[Long, Line] { ... }

case class Station(id: Long, name: String, lineId: Long, line: Option[Line] = None)

object Station extends SkinnyCRUDMapperWithId[Long, Station] {
  ...
  belongsTo[Line](
    right = Line,
    merge = (station, line) => station.copy(line = line)
  ).byDefault
}

.byDefault となっているので、特に明示的になにかをする必要はなく、自動的にStation classのlineにコピーされる。その処理はbelongsToの中のmergeによって指示される。

もしdefaultでjoinされるのが困るのであれば、

lazy val lineRef = belongsTo[Line](...)

などと定義し、

Station.joins(Station.lineRef).findAll()

と呼び出せば明示的にjoinできる。

以上がSkinnyORMのjoinにおける概要である。詳細は公式ドキュメントを参照。

withJoinConditionメソッド

多くの場合は上記のようにすれば足りるが、複雑なjoinを行うには、SkinnyORMが自動で行なっていることを理解する必要がある。ソースコードを紐解いてみよう。

joinまわりの実装の殆どはAssociationsFeature traitで実装されている。さきほど使ったbelongsToメソッドもそこにある。見てみよう。

skinny/orm/feature/AssociationsFeature.scala#L192

def belongsTo[A](right: AssociationsWithIdFeature[_, A], merge: (Entity, Option[A]) => Entity): BelongsToAssociation[Entity] = {
  val fk = toDefaultForeignKeyName[A](right)
  belongsToWithJoinCondition[A](right, sqls.eq(this.defaultAlias.field(fk), right.defaultAlias.field(right.primaryKeyFieldName)), merge)
}

少なくとも、返り値がBelongsToAssociation[Entity]というcase classであること、それを生成しているのはbelongsToWithJoinConditionというメソッドであることが分かる。

skinny/orm/feature/AssociationsFeature.scala#L197

def belongsToWithJoinCondition[A](right: AssociationsWithIdFeature[_, A], on: SQLSyntax, merge: (Entity, Option[A]) => Entity): BelongsToAssociation[Entity] = {
  val joinDef = leftJoinWithDefaults(right, on)
  val extractor = extractBelongsTo[A](right, toDefaultForeignKeyName[A](right), right.defaultAlias, merge)
  new BelongsToAssociation[Entity](this, unshiftJoinDefinition(joinDef, right.defaultJoinDefinitions.filter(_.enabledEvenIfAssociated)), extractor)
}

ここでBelongsToAssociation[Entity]が実際に生成されていることが分かる。

belongsToWithJoinConditionで増えているのはon: SQLSyntaxという引数のみである。これは結合条件ではないだろうか。逆に言うとbelongsToメソッドでは結合条件を自動生成しているのでは。その部分を抜き出すと

val fk = toDefaultForeignKeyName[A](right)
sqls.eq(this.defaultAlias.field(fk), right.defaultAlias.field(right.primaryKeyFieldName)

つまりrightがLineであるので、外部キー名はそのテーブル名を用いてline_idであると推測され、rightはWithIdなAssociationであるのでPrimaryKeyFieldは当然Line.idで、その2つが等価であるような結合条件を自動生成する。

大体の場合はこれで正しいので、最小限書けばあとはSkinnyORMが自動でやってくれるように見える。すばらしい。これではだめなとき(これもまた割と良くある)はWithJoinCondition付きのメソッドを使えば良いのだ。

※実はForeignKeyがdefaultと異なる場合、belongsTowithJoinConditionではだめである。詳しくは後述する。

withFkメソッド

ソースコードを見ていくと近くには他にもBelongsToAssociationを生成しているメソッドが沢山見つかる。順番に見てみよう。

skinny/orm/feature/AssociationsFeature.scala#L203

def belongsToWithFk[A](right: AssociationsWithIdFeature[_, A], fk: String, merge: (Entity, Option[A]) => Entity): BelongsToAssociation[Entity] {
    belongsToWithFkAndJoinCondition(right, fk, sqls.eq(this.defaultAlias.field(fk), right.defaultAlias.field(right.primaryKeyFieldName)), merge)
}

これは先のbelongsToのJoinCondition自動生成におけるfkのみをカスタムしてConditionを生成するものだろうか。呼び出しているbelongsToWithFkAndJoinConditionを見てみる

skinny/orm/feature/AssociationsFeature.scala#L207

def belongsToWithFkAndJoinCondition[A](right: AssociationsFeature[A], fk: String, on: SQLSyntax, merge: (Entity, Option[A]) => Entity): BelongsToAssociation[Entity] = {
    val joinDef = leftJoinWithDefaults(right, on)
    val extractor = extractBelongsTo[A](right, fk, right.defaultAlias, merge)
    new BelongsToAssociation[Entity](this, unshiftJoinDefinition(joinDef, right.defaultJoinDefinitions.filter(_.enabledEvenIfAssociated)), extractor)
  }

この2つのメソッドの関係が、先のwithFk無しの場合とほとんど同じようなものであるというのはすぐに分かるであろう。しかしForeignKeyはjoinConditionの指定をするためだけではなく、Extractorでも指定している。したがって、先に出てきたbelongsToWithJoinConditionでjoinCondition内のForeignKeyを書き換えてもExtractorでエラーが出る筈で、異なるForeignKeyを使う場合はwithFkの付いたメソッドを用いる必要がある。

なお、Extractorはきちんと追っていないものの、おそらくはjoin結果を取得するまわりの設定になっていると思われる。

withAliasメソッド

さらに見ていくとbelongsToWithAliasというメソッドがある。

skinny/orm/feature/AssociationsFeature.scala#L213

def belongsToWithAlias[A](right: (AssociationsWithIdFeature[_, A], Alias[A]), merge: (Entity, Option[A]) => Entity): BelongsToAssociation[Entity] = {
  val fk = if (right._1.defaultAlias != right._2) {
    val fieldName = right._1.primaryKeyFieldName
    val primaryKeyFieldName = fieldName.head.toString.toUpperCase + fieldName.tail
    right._2.tableAliasName + primaryKeyFieldName
  } else {
    toDefaultForeignKeyName[A](right._1)
  }
  belongsToWithAliasAndFk(right, fk, merge)
}

引数rightがtupleになり、Aliasが追加されている。Alias型はどこかで見たことないだろうか?良く見る場所としては、ボイラーコード的に書いている、

override val defaultAlias: Alias[Station] = createAlias("s")

これがAliasの1つである。SkinnyORMCURDMapperはこのように、table毎にdefaultAliasが定義することになっており、selectした結果をextractするときなどはalias名を使って処理する。たとえば

SELECT ... FROM station s LEFT JOIN line l on ...

のようなQueryを生成する。

実際のコードを確認してみると、withAliasの付いていないメソッドでは、joinConditionとextractorでdefaultAliasを使っていた。defaultAliasで困る場合にはwithAliasのメソッドを使う、ということであろう。

ちなみにdefaultで駄目な場合とはどのようなものであろうか。分かりやすい例で言えば、同じテーブルを2回joinする場合などは、片方のAliasをdefaultから書き換えることが必須である(区別付かないので)。SkinnyORMで沢山のjoinを行なっていると、いつのまにかtableが2箇所出てきて突然動かなくなったりすることはある(経験済)。

あとは不幸にも同じAliasを別のtableで使った場合とかもあるだろうか。(もちろんそんなことが無いようにcreateAliasの文字が被らないように気を使ってますよね?)

直下にあるbelongsToWithAliasAndFkbelongsToWithAliasAndFkAndJoinConditionなどは説明不要であろう。特に後者は最もカスタムの効く形態で自動生成も最小に抑えてあり、デバッグ時などに役立つかもしれない。

IdのないTableをjoinする

ところでお気付きだろうか。ほぼ全てのメソッドのrightのtableの型がAssociationsWithIdFeatureになっていること、そしてまれにAssociationsFeatureなっていることを。その名のとおり前者は後者に対し、IdをPrimaryKeyとする制約条件を加えたものである。

つまりrightでIdのないTableをjoinしたいときは、まれなAssociationsFeatureを引数に取るメソッド、

  • belongsToWithFkAndJoinCondition
  • belongsToWithAliasAndFkAndJoinCondition

のどちらかを使う必要がある。

逆に言えばそれ以外のメソッドは右辺のprimary keyが暗黙にidであることを仮定している、ということであり、ForeignKeyの生成(line_idは右辺のPrimaryKeyがidであることを仮定する)やJoinConditionの生成(右辺のPrimaryKeyの定義が必要)などがそれに該当する。

最初書いたときは勘違いしていたが、TableにPrimaryKeyを設定することは可能で、ただbelongsToはそれを利用したりはしていない、というのが正しいようだ。hasOneやhasManyにこのような制限は無い。(primaryKeyさえ設定していればNoId等でも動く)

まとめ

主にbelongs-to関係のjoinを作るメソッドをソースコードを混じえつつ解説を試みた。同様にhas-one関係やhas-many関係なども、勿論いくらか引数は増えて複雑にはなっているものの、基本は同じように作られているので、コードを追えば分かるようになっている筈である。