GORMのMany-to-Manyをちょっと侮っていた...かも知れない

例のついったクローンで「お友だち/フォローされてる」を実装しようとしたときに,つまずいたというかGORMをよくわかってなくてベタな実装したかなって話を整理しておく。


やりたいことは,ある特定の人をピックアップして,

  • その人をお友達登録している人の一覧
  • その人からフォローされている人の一覧

を抜き出す。SQL使うんなら何てこと無い事をGORMで片付けようとすると,ちょっとしたネタになるのが,なんとも...。
ムダに長いから,外に見せるのはここまで。


テストデータは,これ(↓)。


データの上下関係は無いけど,見た目にわかりやすく上が「フォロー(お友達登録)している人たち」で下が「フォローされている人たち」。"a1"と"c"はワザとああゆう状態にしてある。
いわゆる多対多リレーションなんで関連テーブル作るんだけど,GORMのMany-to-Manyに頼らないで,自前でリレーション用ドメイン作って実装した。んが,後日Many-to-Many版もトライしてみたところ,思いの外簡単にできたので,両者を比べてみようとオモタ。


「なんだ,Many-to-Manyでも平気だな」って思ったら,そうでもないところがあったんで,すでに出来てるモノをMany-to-Manyに焼き直しはしなかったよ。
では二元中継でお楽しみ下さい(って誰に言ってんだか...)。


左が自前リレーション用ドメイン版,右がGORMのMany-to-Many版ね。




まずはドメインクラス。

class User {
  String name
  String toString() { "User($id:$name)" }
}

class Relation {
  static belongsTo = [ follower:User,
                       friend:User ]
}


Many-to-Manyだと関連用のドメインは作る必要無い(勝手に作られる)。

class Person {
  String name
  static hasMany = [ friends:Person,
                     followers:Person ]
  String toString() { "Person($id:$name)" }
}


図式化するとこうなる。


この図はIntelliJが描いてくれました。いいでしょ。:-P


データベースに展開されたテーブルは,こんなの。


データ構造は変わらずだけど,Many-to-Many版では人知れず関連テーブルが作られる。


と,ここまでが仕込み。あとはデータ突っ込んで,実際にGORMで検索かけて思い通りのデータを抜けるかどうかを確認する。
ここから,テストコードが登場けど,GrailsUnitTestCaseを継承しててもテストカテゴリはintegrationだってことに注意しといて(要するにテストコードは,$PROJECT_HOME/test/integration に置いておく)。




初期データ突っ込むコードは,こんなの。独自リレーション版はリレーション用ドメインを扱えるので,データ登録がスッキリするね。

class UserTests extends GrailsUnitTestCase {
  def u1, u11, u12
  def u2,      u22, u23
  def u3
  
  protected void setUp() {
    super.setUp()
    u1 = new User(name:'A').save()
    new Relation(follower:u1,
                 friend:u11 = new User(name:'A1').save()).save()
    new Relation(follower:u1,
                 friend:u12 = new User(name:'A2').save()).save()

    u2 = new User(name:'B').save()
    new Relation(follower:u2, friend:u11).save()
    new Relation(follower:u2,
                 friend:u22 = new User(name:'B1').save()).save()
    new Relation(follower:u2,
                 friend:u23 = new User(name:'B2').save()).save()

    u3 = new User(name:'C').save()
  }
    :


Many-to-Manyだと,GORMの作法に則って関連付けしてあげるのが,少々鬱陶しい?
正直,関連付けするとき,個々のドメインのsave()タイミングがよくわかってない。:-)

class PersonTests extends GrailsUnitTestCase {
  def p1, p11, p12
  def p2,      p22, p23
  def p3

  protected void setUp() {
    super.setUp()
    p1 = new Person(name:'a').save()
    p2 = new Person(name:'b').save()
    p3 = new Person(name:'c').save()

    p1.addToFriends(p11 = new Person(name:'a1'))
    p1.addToFriends(p12 = new Person(name:'a2'))
    p11.addToFollowers(p1)
    p12.addToFollowers(p1)

    p2.addToFriends(p11)
    p2.addToFriends(p22 = new Person(name:'b2'))
    p2.addToFriends(p23 = new Person(name:'b3'))
    p11.addToFollowers(p2)
    p22.addToFollowers(p2)
    p23.addToFollowers(p2)
  }
    :



小手始めに「ある人のお友達をひいてくる」のコード。

  void test_Bの友達をひいてくる() {
    assertEquals([u11, u22, u23] as Set,
                 Relation.withCriteria {
                   follower { eq 'name', 'B' }
                 }*.friend as Set)
               // ↑ここの *. に注目!!
  }


実行されたSQLはこんなの(SQLHibernateが生成しているので,あえて受け身)。

select
    this_.id as id0_1_,
    this_.version as version0_1_,
    this_.follower_id as follower3_0_1_,
    this_.friend_id as friend4_0_1_,
    follower_a1_.id as id3_0_,
    follower_a1_.version as version3_0_,
    follower_a1_.name as name3_0_ 
from
    relation this_ 
left outer join
    user follower_a1_ 
        on this_.follower_id=follower_a1_.id 
where
    (
        follower_a1_.name=?
    )


Many-to-Many版は1行で終わる。

  void test_bの友達をひいてくる() {
    assertEquals([p11, p22, p23] as Set,
                  Person.findByName("b").friends as Set)
  }

同じく実行されたSQL

select
    this_.id as id0_0_,
    this_.version as version0_0_,
    this_.name as name0_0_ 
from
    person this_ 
where
    this_.name=?

キャッシュ聞いてるからか1+N問題がでなかったねぇ。



次が,「ある人をフォローしている人をひいてくる」のコード。
さっきと条件変わっただけで,構造は同じ。

  void test_A1をフォローしている人をひいてくる() {
    assertEquals([u1, u2] as Set,
                 Relation.withCriteria {
                   friend { eq 'name', 'A1' }
                 }*.follower as Set)
  }

ちょっと考察しとく。

  • Userドメインは関連を持ってないので,1+N問題とか気にしなくて良い。
  • Relationに対する「やりたいこと」と「実際の検索条件」が逆転しているので,ウッカリしやすい。
    • お友達を知りたければ,[私を follower に登録している人] を探す
    • フォロアーを知りたければ,[私を friend に登録している人] を探す


当然,こっちもそう。

  void test_a1をフォローしている人をひいてくる() {
    assertEquals([p1, p2] as Set,
                 Person.findByName("a1").followers as Set)
  }

こっちも考察しとく。

  • 「やりたいこと」と「実装コード」が明解。
  • イチイチ,1+N問題やeager fetchかどうかを気にかけないといけない。


ここまで,どっちもどっちだからMany-to-Manyでも良いかと思ってたけど,最後のこれで差が出た。




参照ばかり気にしてて,すっかり忘れていたのがこれ,「ある人と縁を切る」
以下が,そのコード。

  void test_A1の縁を切る() {
    Relation.executeUpdate("""
      delete Relation a
      where a.friend = (select b from User b
                        where b.name = ?)""", ['A1'])
    assertEquals([] as Set,
                 Relation.withCriteria {
                   friend { eq 'name', 'A1' }
                 }*.follower as Set)
  }

withCriteriaで検索してひとつひとつ delete() ってのが王道なんだろうけど,ちょっとムリしてbulk deleteしてみた(HQLって,from句にサブクエリ指定できるのはSELECT文だけなんだって。だから,where句にサブクエリ指定してる)。
「HQL直書きってどうよ」と思わなくもないが,せっかく関連テーブルを直接操作できるんだから,このメリットを享受しないでどうする。:-)


bulk delete部分のSQLのログはこんなの。

delete 
from
    relation 
where
    friend_id=(
        select
            id 
        from
            user user1_ 
        where
            user1_.name=?
    )


Many-to-Many版は,どうやったら自動生成された関連テーブルを操作できるのかわからずじまいだったので,bulk deleteは諦めて普通(?)に組んだ。

  void test_a1の縁を切る() {
    def p = Person.findByName('a1')
    Person.withCriteria {
      friends { eq 'name', 'a1' }
    }.each {
      it.removeFromFriends(p)
      p.removeFromFollowers(it)
    }

    assertEquals([] as Set, p.followers as Set)
    assertEquals([] as Set,
                 Person.withCriteria {
                   friends { eq 'name', 'a1'}
                 } as Set)
  }

発行したSQL多分こんなの。
#何か知らんがいろいろ発行されてて,ちょっと恣意的に選別してある(要するにあやしいのだ)。

select
    this_.id as id1_0_,
    this_.version as version1_0_,
    this_.name as name1_0_ 
from
    person this_ 
where
    this_.name=?


select
    this_.id as id1_1_,
    this_.version as version1_1_,
    this_.name as name1_1_,
    friends3_.person_friends_id as person3_3_,
    friends_al1_.id as person2_3_,
    friends_al1_.id as id1_0_,
    friends_al1_.version as version1_0_,
    friends_al1_.name as name1_0_ 
from
    person this_ 
left outer join
    person_person friends3_ 
        on this_.id=friends3_.person_friends_id 
left outer join
    person friends_al1_ 
        on friends3_.person_id=friends_al1_.id 
where
    (
        friends_al1_.name=?
    )


delete 
from
    person_person 
where
    person_friends_id=? 
    and person_id=?


delete 
from
    person_person 
where
    person_friends_id=? 
    and person_id=?


delete 
from
    person_person 
where
    person_followers_id=?


あと,そうそう,さっき「普通(?)」としたのは,こっちの方が普通っぽいんだけどエラーになったので,withCriteria版にした経緯があったため。

def p = Person.findByName('a1')
p.followers.each {
  it.removeFromFriends(p)
  p.removeFromFollowers(it)
}

出てきたエラーはこれ。

java.util.ConcurrentModificationException
        at java.util.HashMap$HashIterator.nextEntry(HashMap.java:793)
        at java.util.HashMap$KeyIterator.next(HashMap.java:828


んで,結論。Many-to-Manyも悪く無いけど,関連テーブル直接扱えた方が小回りも利くよっ!!


...と言ってみたけど,ただでさえGORM > Hibernate(HQL) > SQL と隠蔽されているんで,あんまりオブジェクト指向っぽいモデルにしないでERっぽくしてたほうか良いかもね。