Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to metaprogramatically define typecasting attribute readers/writers in Ruby

I am trying to create a simple class that automatically converts a set of fields to a specified Ruby type when the field is either set or read.

Here's what I have so far, and it works. However, it is not DRY and my metaprogramming is rudimentary.

Is there a better, cleaner way to implement this?

class BasicModel

  def self.fields(params)
    params.each do |name, type|

      # Define field writers
      define_method("#{name}=") {|v| @fields[name] = v}

      # Define field readers
      case type.name
      when 'String'
        define_method(name) { @fields[name].to_s }
      when 'Integer'
        define_method(name) { @fields[name].to_i }
      when 'Float'
        define_method(name) { @fields[name].to_f }
      else raise 'invalid field type'
      end

    end
  end

  fields(
    name: String,
    qty: Integer,
    weight: Float
  )

  def initialize
    @fields = {}
  end

end

# specification
m = BasicModel.new
m.name         => ""
m.name = 2     => 2
m.name         => "2"
m.qty          => 0
m.qty = "1"    => "1"
m.qty          => 1
m.weight       => 0.0
m.weight = 10  => 10
m.weight       => 10.0

What are the dis/advantages of typecasting on the reader vs. the writer? For example, the following code typecasts on the writer, as opposed to the reader (above). I also put the case inside the define_method.

class BasicModel
  def self.fields(params)
    params.each do |name, type|

      define_method(name) { @fields[name] }

      define_method("#{name}=") do |val|
        @fields[name] = case type.name
                        when 'Integer'  then val.to_i
                        when 'Float'    then val.to_f
                        when 'String'   then val.to_s
                        else raise 'invalid field type'
                        end
    end
  end
end

I was thinking that a possible concern is that decision trees (e.g. case statement) should probably be kept out of the block of the define_method. I'm assuming the statement is pointlessly evaluated each time the field is set/read. Is this correct?

like image 996
Clint Pachl Avatar asked Oct 25 '12 02:10

Clint Pachl


1 Answers

So, you asked two questions here:

  1. How to metaprogramatically typecast
  2. Whether to typecast on the reader or writer.

The second question is much easier to answer so let me start there:

I would cast on the writer. Why? While the difference is subtle, you have somewhat different behavior inside the object if you cast on the reader.

For example if you have a field, price of type Integer, and you cast this on read, then inside the class the value of price and @fields['price'] are not the same. This isn't a huge deal, as you should just use the reader method, but why create unnecessary inconsistency?

The first question is more interesting, how to metaprogramatically typecast. Your code is illustrating the common method of type coercion in ruby, namely the to_* methods that most objects provide. There's another way of doing this though:

String(:hello!) #=> "Hello"
Integer("123") #=> 123
Float("123") #=> 123.0
Array("1,2,3") #=> ["1,2,3"]

Now, these are interesting. It looks like what you're doing here is calling a nameless method on the classes, like String.(), which is how the [] syntax works on arguments. But that's not the case, you can't define a method named (). Instead these are actually methods defined on Kernel.

Therefore there are two ways of metaprogramatically calling them. The simplest is like so:

type = 'String'
Kernel.send(type,:hello) #=> "hello"

If no typecasting method exists you'll get a NoMethodError.

You could also get the method object and call it, like so:

type = 'String'
method(type).call(:hello) #=> "hello"

If the method doesn't exist in this case, you'll get a NameError.

The only real caveat for these is that, like all metaprogramming, you want to think through what you may be exposing. If there's an opportunity for user input to define the type attribute, then a malicious user could send you a payload like:

{type: 'sleep', value: 9999}

And now your code is going to call Kernel.send('sleep',9999), which would suck mightily for you. So you need to ensure that these type values are not something that can be set by any untrusted party, and/or whitelist the allowed types.

Keeping that caveat in mind, the following would be a fairly elegant way to solve your problem:

class BasicModel
  def self.fields(hash={})
    hash.each do |name,type|
      define_method("#{name}"){ instance_variable_get "@#{name"} }
      define_method("#{name}=") {|val| instance_variable_set "@#{name}", Kernel.send(type,val) }
    end
  end

  fields name: String, qty: Integer, weight: Float
end

Note also, I'm defining instance variables (@name, @qty, @weight) rather than a fields hash, as I personally don't like when a metaprogramming macro like this depends on the initialize method to function correctly.

There's an added benefit if you don't need to override the initializer, you could actually extract this to a module and extend it in any class where you want to provide this behavior. Consider the following example, this time with whitelisting added to the allowed field types:

module Fieldset
  TYPES = %w|String Integer Float|

  def self.fields(hash={})
    hash.each do |name,type|
      raise ArgumentError, "Invalid Field Type: #{type}" unless TYPES.include?(type)
      define_method("#{name}"){ instance_variable_get "@#{name"} }
      define_method("#{name}=") {|val| instance_variable_set "@#{name}", Kernel.send(type,val) }
    end
  end
end

class AnyModel
  extend Fieldset
  fields name: String, qty: Integer, weight: Float
end

Great question. I hope this answer gives you some new ideas!

like image 62
Andrew Avatar answered Sep 28 '22 05:09

Andrew