Also known as many-to-many, is an association between an entity (Story) and a collection of many entities (User), passing via an intermediate entity (Comment).

Setup

  1. $ bundle exec hanami generate model user
  2. create lib/bookshelf/entities/user.rb
  3. create lib/bookshelf/repositories/user_repository.rb
  4. create db/migrations/20171024083639_create_users.rb
  5. create spec/bookshelf/entities/user_spec.rb
  6. create spec/bookshelf/repositories/user_repository_spec.rb
  7. $ bundle exec hanami generate model story
  8. create lib/bookshelf/entities/story.rb
  9. create lib/bookshelf/repositories/story_repository.rb
  10. create db/migrations/20171024085712_create_stories.rb
  11. create spec/bookshelf/entities/story_spec.rb
  12. create spec/bookshelf/repositories/story_repository_spec.rb
  13. $ bundle exec hanami generate model comment
  14. create lib/bookshelf/entities/comment.rb
  15. create lib/bookshelf/repositories/comment_repository.rb
  16. create db/migrations/20171024085858_create_comments.rb
  17. create spec/bookshelf/entities/comment_spec.rb
  18. create spec/bookshelf/repositories/comment_repository_spec.rb

Edit the migrations:

  1. # db/migrations/20171024083639_create_users.rb
  2. Hanami::Model.migration do
  3. change do
  4. create_table :users do
  5. primary_key :id
  6. column :name, String, null: false
  7. column :created_at, DateTime, null: false
  8. column :updated_at, DateTime, null: false
  9. end
  10. end
  11. end
  1. # db/migrations/20171024085712_create_stories.rb
  2. Hanami::Model.migration do
  3. change do
  4. create_table :stories do
  5. primary_key :id
  6. foreign_key :user_id, :users, null: false, on_delete: :cascade
  7. column :text, String, null: false
  8. column :created_at, DateTime, null: false
  9. column :updated_at, DateTime, null: false
  10. end
  11. end
  12. end
  1. # db/migrations/20171024085858_create_comments.rb
  2. Hanami::Model.migration do
  3. change do
  4. create_table :comments do
  5. primary_key :id
  6. foreign_key :user_id, :users, null: false, on_delete: :cascade
  7. foreign_key :story_id, :stories, null: false, on_delete: :cascade
  8. column :text, String, null: false
  9. column :created_at, DateTime, null: false
  10. column :updated_at, DateTime, null: false
  11. end
  12. end
  13. end

Now we can prepare the database:

  1. $ bundle exec hanami db prepare

Basic usage

Let’s edit the repositories:

  1. # lib/bookshelf/repositories/user_repository.rb
  2. class UserRepository < Hanami::Repository
  3. associations do
  4. has_many :stories
  5. has_many :comments
  6. end
  7. end
  1. # lib/bookshelf/repositories/story_repository.rb
  2. class StoryRepository < Hanami::Repository
  3. associations do
  4. belongs_to :user
  5. has_many :comments
  6. has_many :users, through: :comments
  7. end
  8. def find_with_comments(id)
  9. aggregate(:user, comments: :user).where(id: id).map_to(Story).one
  10. end
  11. def find_with_commenters(id)
  12. aggregate(:users).where(id: id).map_to(Story).one
  13. end
  14. end
  1. # lib/bookshelf/repositories/comment_repository.rb
  2. class CommentRepository < Hanami::Repository
  3. associations do
  4. belongs_to :story
  5. belongs_to :user
  6. end
  7. end

We have defined explicit methods only for the operations that we need for our model domain. In this way, we avoid to bloat StoryRepository with dozen of unneeded methods.

Let’s create a couple of users, a story, then a comment:

  1. users = UserRepository.new
  2. author = users.create(name: "Luca")
  3. # => #<User:0x00007ffe71bc3b18 @attributes={:id=>1, :name=>"Luca", :created_at=>2017-10-24 09:06:57 UTC, :updated_at=>2017-10-24 09:06:57 UTC}>
  4. commenter = users.create(name: "Maria G")
  5. # => #<User:0x00007ffe71bb3010 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>
  1. stories = StoryRepository.new
  2. story = stories.create(user_id: author.id, text: "Hello, folks")
  3. # => #<Story:0x00007ffe71b4ace0 @attributes={:id=>1, :user_id=>1, :text=>"Hello folks", :created_at=>2017-10-24 09:09:59 UTC, :updated_at=>2017-10-24 09:09:59 UTC}>
  1. comments = CommentRepository.new
  2. comment = comments.create(user_id: commenter.id, story_id: story.id, text: "Hi and welcome!")
  3. # => #<Comment:0x00007ffe71af9598 @attributes={:id=>1, :user_id=>2, :story_id=>1, :text=>"Hi and welcome!", :created_at=>2017-10-24 09:12:30 UTC, :updated_at=>2017-10-24 09:12:30 UTC}>

What happens if we load the user with StoryRepository#find?

  1. story = stories.find(story.id)
  2. # => #<Story:0x00007ffe71ae2cd0 @attributes={:id=>1, :user_id=>1, :text=>"Hello folks", :created_at=>2017-10-24 09:09:59 UTC, :updated_at=>2017-10-24 09:09:59 UTC}>
  3. story.comments
  4. # => nil

Because we haven’t explicitly loaded the associated records story.comments is nil. We can use the method that we have defined on before (#find_with_comments):

  1. story = stories.find_with_comments(story.id)
  2. # => #<Story:0x00007fd45e327e60 @attributes={:id=>2, :user_id=>1, :text=>"Hello folks", :created_at=>2017-10-24 09:09:59 UTC, :updated_at=>2017-10-24 09:09:59 UTC, :user=>#<User:0x00007fd45e326bc8 @attributes={:id=>1, :name=>"Luca", :created_at=>2017-10-24 09:06:57 UTC, :updated_at=>2017-10-24 09:06:57 UTC}>, :comments=>[#<Comment:0x00007fd45e325930 @attributes={:id=>1, :user_id=>2, :story_id=>2, :text=>"Hi and welcome!", :created_at=>2017-10-24 09:12:30 UTC, :updated_at=>2017-10-24 09:12:30 UTC, :user=>#<User:0x00007fd45e324490 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>}>]}>
  3. story.comments
  4. # => [#<Comment:0x00007fd45e325930 @attributes={:id=>1, :user_id=>2, :story_id=>2, :text=>"Hi and welcome!", :created_at=>2017-10-24 09:12:30 UTC, :updated_at=>2017-10-24 09:12:30 UTC, :user=>#<User:0x00007fd45e324490 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>}>]
  5. story.comments.map(&:user)
  6. # => [#<User:0x00007fd45e324490 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>]

This time story.comments has the associated records.

Similarly, we can find directly the associated commenters:

  1. story = stories.find_with_commenters(story.id)
  2. # => #<Story:0x00007f8e28b79d88 @attributes={:id=>2, :user_id=>1, :text=>"Hello folks", :created_at=>2017-10-24 09:09:59 UTC, :updated_at=>2017-10-24 09:09:59 UTC, :users=>[#<User:0x00007f8e28b78b40 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>]}>
  3. story.users
  4. # => [#<User:0x00007f8e28b78b40 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>]

Aliasing

In the examples above story.users was the way to go, because of the Hanami conventions, but that isn’t a great name for an association. We can alias users with something more meaningful like commenters:

  1. # lib/bookshelf/repositories/story_repository.rb
  2. class StoryRepository < Hanami::Repository
  3. associations do
  4. belongs_to :user
  5. has_many :comments
  6. has_many :users, through: :comments, as: :commenters
  7. end
  8. def find_with_comments(id)
  9. aggregate(:user, comments: :commenter).where(id: id).map_to(Story).one
  10. end
  11. end
  1. # lib/bookshelf/repositories/comment_repository.rb
  2. class CommentRepository < Hanami::Repository
  3. associations do
  4. belongs_to :story
  5. belongs_to :user, as: :commenter
  6. end
  7. end
  1. story = stories.find_with_comments(2)
  2. # => #<Story:0x00007fe289f2f800 @attributes={:id=>2, :user_id=>1, :text=>"Hello folks", :created_at=>2017-10-24 09:09:59 UTC, :updated_at=>2017-10-24 09:09:59 UTC, :user=>#<User:0x00007fe289f2e810 @attributes={:id=>1, :name=>"Luca", :created_at=>2017-10-24 09:06:57 UTC, :updated_at=>2017-10-24 09:06:57 UTC}>, :comments=>[#<Comment:0x00007fe289f2d618 @attributes={:id=>1, :user_id=>2, :story_id=>2, :text=>"Hi and welcome!", :created_at=>2017-10-24 09:12:30 UTC, :updated_at=>2017-10-24 09:12:30 UTC, :commenter=>#<User:0x00007fe289f2c420 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>}>]}>
  3. story.comments
  4. # => [#<Comment:0x00007fe289f2d618 @attributes={:id=>1, :user_id=>2, :story_id=>2, :text=>"Hi and welcome!", :created_at=>2017-10-24 09:12:30 UTC, :updated_at=>2017-10-24 09:12:30 UTC, :commenter=>#<User:0x00007fe289f2c420 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>}>]
  5. story.comments.map(&:commenter)
  6. # => [#<User:0x00007fe289f2c420 @attributes={:id=>2, :name=>"Maria G", :created_at=>2017-10-24 09:07:16 UTC, :updated_at=>2017-10-24 09:07:16 UTC}>]