With Merb v1.0 almost upon us I did a little research into it and it's opinionated choice for DataMapper.
After some initial hurdles I've come to be taken by DataMapper. It's nice, easy and fast to work with. The toughest thing to model in DataMapper though was a many-to-many self-referential graph. Others have attempted to solve this in various ways, but all seemed lacking to me. The documentation and specs are also still lacking somewhat at the moment, though good efforts are underway to improve this.
So the thing I wanted to model was a user that can friend many other users. On the user model I wanted an easy way to get to the friends of the user, and to get the users that have friended the user, all via datamapper associations.
Below is a solution that I think models this well in the database, and provides nice interfaces in a bi-directional fashion, providing datamapper collections to work with.
If this weren't a self-referential many-to-many relationship you don't need to be specific about the intermediate model. For example, you would simply do this:
class Article
include DataMapper::Resource
has n, :categories, :through => Resource
end
class Category
include DataMapper::Resource
has n, :articles, :through => Resource
end
The :through => Resource bit tells DataMapper that you're referring to another model through the anonymous Resource model. This will basically define a class ArticleCategory and a matching table articles_categories
However, due to the self-referential nature that we're looking at, we need to be specific about the intermediate model. Here are the models:
class User
include DataMapper::Resource
property :id, Serial
property :name, String, :nullable => false
has n, :friended_users
has n, :friends, :through => :friended_users, :class_name => "User",
:child_key => [:user_id]
has n, :friended_by, :through => :friended_users, :class_name => "User",
:remote_name => :user, :child_key => [:friend_id]
end
class FriendedUser
include DataMapper::Resource
property :user_id, Integer, :key => true
property :friend_id, Integer, :key => true
belongs_to :user, :child_key => [:user_id]
belongs_to :friend, :class_name => "User", :child_key => [:friend_id]
end
Things to note:
- The
FriendedUsermodel defines two integer properties explicitly and makes them the primary key. There is no need for an auto-generated (Serial) ID. Because of the primary key definition, uniqueness of the tuple(:user_id, :friend_id)is guaranteed and you get the indexes. - The
FriendedUsermodelbelongs_to :user, referring to theUserclass. It maps into the property:user_id. - The
FriendedUsermodelbelongs_to a :friend, referring to theUserclass. It maps into the property:friend_id. - The
Usermodel has many:friended_users, refering to theFriendedUserclass. Note that when you access this collection for a specific user it will join with the:user_idproperty of theFriendedUsermodel. - It then defines the two associations
:friendsand:friended_by, through:friended_users. It must specify that what we'll expect to be returned areUserinstances. It also must specify the:child_keyinFriendedUserto which this association should be linked. Lastly, for the:friended_byassociation you must specify the:remote_name, which refers to thebelongs_to :userbit inFriendedUser. You don't need to (but can if you want to) specify:remote_namefor the:friendsassociation because datamapper will infer that it is referring to thebelongs_to :friendbit inFriendedUserbased on the association name:friends.
Let's test this:
DataMapper.auto_migrate!
jan = User.create(:name => 'Jan')
piet = User.create(:name => 'Piet')
klaas = User.create(:name => 'Klaas')
bo = User.create(:name => 'Bo')
fu1 = FriendedUser.create(:user => jan, :friend => piet)
fu2 = FriendedUser.create(:user => jan, :friend => klaas)
fu3 = FriendedUser.create(:user => bo, :friend => klaas)
# Output
p "-"*78
p bo.friended_users == [fu3]
p jan.friended_users == [fu1, fu2]
p jan.friends == [piet, klaas]
p klaas.friended_by == [jan, bo]
The resulting output:
~ CREATE TABLE "users" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" VARCHAR(50) NOT NULL)
~ CREATE TABLE "friended_users" ("user_id" INTEGER NOT NULL, "friend_id" INTEGER NOT NULL, PRIMARY KEY("user_id", "friend_id"))
~ INSERT INTO "users" ("name") VALUES ('Jan')
~ INSERT INTO "users" ("name") VALUES ('Piet')
~ INSERT INTO "users" ("name") VALUES ('Klaas')
~ INSERT INTO "users" ("name") VALUES ('Bo')
~ INSERT INTO "friended_users" ("user_id", "friend_id") VALUES (1, 2)
~ INSERT INTO "friended_users" ("user_id", "friend_id") VALUES (1, 3)
~ INSERT INTO "friended_users" ("user_id", "friend_id") VALUES (4, 3)
-----------------------------------------"
~ SELECT "user_id", "friend_id" FROM "friended_users" WHERE ("user_id" IN (4)) ORDER BY "user_id", "friend_id"
true
~ SELECT "user_id", "friend_id" FROM "friended_users" WHERE ("user_id" IN (1)) ORDER BY "user_id", "friend_id"
true
~ SELECT "users"."id", "users"."name" FROM "users" INNER JOIN "friended_users" ON ("users"."id" = "friended_users"."friend_id") WHERE ("friended_users"."user_id" = 1) GROUP BY "users"."id", "users"."name" ORDER BY "users"."id"
true
~ SELECT "users"."id", "users"."name" FROM "users" INNER JOIN "friended_users" ON ("users"."id" = "friended_users"."user_id") WHERE ("friended_users"."friend_id" = 3) GROUP BY "users"."id", "users"."name" ORDER BY "users"."id"
true
Lastly, note that instead of creating a friendship by calling FriendedUser.create you can also create a friend like this:
jan.friended_users.create(:friend => piet)
This should be added to the wiki, it seems to be the way to go with this.
— Nathan on October 30, 2008 at 2:44 pm #
You might want to add :nullable => false to the keys, just for good measure.
— Jeremy Nicoll on October 30, 2008 at 5:32 pm #
A rather cosmetic issue, but instead of "friended_users", wouldn't "friendships" and a Friendship class be more appropriate? The table is, after all, a description of the relationship, and a "friended user" already exists, in effect, in the "users" table...
Many thanks for the tutorial though. Very useful.
— Mark H-J on November 27, 2008 at 7:52 am #
I was trying to use this as a model for my classes, but I wasn't able to get the
p jan.friends == [piet, klaas] to work until I realised that the relation 'friends' must match the convention friend_id in the FriendedUsers class. If you changed the has n, :friends to has n, :friendeds, for example, you get the following error:
C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/query.rb:408:in `normalize_links': +options[:links]+ entry nil
not supported (ArgumentError)
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/query.rb:241:in `initialize'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/model.rb:421:in `new'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/model.rb:421:in `scoped_query'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/model.rb:252:in `all'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/relationship_chain.rb:26:in `send'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/relationship_chain.rb:26:in `get_children'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/relationship.rb:174:in `with_repository'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/support/kernel.rb:6:in `repository'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core.rb:181:in 'repository'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/repository.rb:44:in `scope'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core.rb:181:in `repository'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/support/kernel.rb:6:in `repository'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/relationship.rb:174:in `with_repository'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/relationship_chain.rb:25:in `get_children'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/one_to_many.rb:251:in `children'
from C:/ruby/lib/ruby/gems/1.8/gems/dm-core-0.9.10/lib/dm-core/associations/one_to_many.rb:309:in `method_missing'
from friends.rb:45
— Kristian M on March 23, 2009 at 6:25 am #
why do you need: "It also must specify the :child_key in FriendedUser to which this association should be linked." ??
there is through :friended_users
in the declaration:
has n, :friends, :through => :friended_users, :class_name => "User",
:child_key => [:user_id]
so
the link can be derived from :friended_users
--> indeed that link was :user_id
is this some unnecessary redundancy in datampper?
— adg on June 6, 2009 at 11:52 pm #