makandra / active_type

Make any Ruby object quack like ActiveRecord
MIT License
1.09k stars 74 forks source link

Dynamic Association Type ? #12

Closed andyl closed 10 years ago

andyl commented 10 years ago

In #11, we talked about creating hard-coded associations that return ActiveType objects. This approach works in situations where you know the expected type ahead of time.

The downside of this approach is that you are forced to create a chain of ActiveType classes, which can sometimes grow deep and hard to manage.

In many situations, I want to dynamically select the type of object to be returned from an association. It would be great if I could do something like this:

class User < ActiveRecord::Base
  has_many :messages
end
class Message < ActiveRecord::Base
  belongs_to :user
end
class User::AsTexting < ActiveType::Record[User]
  has_many :texting_messages, class_name: 'Message::AsTexting'
end
class Message::AsTexting < ActiveType::Record[Message]
  belongs_to :texting_user, class_name: 'User::AsTexting'
end
user = User.new
texting_messages = user.messages('AsTexting')  # returns a collection of Message::AsTexting

The idea I have is similar to single-table-inheritance. But instead of storing the class name in a type field, it would be passed as a method argument.

Has anyone done anything like this? Is this possible in Rails?

andyl commented 10 years ago

I found that AR supports a #becomes method (eg new_obj = obj.becomes(klass)). Could be used in a view, or in a controller like:

texting_messages = user.messages.map {|x| x.becomes(Message::AsTexting)}

Better yet:

class Array
  def becomes(klass)
    self.map {|x| x.becomes(klass)}
  end
end

texting_messages = user.messages.all.becomes(Message::AsTexting)

This seems ok - is there a better approach I should consider?

abinoam commented 10 years ago

Hi @andyl,

Your approach is more linked with the base User class and it's results. I'm more inclined to let them "over there". Another "problem" is that it makes one conversion operation for each member of the messages collection. What about to first convert the User instance to a User::AsTexting and only after that to call the messages method on it? Perhaps an "elegant" way (IMHO) is to use a capitalized conversion method (Like Integer(), String(), etc). (But it may be just a matter of 'taste')

I saw this approach in details on the "Confident Ruby Book" by Avid Grimn (@avdi).

I'm not able to test code right now, but it would be something like...

class User::AsTexting < ActiveType::Record[User]
  has_many :texting_messages, class_name: 'Message::AsTexting', foreign_key: :user_id
end

def User::AsTexting(user)
  user.becomes(User::AsTexting)
end

# It would be...
texting_messages = User::AsTexting(user).messages

# versus
texting_messages = user.messages.all.becomes(Message::AsTexting)

At the book, there's a more elegant way to define this conversion function inside a module.

Please let me know if you test this approach.

@kratob and @henning-koch are the creators and maintainers of this gem.

andyl commented 10 years ago

Hi @abinoam - yes it is more efficient to incur the conversion cost once on the 'topmost' object.

I've had good luck using the object.becomes(klass) style, and it has the extra benefit of not having to write a conversion function for each sub-class. For example:

collection = user.becomes(User::AsTexting).texting_messages

The other benefit of converting the 'topmost' object is that you can refine the relation, like:

col = user.becomes(User::AsTexting).texting_messages.where(condition: 'valid')
obj = user.becomes(User::AsTexting).texting_messages.create   # create Message::AsTexting

There are differences between the Capitalized conversion function and the post-fix conversion methods (like .to_s or .becomes). But I don't know enough about the differences to say which is best.

triskweline commented 10 years ago

Internally at makandra we usually define a becomes method on ActiveRecord relations and has_many associations (which are also relations).

It it possible to implement such a becomes method without loading any records, and without having to to iterate over every record and cast it to a new class:

ActiveRecord::Base.class_eval do

  def self.becomes(other_class)
    other_class.scoped.merge(scoped)
  end

end

You can use it similarly to the examples in the previous comments:

user.messages.becomes(Message::AsTexting)

Chaining subsequent calls like find, where or build will then use the Message::AsTexting model.

andyl commented 10 years ago

@henning-koch thanks this is just what I wanted.

But - it looks like the 'scoped' method was removed in Rails4.1. Can you give an example that is compatible with Rails 4.1 ?

triskweline commented 10 years ago

The equivalent method in Rails 4 is all. Or you can install edge_rider to get scoped in Rails 4.

andyl commented 10 years ago

When I run the example in IRB things work as expected:

> new = Message::AsTexting.all
> old = user.messages
> new.klass      #=> Message::AsTexting
> old.klass        #=> Message
> new.merge(old).first.class #=>  Message::AsTexting

But within the context of the becomes method it doesn't work for me:

ActiveRecord::Base.class_eval do
  def self.becomes(other_class)
    new = other_class.all
    old = all
    puts "1> #{other_class}"
    puts "2> #{new.klass}"
    puts "3> #{old.klass}"
    new.merge(old)
  end
end

user.messages.becomes(Message::AsTexting).first.class
1> Message::AsTexting
2> Message
3> Message
Message