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?
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).
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?
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With