ruby-hyperloop / hyper-mesh

The project has moved to Hyperstack!! - Synchronization of active record models across multiple clients using Pusher, ActionCable, or Polling
https://hyperstack.org/
MIT License
22 stars 12 forks source link

Add has_and_belongs_to_many via has_many :through #48

Open sfcgeorge opened 6 years ago

sfcgeorge commented 6 years ago

This code implements has_and_belongs_to_many in Hyperloop by using the existing has_many :through functionality and dynamically generating the join table.

if RUBY_ENGINE == "opal"
  module ActiveRecord
    module ClassMethods
     def has_and_belongs_to_many(assoc, scope=nil, **options)
        other = assoc.singularize
        name = self.name.downcase
        rassoc = name.pluralize
        habtm_name = [assoc, rassoc].sort.join("_") # alphabetical order
        habtm_class = habtm_name.singularize.camelize

        has_many habtm_name # regular has_many makes the through work
        has_many assoc, **options.merge(through: habtm_name)

        return if ::Object.const_defined?(habtm_class) # prevent duplication

        # double colon before Object and Class are needed - Opal bug?
        ::Object.const_set(
          habtm_class, ::Class.new(::ActiveRecord::Base) do
            belongs_to other, foreign_key: "#{other}_id", inverse_of: assoc
            belongs_to name, foreign_key: "#{name}_id", inverse_of: rassoc
          end
        )
      end
    end
  end
end

Note that this patch alone only enables read only access. This is because Hyperloop doesn't support writing to has_many :through e.g. with the << method (but Rails does). You can't work around it by trying to create the join table directly because it doesn't exist on the server side so execute_remote fails.

The inverse_of isn't necessary but can't hurt I think. I hoped it would make the association writeable but it doesn't.

Needs tests adding to test_app.

Tested in lap17 and Opal 0.10

sfcgeorge commented 6 years ago

Well I came to the conclusion that it's not easy to get saving of HABTM working. Because it translates to has_many :through which Hyperloop doesn't support saving to. But even if it did, there is no server-side join model, so Rails complains.

For now at least, I've decided the best thing to do is leave my HABTM patch for read-only associations, but for associations that need writing, convert them to has_many through on the server side. It's not a difficult conversion, and allows you to add validations which might otherwise have been on the parent but work better with hyperloop on the join. So after manual conversion, this is how it looks:

What you start with, HABTM

class Post < ApplicationRecord
  has_and_belongs_to_many :tags
end

class Tag < ApplicationRecord
  has_and_belongs_to_many :posts
end

# what works with my patch in a component
render(DIV) do
  Post.all.first.tags.each do |tag|
    SPAN { tag.name } # yay
  end
end

# but saving doesn't work
render(DIV) do
  # no execute_remote, not saved
  Post.all.first.tags << Tag.find_by(name: "Hyperloop") 
end

How you refactor to has_many through

class Post < ApplicationRecord
  has_many :posts_tags
  has_many :tags, through: :posts_tags
end

# * the order of the class names must be alphabetical.
# * yes the first is plural and second is not.
class PostsTag < ApplicationRecord
  belongs_to :post
  belongs_to :tag
  # optional validation example, bonus of manual conversion
  validate :maximum_comments

private  
  def maximum_comments
    return unless post.comments.count > 10
    errors.add(:comments, "maximum 10 comments, stop spammin!")
  end
end

class Tag < ApplicationRecord
  has_many :posts_tags
  has_many :posts, through: :posts_tags
end

# This listing example above still works

# This is what you want to do but still doesn't work
Post.all.first.tags << Tag.find_by(name: "Hyperloop") 
# This also doesn't work but feels like it should
Post.all.first.posts_tags.create tag: Tag.find_by(name: "Hyperloop")
# This does work but feels gross. Permission works nicely at least
PostsTag.create(post: Post.all.first, tag: Tag.find_by(name: "Hyperloop"))