Dynamically extend Virtus instance attributes
Asked Answered
T

2

9

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

Tweedsmuir answered 18/5, 2017 at 11:33 Comment(0)
T
5

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?

Trinitarianism answered 25/5, 2017 at 12:39 Comment(4)
Thank you! And yes, I think PR is a great idea, it seems that quite a lot of people have struggled with this issue.Tweedsmuir
@Tweedsmuir It's nice that @Trinitarianism has come up with a PR. Good job, eregon! But, did I not answer your question which is this: What is causing the problem and how can I get to convert the object without loosing the data??Romans
@Romans I think you answered very nicely the "what is the problem", but the fix sounds rather hacky (it only addresses to_h, other parts of Virtus will not work), that's why I decided to try finding a cleaner solution.Trinitarianism
@Trinitarianism My answer can fix his problem (explicitly written in the question) right away. I thought this is how SO works. How could anyone know that he wanted a PR to the library related to that problem that will also fix other unrelated problems (if any) that are not even mentioned in the question. But anyway, congratz @eregon!Romans
R
3

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
Romans answered 23/5, 2017 at 16:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.