solnic / virtus

[DISCONTINUED ] Attributes on Steroids for Plain Old Ruby Objects
MIT License
3.77k stars 229 forks source link

Custom Coercions #215

Closed attack closed 10 years ago

attack commented 10 years ago

I am attempting to use Virtus for attributes with custom data types. In a prior version (ie 1.0.0.beta0), this was trivial.

# custom data type
class Temperature; end

class Writer < Virtus::Attribute::Writer::Coercible
  def call(instance, value)
    # do stuff, access to new value, instance, default_value & primitive
  end
end

class Reader < Virtus::Attribute::Reader
  def call(instance)
    # do stuff, access to value, instance, default_value & primitive
  end
end

class TemperatureAttribute < Virtus::Attribute::Object
  primitive Temperature
  default nil

  def self.writer_class(*args); Writer; end
  def self.reader_class(*args); Reader; end
end

class Forecast
  include Virtus
  attr_accessor :units
  attribute :high, TemperatureAttribute
end

I am aware of #211 which demonstrates an alternative for the writer_class. Unfortunately this does not allow access to the instance of which the attribute is a part of (eg, the context of the :coercer proc would not have access to Forecast#units).

I am also aware of the Readme for Custom Coercions, but the context of the Virtus::Attribute#coerce method does not have access to the User instance.

I understand this example is based of an early beta and many changes have been made since then. Do you have any suggestions for creating custom coercions with access to the instance during attribute write and/or read?

Thanks for this great library! Mark

solnic commented 10 years ago

Like that:

# custom data type
class Temperature; end

class TemperatureAttribute < Virtus::Attribute
  primitive Temperature
  default nil

  def set(instance, value)
    # do stuff, access to value, instance, default_value & primitive
  end
end

class Forecast
  include Virtus.model
  attr_accessor :units
  attribute :high, TemperatureAttribute
end

Hope this helps

solnic commented 10 years ago

Actually, that's not gonna work since #set is being mixed in after an attribute instance was created. Re-opening.

solnic commented 10 years ago

For now I'd suggest overriding attribute writer method. This seems like a reasonable solution and the quickest one. Lemme know if that would be a sufficient solution for you.

We have an upcoming new feature where you will be able to customize attribute behavior with a custom extension, that would allow you to tweak #set method.

I would suggest overriding writer method though, or maybe encapsulating coercion logic inside Temperature constructor. It doesn't sound right that an attribute coercer would know so much details about temperature.

elskwid commented 10 years ago

@attack, will any of @solnic's recommendations work for you? (or have they worked for you already?)

stevenharman commented 10 years ago

I've got a similar situation where we have a custom type, StrictDate, which had a custom coercion method to make sure that we get nil when the value can't be converted.

# Virtus 0.5.5
class StrictDate < Virtus::Attribute::Object
  primitive ::Date
  coercion_method :to_strict_date
end

module Virtus
  class Coercion
    class String < Virtus::Coercion::Object
      # Default to nil instead of the uncoerced string
      def self.to_strict_date(value)
        coerced = to_date(value)
        coerced = coerced.is_a?(::Date) ? coerced : nil
      end
    end
  end
end

As of 1.0.0, I've tried changing that to

class StrictDate < Virtus::Attribute
  primitive ::Date

  def coerce(value)
    coerced = to_date(value)
    coerced = coerced.is_a?(::Date) ? coerced : nil
  end
end

This errors b/c to_date is not defined. Debugging shows that w/in the coerce method, the primitive is BasicObject rather than the expected ::Date object. Also, the suggested API (from the README) coercer[value.class] errors because [] is not a method for the coercer.

What am I missing? Perhaps I'm going about this all wrong?

dkubb commented 10 years ago

@stevenharman I think the bulk of the coercion logic was moved out to https://github.com/solnic/coercible

stevenharman commented 10 years ago

@dkubb Yeah, I noticed that. Perhaps I'm not understanding how the Virtus API for Virtus::Attribute is intended to be used. What I'm trying above seems consistent with what is described in the README. This seems like a typical use case - custom attribute types based on a different primitive, yet I can't figure out how to use the API to accomplish it. Any pointers?

dkubb commented 10 years ago

@stevenharman I think @elskwid is probably more familiar with the intended API. I've contributed a few things in the past, but I haven't used it in production yet, and my current knowledge of specifics is a bit stale.

elskwid commented 10 years ago

@dkubb, @stevenharman: yes, I have more recent experience. I am taking a stroll through Virtus issues tonight. I'll see if I can't help out here.

elskwid commented 10 years ago

@stevenharman, the Virtus::Attribute.primitive interface can get finicky (unstable) when you use a primitive for a built-in primitive, something like Date, String, etc. The lookup inside Virtus gets confused and you end up with the BasicObject that you noted.

Looking at your example it seems that you don't want a custom attribute as much as you want to handle coercions for these dates differently. Here's one way to do that:

Custom coercer

The coercer just needs to respond to call(input). Which means you can use your imagination.

class NilDateCoercer
  def self.call(input)
    coerced = begin
      Virtus.coercer[input.class].to_date(input)
    rescue ::Coercible::UnsupportedCoercion
      nil
    end

    coerced.is_a?(::Date) ? coerced : nil
  end
end

class A
  include Virtus.model
  attribute :at, Date, coercer: NilDateCoercer
end

gives you:

[1] pry(main)> a = A.new
=> #<A:0x007fb1289b70e0 @at=nil>

[2] pry(main)> a.at = "10/12/2013"
=> "10/12/2013"

[3] pry(main)> a.at
=> #<Date: 2013-12-10 ((2456637j,0s,0n),+0s,2299161j)>

[4] pry(main)> a.at = "not-a-date"
=> "not-a-date"

[5] pry(main)> a.at
=> nil

Keep in mind that if you turn on strict for the coercion then your coercer also needs to respond to .success?(primitive, value)

stevenharman commented 10 years ago

Thanks for the explanation and example, @elskwid.

elskwid commented 10 years ago

You bet, @stevenharman! I'm going to close this issue. @attack if you need more info please feel free to reopen or create a new issue. Have a great day!