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?
So, you asked two questions here:
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!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With