Lighthouse has a new layout. Prefer the old one? Return to the old layout, and switch back any time from the link at the top of each page.

There was a problem

You must be a member of this account.

This project is archived and is in readonly mode.

AttributeDecorator, a new take on aggregation.

#950

This is our AttributeDecorator plugin (http://github.com/Fingertips/att... turned into a patch, as was discussed on rails-core the last week. See the comments by Manfred and me for more in depth info on the rational: http://groups.google.com/group/r...

It allows you to add reader and writer methods decorating one or more attributes on an ActiveRecord model. It's comparable to composed_of, but has a much more concise implementation and a lot less confusing configuration options.

From the documentation:


class CompositeDate
  attr_accessor :day, :month, :year

  # Gets the value from Artist#date_of_birth= and will return a CompositeDate instance with the :day, :month and :year attributes set.
  def self.parse(value)
    day, month, year = value.scan(/(\d+)-(\d+)-(\d{4})/).flatten.map { |x| x.to_i }
    new(day, month, year)
  end

  # Notice that the order of arguments is the same as specified with the :decorates option.
  def initialize(day, month, year)
    @day, @month, @year = day, month, year
  end

  # Here we return the parsed values in the same order as specified with the :decorates option.
  def to_a
    [@day, @month, @year]
  end

  # Here we return a string representation of the value, this will for instance be used by the form helpers.
  def to_s
    "#{@day}-#{@month}-#{@year}"
  end

  # Returns wether or not this CompositeDate instance is valid.
  def valid?
    @day != 0 && @month != 0 && @year != 0
  end
end

class Artist < ActiveRecord::Base
  attribute_decorator :date_of_birth, :class => CompositeDate, :decorates => [:day, :month, :year]
  validates_decorator :date_of_birth, :message => 'is not a valid date'
end

# Option examples:
attribute_decorator :date_of_birth, :class => CompositeDate, :decorates => [:day, :month, :year]
attribute_decorator :gps_location, :class_name => 'GPSCoordinator', :decorates => :location
attribute_decorator :balance, :class_name => 'Money'
attribute_decorator :english_date_of_birth, :class => (Class.new(CompositeDate) do
  # This is a anonymous subclass of CompositeDate that supports the date in English order
  def to_s
    "#{@month}/#{@day}/#{@year}"
  end

  def self.parse(value)
    month, day, year = value.scan(/(\d+)\/(\d+)\/(\d{4})/).flatten.map { |x| x.to_i }
    new(day, month, year)
  end
end)

The patch also deprecates composed_of and a small refactor of code I came across in activerecord/lib/active_record/reflection.rb.

Eloy

Reported by Eloy Duran · September 1st, 2008 @ 12:01 PM

State: open
Milestone: 3.1
Assigned to: Aaron Patterson Aaron Patterson
Importance: Low

Activity

  1. Michael Koziarski
    Michael Koziarski
    • Assigned user set to Michael Koziarski

    September 2nd, 2008 @ 08:13 AM

  2. Joel
    Joel

    This looks pretty neat, will this trigger changed? on the attribute (which composed_of does not seem to do atm)

    November 17th, 2008 @ 11:27 AM

  3. Joel
    Joel

    sigh ignore my comment ^^

    November 17th, 2008 @ 04:01 PM

  4. Eloy Duran
    Eloy Duran

    Updated the patch for current HEAD.

    @Joel: I assume you found out that it does in fact trigger the "dirty" status?

    November 17th, 2008 @ 07:29 PM

  5. Joel
    Joel

    Indeed I did.

    I had missed a commit of a plugin that busted it tho =)

    November 18th, 2008 @ 04:09 AM

  6. Eloy Duran
    Eloy Duran

    Updated for current HEAD

    December 5th, 2008 @ 12:26 PM

  7. Eloy Duran
    Eloy Duran

    And this time with a patch :-)

    December 5th, 2008 @ 12:26 PM

  8. Pascal Ehlert
    Pascal Ehlert

    In the example above, you say .to_s is for form helpers.

    I couldn't check it out, yet, but is that true? The problem I've always had with my own solution to this problem is that form helpers were actually using attribute_before_typecast for reasons I never fully understood so that all formatting was gone.

    Anyway, this looks nice and much more sophisticated than the existing solution so +1 for the patch.

    December 8th, 2008 @ 08:34 AM

  9. Eloy Duran
    Eloy Duran

    @Pascal: You are right, so there is indeed an implementation of the foo_before_type_cast method (where foo' is the decorator), which calls the #to_s method on the decorator instance to get the value for the form field.

    December 8th, 2008 @ 09:00 AM

  10. Jeremy Kemper
    Jeremy Kemper
    • Milestone cleared.
    • State changed from new to open

    Nice! The API feels a bit wordy, though, like a specification rather than a declaration.

    Consider

    
    attribute_decorator :date_of_birth, :class => CompositeDate, :decorates => [:day, :month, :year]
    

    versus

    
    compose :date_of_birth, :of => [:day, :month, :year], :as => CompositeDate
    

    or

    
    view :date_of_birth, :as => ComposedDateAttribute.new(:day, :month, :year)
    

    December 8th, 2008 @ 08:33 PM

  11. Manfred Stienstra
    Manfred Stienstra

    Jeremy, your compose example doesn't read really well when you're decorating one attribute. I guess we can meet halfway? How about this:

    
    class Artist < ActiveRecord::Base
      view :location, :as => Location, :decorating => :gps_location
      view :date_of_birth, :as => CompositeDate, :decorating => [:day, :month, :year]
    
      validate_view :location
      validate_view :date_of_birth, :message => 'is not a valid date'
    end
    

    December 9th, 2008 @ 10:34 AM

  12. Eloy Duran
    Eloy Duran

    Updated the API according to what Manfred suggested, did some cleaning and updated for edge.

    December 10th, 2008 @ 08:48 PM

  13. Eloy Duran
    Eloy Duran

    Forgot to update a deprecation warning for composed_of.

    December 10th, 2008 @ 09:03 PM

  14. Brennan Dunn
    Brennan Dunn

    What about aiming toward something a bit more like this plugin I recently wrote: http://github.com/brennandunn/at...

    The reason being is that I think, for the purpose of adding helper functionality to attributes, the extension model implemented by AR's association proxy/named_scope is more concise and easy to work with.

    Example:

    
      class Person < ActiveRecord::Base
      
        with_attribute :name do
        
          def has_middle_name?
            self.split(' ').size == 3
          end
        
          def surname
            self.split(' ').last
          end
        
        end
      
      end
    
      @person = Person.create :name => 'John C. Doe'
      
      @person.name.has_middle_name?  # => true
      @person.name.surname           # => "Doe"
      
      # and of course, 
      @person.name                   # => "John C. Doe"
    
    

    Thoughts?

    December 10th, 2008 @ 10:42 PM

  15. Manfred Stienstra
    Manfred Stienstra

    Yesterday, we were talking to Jeremy on IRC and I promised to show some examples of how the API would function.

    In short, we're trying to find a way to compose attributes on an ActiveRecord class in such a way that it supports:

    1. Conversions: Attributes <=> Value object <=> Form data
    2. Validation

    Jeremy raised two problems: using existing classes and assigning instances through the attribute writer. Basically the following:

    
    class Artist < ActiveRecord::Base
      view :date_of_birth, :as => DateDecorator, :decorating => [:day, :month, :year]
    end
    
    artist = Artist.new(:date_of_birth => '24-05-1980')
    artist.date_of_birth += 10.days
    

    From what I understand he thought that meant changing the API slightly and have an object do all the conversions:

    
    view :date_of_birth, :as => ComposedDateAttribute.new(:day, :month, :year)
    

    The conversion class could look something like this:

    
    class ComposedDateAttribute
      attr_accessor :attributes
      
      def initialize(*attributes)
        @attributes = attributes.flatten
      end
      
      # Attributes => Value object
      def instantiate(record)
        Date.new(*attributes.reverse.map { |attribute| record.send(attribute) })
      end
      
      # Value object => Form data
      def form_value(date)
        date.strftime("%Y-%m-%d")
      end
      
      # Form data => Value object
      def parse(string)
        Date.parse(string)
      end
      
      # Value object => Attributes
      def attributes(date)
        [date.year, date.month, date.day]
      end
      
      # Validation
      def valid?(string)
        string =~ /\d{4}-\d{2}-\d{2}/
      end
    end
    

    The upside is that you get an actual Date object, the downside is that it looks like crap and becomes even weirder if you want to use the same class as the value object. Maybe I misunderstood Jeremy's intentions?

    As far as I can see this can be solved with the current implementation:

    
    class DateDecorator < Date
      
      # Attributes => Value object
      def initialize(day, month, year)
        super(year, month, day)
      end
      
      # Value object => Form data
      def to_s
        strftime("%Y-%m-%d")
      end
      
      # Form data => Value object
      def self.parse(value)
        case value
        when Date
          new(value.day, value.month, value.year)
        when CompositeDate
          value
        else # Assume it's a String
          day, month, year = value.scan(/(\d\d)-(\d\d)-(\d{4})/).flatten.map { |x| x.to_i }
          new(day, month, year)
        end
      end
      
      # Value object => Attributes
      def to_a
        [day, month, year]
      end
      
      # Validation
      def valid?
        day != 0 && month != 0 && year != 0
      end
    end
    

    The upside of this solution is that the class makes sense: it's an actual decorator. The downside is that strict class checking code might break. I guess the method names should change to be more expressive so this use case makes more sense.

    Finally, Jeremy says that he "[doesn't] think it should be the value object's responsibility to understand how it's composed from the database." I completely agree, that's why we use a decorator object to sit 'around' the value object to facilitate the conversions.

    December 11th, 2008 @ 02:49 PM

  16. Jeremy Kemper
    Jeremy Kemper

    Combining the value object and it's parse/dump behavior is more confusing to me. It's a poor separation of concerns that makes working with existing classes cumbersome.

    I suggest

    
    class ComposedDateView
      def initialize(year, month, day) @y, @m, @d = year, month, day end
    
      def load(record)
        Date.new(record.send(@y), record.send(@m), record.send(@d))
      end
    
      def parse(string)
        Date.parse(string)
      end
    
      def dump(date)
        { @y => date.year, @m => date.month, @d => date.day }
      end
    end
    

    Then we can do nice things like share attribute types as plugins, subclass composers to change behavior without having to change the value object code, and refactor YAML serialization as a view.

    The form value and validation stuff aren't really necessary. We can already handle that with ValueObject#to_s and existing model validations.

    December 11th, 2008 @ 09:45 PM

  17. Jeremy Kemper
    Jeremy Kemper

    YAML example:

    
    class YamlView
      def initialize(attr) @attr = attr end
    
      def load(record)
        parse(record.send(@attr))
      end
    
      def parse(string)
        YAML.load(string) if string
      end
    
      def dump(object)
        { @attr => is_yaml?(object) ? object : YAML.dump(object) }
      end
    
      protected
        def is_yaml?(object)
          object.is_a?(String) && object =~ /\A---/
        end
    end
    

    December 11th, 2008 @ 09:53 PM

  18. Manfred Stienstra
    Manfred Stienstra

    I have to admit, when you limit the responsibility of the view class to just loading, parsing, and dumping it looks ok.

    I don't agree on the ValueObject#to_s thing though, form helpers currently use #{attribute_name}_before_type_cast on the record instead of to_s to get the value to put back into the form on validation errors. So that still has to be addressed somehow.

    If you like, I can set up an example project to explore your proposal.

    December 12th, 2008 @ 10:41 AM

  19. Eloy Duran
    Eloy Duran

    @Manfred: I think that'd be a good idea. Maybe include our real use case that it all was based on?

    December 13th, 2008 @ 11:02 AM

  20. Michael Koziarski
    Michael Koziarski
    • Assigned user changed from Michael Koziarski to Jeremy Kemper
    • Milestone set to 2.x

    Removing from 2.3, I'd still like to get this in but we're close enough now that this shouldn't be 'blocking'

    Assigning to jeremy

    February 1st, 2009 @ 02:04 AM

  21. Manfred Stienstra
    Manfred Stienstra
    • Milestone cleared.

    I've been working on this again as part of a new application. I've been abstracting some of the composed, formatted and decorated attribute accessors. Now I basically do the following:

    views :fee, :as => Fee.new(:fee_in_cents)
    
    class Fee < ActiveRecord::AttributeView
      def load(fee_in_cents)
        if fee_in_cents.blank? or fee_in_cents.zero?
          ''
        elsif (fee_in_cents % 100).zero?
          "%d" % (fee_in_cents / 100)
        else
          "%.2f" % (fee_in_cents / 100.0)
        end
      end
    
      def parse(input)
        return nil if input.blank?
    
        input = input.gsub(/[^\.^\,^\d]/, '')
        whole, cents = input.split(/\,|\./)
        (whole.to_i * 100) + cents.to_i
      end
    end
    

    I've solved the problem with forms and before_type_cast. I'm considering a few things now:

    1. Should we add a format method to the view so you also get a formatted_fee accessor?
    2. Should we allow people to specify which accessors get defined (ie. :only_define => [:getter, :formatter])

    Current implementation on: http://gist.github.com/118033

    May 26th, 2009 @ 11:33 AM

  22. Chris Hapgood
    Chris Hapgood

    Manfred's example feels like a natural evolution in Rails. Jeremy hits the key point: it factors out the view behavior into a kind of Presenter, but since it also accommodates reversing the Presenter (to parse input) it might be called an attribute agent. It could allow much of the existing composed_of behavior to remain since the construction of value objects from DB attributes is orthogonal to view behavior (although the :converter option might be merged into these attribute agents).

    Once you've added support for these agents, why not take the next step and instantiate simple agents for EVERY model attribute and eliminate the troublesome call to before_type_cast by the AR form helper altogether? My impression is that before_type_cast has become the de facto presenter for AR attributes and it doesn't feel right.

    You would also expose an ideal entry point for i18n (parsing/presenting of dates, times, floats, etc.).

    June 25th, 2009 @ 05:11 PM

  23. Michael Koziarski
    Michael Koziarski

    I can't see a reason to allow configuration for the different methods to be defined. If they really care you can always either override them after the fact or undef_method if you want to lose them.

    June 26th, 2009 @ 06:11 AM

  24. Jeremy Kemper
  25. Aaron Patterson
    Aaron Patterson
    • Assigned user changed from Jeremy Kemper to Aaron Patterson
    • Importance changed from to Low

    September 22nd, 2010 @ 10:45 PM

  26. Ryan Bigg
    Ryan Bigg
    • Tag cleared.

    Automatic cleanup of spam.

    October 9th, 2010 @ 09:45 PM