动态扩展 Virtus 实例属性

Dynamically extend Virtus instance attributes

假设我们有一个 Virtus 模型 User

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

然后我们创建这个模型的实例并从 Virtus.model 扩展以动态添加另一个属性:

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

当前输出:

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

但是当我尝试获取 attributes 或通过 as_json(或 to_json)或 Hash 通过 [= 将对象转换为 JSON 时20=] 我只得到 post-扩展属性 active:

user.to_h # => { active: true }

是什么导致了这个问题,我怎样才能在不丢失数据的情况下转换对象?

P.S.

我找到了一个github issue,不过好像还是没修好(那里推荐的方法也不太稳定)。

我还没有进一步调查,但似乎每次包含或扩展 Virtus.model 时,它都会初始化一个新的 AttributeSet 并将其设置为 @attribute_set 您的 User class(source). What the to_h or attributes do is they call the get method of the new attribute_set instance (source)。所以只能获取到最后包含或者扩展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

可以看到,扩展前后attribute_set实例的object_id是不一样的,也就是说前者和后者attribute_set是两个不同的对象

我现在可以建议的技巧是:

(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

根据 Adrian 的发现,这是一种修改 Virtus 以允许您想要的方法。所有规格都通过此修改。

本质上,Virtus 已经有了父 AttributeSet 的概念,但只有当 Virtus.model 包含在 class 中时才会出现。 我们也可以扩展它以考虑实例,甚至允许在同一对象中使用多个 extend(Virtus.model)(尽管这听起来不是最优的):

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}

也许这值得为 Virtus 做一个 PR,你觉得怎么样?