ruby-on-railsrubyruby-on-rails-4virtus

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).


Solution

  • 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?