Initialize with String or Hash in Virtus Ruby object

589 views Asked by At

I need to be able to create a Virtus based object that takes in a Hash or a String. If its a Hash, then normal behavior is perfect. If it's a plain String, then i need to convert it to {'id' => "STRING"}. At this time, i'm not sure how / where to override the initialize method to perform this function. Or maybe there's a different way. Your expertise is greatly appreciated.

class Contact
  include Virtus.model

  attribute :id, String
end

class Account
  include Virtus.model

  attribute :id, String
  attribute :contact, Contact
  attribute :name, String
end 

account = Account.new("1234")
account.id #>1234

# and still work like this

account = Account.new(id: '1234', contact: '123456', name: 'Bob Jones')
account.id #>1234
account.contact.id #>123456
account.name #>Bob Jones

Sample Data

{'id' => '1234', 'contact' => '123456', 'name' => 'Bob Jones'}

So between both Contact and Account. I need them to be able to initialized with a String, which populates the @id param.

2

There are 2 answers

4
engineersmnky On

Note: This is for edification purposes only and may have unintended consequences that might be difficult to debug.

You can override the Virtus::InstanceMethods::Constructor like so if you really want to:

module VirtusOverride
  def self.included(base)
    raise "#{base.name} must include Virtus.model prior to including VirtusOverride" unless base.included_modules.include?(Virtus::InstanceMethods)
  end

  def initialize(*attributes)
    super(construct(attributes))
  end

  private
    def construct(attributes)
      return attributes.first if valid_constructor?(attributes)
      build_attributes_from_array(attributes) 
    end 
    def valid_constructor?(attributes)
      return false unless attributes.count == 1 
      constructor = attributes.first
      constructor.is_a?(Hash) &&  
      !(attribute_set.flat_map {|a| [a.name,a.name.to_s]} & constructor.keys).empty?
    end
    def build_attributes_from_array(attributes)
      attribute_set.map(&:name).zip(attributes).to_h
    end
end 

Then include it as needed

class Account
  include Virtus.model
  include VirtusOverride

  attribute :id, String
  attribute :contact, Hash
end 

Now you can either pass the options positional to the definition of attributes (e.g. Account.new(id,contact)) or as a Hash.

Example:

Account.new("1234",{name: 'mnky'})
#=> #<Account:0x2a071b8 @id="1234", @contact={:name=>"mnky"}>

Account.new(id: "1234", contact: {name: 'mnky'})
#=> #<Account:0x2b42b78 @id="1234", @contact={:name=>"mnky"}>

You could monkey patch Virtus::InstanceMethods::Constructor to perform the same but I am not a big supporter of this philosophy as it could add confusion to other developers where as the module inclusion offers granularity and clarity.

Update

class Account
  include Virtus.model
  include VirtusOverride

  attribute :id, String
  attribute :contact, Contact
end 

class Contact
  include Virtus.model
  include VirtusOverride

  attribute :id, String
end  

 Account.new(id: '1234', contact: '123456', name: 'Bob Jones')
 #=>  #<Account:0x2bcb060 @id="1234", @contact=#<Contact:0x2bc9c50 @id="123456">, @name="Bob Jones">
 Account.new('1234', '123456', 'Bob Jones')
 #=> #<Account:0x2faea00 @id="1234", @contact=#<Contact:0x2fae880 @id="123456">, @name="Bob Jones">
 Account.new('id' => '1234', 'contact' => '123456', 'name' => 'Bob Jones')
 #=> #<Account:0x2faffc0 @id="1234", @contact=#<Contact:0x2fafb70 @id="123456">, @name="Bob Jones">
1
coreyward On

Virtus’ author’s opinion is that you should not be using Virtus for sanitization. As you can see from @engineersmnky’s answer, it takes more work than it ought to to override the initialize method.

It would instead be easier to wrap the object you're passing in:

class Hashable
  def initialize(hashable_object)
    @obj = hashable_object
  end

  def to_hash
    case @obj
    when Hash then @obj
    when String
      { "id" => @obj }
    else
      raise "No conversion from #{@obj.class} to hash"
    end
  end
end

Account.new(Hashable.new(relationship))