Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamically extend Virtus instance attributes

Let's say we have a Virtus model User

class User
  include Virtus.model
  attribute :name, String, default: 'John', lazy: true
end

Then we create an instance of this model and extend from Virtus.model to add another attribute on the fly:

user = User.new
user.extend(Virtus.model)
user.attribute(:active, Virtus::Attribute::Boolean, default: true, lazy: true)

Current output:

user.active? # => true
user.name # => 'John'

But when I try to get either attributes or convert the object to JSON via as_json(or to_json) or Hash via to_h I get only post-extended attribute active:

user.to_h # => { active: true }

What is causing the problem and how can I get to convert the object without loosing the data?

P.S.

I have found a github issue, but it seems that it was not fixed after all (the approach recommended there doesn't work stably as well).

like image 222
potashin Avatar asked May 18 '17 11:05

potashin


2 Answers

Building on Adrian's finding, here is a way to modify Virtus to allow what you want. All specs pass with this modification.

Essentially, Virtus already has the concept of a parent AttributeSet, but it's only when including Virtus.model in a class. We can extend it to consider instances as well, and even allow multiple extend(Virtus.model) in the same object (although that sounds sub-optimal):

require 'virtus'
module Virtus
  class AttributeSet
    def self.create(descendant)
      if descendant.respond_to?(:superclass) && descendant.superclass.respond_to?(:attribute_set)
        parent = descendant.superclass.public_send(:attribute_set)
      elsif !descendant.is_a?(Module)
        if descendant.respond_to?(:attribute_set, true) && descendant.send(:attribute_set)
          parent = descendant.send(:attribute_set)
        elsif descendant.class.respond_to?(:attribute_set)
          parent = descendant.class.attribute_set
        end
      end
      descendant.instance_variable_set('@attribute_set', AttributeSet.new(parent))
    end
  end
end

class User
  include Virtus.model
  attribute :name, String, default: 'John', lazy: true
end

user = User.new
user.extend(Virtus.model)
user.attribute(:active, Virtus::Attribute::Boolean, default: true, lazy: true)

p user.to_h # => {:name=>"John", :active=>true}

user.extend(Virtus.model) # useless, but to show it works too
user.attribute(:foo, Virtus::Attribute::Boolean, default: false, lazy: true)

p user.to_h # => {:name=>"John", :active=>true, :foo=>false}

Maybe this is worth making a PR to Virtus, what do you think?

like image 98
eregon Avatar answered Oct 19 '22 01:10

eregon


I haven't investigated it further, but it seems that every time you include or extend Virtus.model, it initializes a new AttributeSet and set it to @attribute_set instance variable of your User class (source). What the to_h or attributes do is they call the get method of the new attribute_set instance (source). Therefore, you can only get attributes after the last inclusion or the extension of Virtus.model.

class User
  include Virtus.model
  attribute :name, String, default: 'John', lazy: true
end

user = User.new
user.instance_variables
#=> []
user.send(:attribute_set).object_id
#=> 70268060523540

user.extend(Virtus.model)
user.attribute(:active, Virtus::Attribute::Boolean, default: true, lazy: true)

user.instance_variables
#=> [:@attribute_set, :@active, :@name]
user.send(:attribute_set).object_id
#=> 70268061308160

As you can see, the object_id of attribute_set instance before and after the extension is different which means that the former and the latter attribute_set are two different objects.

A hack I can suggest for now is this:

(user.instance_variables - [:@attribute_set]).each_with_object({}) do |sym, hash|
  hash[sym.to_s[1..-1].to_sym] = user.instance_variable_get(sym)
end
like image 3
Adrian Avatar answered Oct 19 '22 02:10

Adrian