abangratz - blag

Stuff that matters - at least for me.

Datamapper: Customizing Associations

Datamapper is an alternative ORM for various ruby projects, and can work as an ORM replacement for ActiveRecord in Ruby on Rails. The most pervasive arguments for using Datamapper over AR is the easier handling of legacy databases and the intelligent deconstruction of N+1 queries, and the fact that functionality is splitted into small plugins, so for simple projects it does not weigh down the framework codebase with unused code.

One other strength for me is the auto-migration part: You do not have (but you are able) to add separate migrations that pretty much reflect what you have configured in the models already. It takes the configuration of the model properties and creates the tables from that (destructive, non-destructive: your choice). This means that the control of the properties of a data model is no longer handled by a separate script, but by the definitions of the structure of the models itself. This can optionally include referential integrity in the underlying database, too.

Also, the documentation is really good. There are whole chapters handling understanding of HABTM (has n, through: …, self-referential HABTM relationships et al). But one chapter left me stumped a bit:

How to customize a relationship?

Maybe it was me skimming over the surface of the documentation, maybe my current needs were not exactly covered, but reading the fine manual was not quite enough for me. I still managed to create classes that wouldn’t play nice.

The problem:

I had a simple model, User:

User Model - user.rb
1
2
3
4
5
6
7
8
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

end

And another simple model, Message:

Message Model - message.rb
1
2
3
4
5
6
7
8
9
class Message

  include DataMapper::Resource

  property id, Serial

  property subject, required: true, length: 3..255
  property text, lazy: false
end

Now, I wanted to add a relation from User to Message as follows:

  • a User has many sent messages
  • a Message belongs to one sender

Doing it as a normal relation would be simple, as follows:

First, I change the User model:

User Model - user.rb
1
2
3
4
5
6
7
8
9
10
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

  has n, :messages

end

Then the Message model:

Message Model - message.rb
1
2
3
4
5
6
7
8
9
10
11
class Message

  include DataMapper::Resource

  property id, Serial

  property subject, required: true, length: 3..255
  property text, lazy: false

  belongs_to :user
end

So far, so easy. Now the more complex part: I want to have another, more complex relation:

  • a Message can have many recipients
  • a User can have many received messages

Now, as you can see immediately, this is going to be a real HABTM-relationship. But when I started to add the relation to the User model, I saw oddities immediately:

User Model - user.rb
1
2
3
4
5
6
7
8
9
10
11
12
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

  has n, :messages

  has n, :messages, through: Resource  # That will not do.

end

At this point I decided to just rename the first relation to what I already suggested: sender and sent_messages:

User Model - user.rb
1
2
3
4
5
6
7
8
9
10
11
12
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

  has n, :sent_messages, 'Message', child_key: ['sender_id']

  has n, :messages, through: Resource

end
Message Model - message.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class Message

  include DataMapper::Resource

  property id, Serial

  property subject, required: true, length: 3..255
  property text, lazy: false

  belongs_to :sender, 'User', child_key: ['sender_id']

  has n, :users, through: Resource
end

As an explanation: If I rename a relation, but there is no model that corrensponds to the singularized name (e.g. Sender), Datamapper will complain when migrating or instantiating the relationship. So, I have to tell datamapper to use the ‘Message’ model for the relation ‘sent_messages’, and the ‘User’ model for the ‘sender’ relation. The constraint ‘child_key’ helps with selecting the correct foreign key field in the child model - the side that has the ‘belongs_to’ association added.

And the second association will add a table messages_users with the fields user_id and message_id as primary key. This should be sufficient …

But no, I wanted a bit more: first, I wanted to store the time the message was read by each recipient, and then I wanted to have nice names for the associations without using (ugly) proxy methods.

The first part is easy: I will add another model, Notification, that keeps the recipient/message relation and has a ‘read’ DateTime field as payload:

Notification Model - notification.rb
1
2
3
4
5
6
7
8
9
class Notification

  include DataMapper::Resource

  property :read, DateTime, required: false #if it is null, the associated message has not been read yet

  belongs_to :message, key: true
  belongs_to :user, key: true
end

The key: true constraint on the property means that both fields are part of the primary key. Now let’s adapt the other two classes. User first:

User Model - user.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

  has n, :sent_messages, 'Message', child_key: ['sender_id']

  has n, :notifications # this is a simple has many/has one relationship

  has n, :messages, through: :notifications

end

Message second:

Message Model - message.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Message

  include DataMapper::Resource

  property id, Serial

  property subject, required: true, length: 3..255
  property text, lazy: false

  belongs_to :sender, 'User', child_key: ['sender_id']

  has n, :notifications # this is a simple has many/has one relationship

  has n, :users, through: :notifications
end

The through: constraint tells Datamapper to use the notifications table as underlying n:m relationship table.

But now I wanted to make it really neat: from Clean Code we know that having methods that are named for their purpose create more value that cryptically named ones. So, I decided to rename those associations to reflect theirs:

User Model - user.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

  has n, :sent_messages, 'Message', child_key: ['sender_id']

  has n, :notifications # this is a simple has many/has one relationship

  has n, :received_messages, 'Message', through: :notifications

end
Message Model - message.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Message

  include DataMapper::Resource

  property id, Serial

  property subject, required: true, length: 3..255
  property text, lazy: false

  belongs_to :sender, 'User', child_key: ['sender_id']

  has n, :notifications # this is a simple has many/has one relationship

  has n, :recipients, 'User', through: :notifications
end

… to find out that this does not work.

First, the notification tables looked like this:

1
2
3
4
5
Notifications:
  user_id             | Integer
  recipient_id        | Integer
  message_id          | Integer
  received_message_id | Integer

Trying to use the models on the console lead to messages like:

property or constraint recipient not found on model User

Well. Erm. No good. But there is a solution already built in, but the documentation all but skips it:

[…]we will use :via to be able to provide “better” names[…]

Solution:

But the rest of the example took me a while to figure out. I will let the finished example speak for itself:

User Model - user.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User
  include DataMapper::Resource

  property id, Serial

  property name, required: true, length: 3..255

  has n, :sent_messages, 'Message', child_key: ['sender_id']

  has n, :notifications # this is a simple has many/has one relationship

  has n, :received_messages, 'Message', through: :notifications, via: :message

end
Message Model - message.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Message

  include DataMapper::Resource

  property id, Serial

  property subject, required: true, length: 3..255
  property text, lazy: false

  belongs_to :sender, 'User', child_key: ['sender_id']

  has n, :notifications # this is a simple has many/has one relationship

  has n, :recipients, 'User', through: :notifications, via: :user
end
Notification Model - notification.rb
1
2
3
4
5
6
7
8
9
class Notification

  include DataMapper::Resource

  property :read, DateTime, required: false #if it is null, the associated message has not been read yet

  belongs_to :message, key: true
  belongs_to :user, key: true
end

So, what does :via tell Datamapper? Fairly simple: it says: “ The reverse relation of this relationship is called “:message” in the “Notification” model”.

Reading the code at the documentation example first, I tried the following:

  1. Let :via point to the reverse relation in the other model (User or Message, not Notification)
  2. Let the :notification relationship know that the child key is :message_id for :received_messages in Notification (and :user_id for :recipients in Message)
  3. Both of the above

It took me the better part of two hours to read through the code and realize where I made the wrong assumption. But now everything works.

Conclusio

If you want to customize a HABTM-Association in Datamapper, make sure that:

  1. The link model (here: Notification) uses the derived name for the association as is standard: “User” is related by “belongs_to :user”, “Message” by “belongs_to :message” and so on.

  2. The other models’ relations know what the relationship’s reverse is named: In the “User” model, “received_messages” is defined as

    has n, :received_messages, 'Message', through: :notifications, via: :message

    where

    • “:received_messages” is the relationship name
    • “‘Message’” the name of the model that is linked via this relationship, and
    • “via: :message” denotes the reverse of the association in the “link” model (here: “Notification”).

I hope that this helps someone to save some time and helps understanding.

Comments