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)" } }
と,ここまでが仕込み。あとはデータ突っ込んで,実際に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はこんなの(SQLはHibernateが生成しているので,あえて受け身)。
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っぽくしてたほうか良いかもね。