Commits

Anonymous committed 2219799

Pulled in code from github.com/bkaney/ripple-anaf.

NOTE: The specs aren't mocked up and require a real riak server to run.

Comments (0)

Files changed (14)

ripple/lib/ripple.rb

   autoload :Property, "ripple/properties"
   autoload :Timestamps
   autoload :Validations
+  autoload :NestedAttributes
 
   # Exceptions
   autoload :PropertyTypeMismatch

ripple/lib/ripple/core_ext.rb

 #    See the License for the specific language governing permissions and
 #    limitations under the License.
 require 'ripple/core_ext/casting'
+require 'ripple/core_ext/hash'

ripple/lib/ripple/core_ext/hash.rb

+class Hash
+  
+  # Return a new hash with all keys converted to strings.
+  def stringify_keys
+    inject({}) do |options, (key, value)|
+      options[key.to_s] = value
+    options
+    end
+  end
+  
+  # Destructively convert all keys to strings.
+  def stringify_keys!
+    keys.each do |key|
+      self[key.to_s] = delete(key)
+    end
+    self
+  end
+
+  # Return a new hash with all keys converted to symbols.
+  def symbolize_keys
+    inject({}) do |options, (key, value)|
+      options[(key.to_sym rescue key) || key] = value
+    options
+    end
+  end
+
+  # Destructively convert all keys to symbols.
+  def symbolize_keys!
+    self.replace(self.symbolize_keys)
+  end
+  
+end

ripple/lib/ripple/document.rb

       include Ripple::Conversion
       include Ripple::Document::Finders
       include Ripple::Inspection
+      include Ripple::NestedAttributes
     end
 
     module ClassMethods

ripple/lib/ripple/document/key.rb

           def key=(value)
             self.#{prop} = value
           end
+          def key_attr
+            :#{prop}
+          end          
           CODE
         end
       end
         def key=(value)
           @key = value.to_s
         end
+
+        def key_attr
+          :key
+        end        
       end
     end
   end

ripple/lib/ripple/embedded_document.rb

       include Ripple::Conversion
       include Finders
       include Ripple::Inspection
+      include Ripple::NestedAttributes
     end
 
     module ClassMethods

ripple/lib/ripple/nested_attributes.rb

+require 'ripple'
+module Ripple
+  module NestedAttributes #:nodoc:
+    extend ActiveSupport::Concern
+
+    UNASSIGNABLE_KEYS = %w{ _destroy }
+    TRUE_VALUES = [ true, "true", 1, "1", "yes", "ok", "y" ]
+
+    included do
+      class_inheritable_accessor :nested_attributes_options, :instance_writer => false
+      self.nested_attributes_options = {}
+    end
+    
+    # = Nested Attributes
+    #
+    # This is similar to the `accepts_nested_attributes` functionality
+    # as found in AR.  This allows the use update attributes and create
+    # new child records through the parent.  It also allows the use of
+    # the `fields_for` form view helper, using a presenter pattern.
+    #
+    # To enable in the model, call the class method, using the same
+    # relationship as defined in the `one` or `many`.
+    #
+    #   class Shipment
+    #     include Ripple::Document
+    #     one :box
+    #     many :addresses
+    #     accepts_nested_attributes_for :box, :addresses
+    #   end
+    #
+    # == One
+    #
+    # Given this model:
+    #
+    #   class Shipment
+    #     include Ripple::Document
+    #     one :box
+    #     accepts_nested_attributes_for :box
+    #   end
+    #
+    # This allows creating a box child during creation:
+    #
+    #   shipment = Shipment.create(:box_attributes => { :shape => 'square' })
+    #   shipment.box.shape # => 'square'
+    #
+    # This also allows updating box attributes:
+    #
+    #   shipment.update_attributes(:box_attributes => { :key => 'xxx', :shape => 'triangle' })
+    #   shipment.box.shape # => 'triangle'
+    #
+    # == Many
+    #
+    # Given this model
+    #
+    #   class Manifest
+    #     include Ripple::Document
+    #     many :shipments
+    #     accepts_nested_attributes_for :shipments
+    #   end
+    #
+    # This allows creating several shipments during manifest creation:
+    #
+    #   manifest = Manifest.create(:shipments_attributes => [ { :reference => "foo1" }, { :reference => "foo2" } ])
+    #   manifest.shipments.size # => 2
+    #   manifest.shipments.first.reference # => foo1
+    #   manifest.shipments.second.reference # => foo2
+    #
+    # And updating shipment attributes:
+    #
+    #   manifest.update_attributes(:shipment_attributes => [ { :key => 'xxx', :reference => 'updated foo1' },
+    #                                                        { :key => 'yyy', :reference => 'updated foo2' } ])
+    #   manifest.shipments.first.reference # => updated foo1
+    #   manifest.shipments.second.reference # => updated foo2
+    # 
+    # NOTE: On many embedded, then entire collection of embedded documents is replaced, as there
+    # is no key to specifically update.
+    #
+    # Given
+    #
+    #   class Manifest
+    #     include Ripple::Documnet
+    #     many :signatures
+    #     accepts_nested_attributes_for :signatures
+    #   end
+    #
+    #   class Signature
+    #     include Ripple::EmbeddedDocument
+    #     property :esignature, String
+    #   end
+    #
+    # The assigning of attributes replaces existing:
+    #   
+    #   manifest = Manifest.create(:signature_attributes => [ { :esig => 'a00001' }, { :esig => 'b00001' } ]
+    #   manifest.signatures # => [<Signature esig="a00001">, <Signature esig="b00001">]
+    #
+    #   manifest.signature_attributes = [ { :esig => 'c00001' } ]
+    #   manifest.signatures # => [<Signature esig="c00001">]
+    #     
+    module ClassMethods
+    
+      def accepts_nested_attributes_for(*attr_names)
+        options = { :allow_destroy => false }
+        options.update(attr_names.extract_options!)
+        
+        attr_names.each do |association_name|
+          if association = self.associations[association_name]
+            nested_attributes_options[association_name.to_sym] = options
+         
+            class_eval %{
+              def #{association_name}_attributes=(attributes)
+                assign_nested_attributes_for_#{association.type}_association(:#{association_name}, attributes)
+              end
+
+              before_save :autosave_nested_attributes_for_#{association_name}
+              before_save :destroy_marked_for_destruction
+
+              private
+
+              def autosave_nested_attributes_for_#{association_name}
+                save_nested_attributes_for_#{association.type}_association(:#{association_name}) if self.autosave[:#{association_name}]
+              end
+            }, __FILE__, __LINE__
+          else
+            raise ArgumentError, "Association #{association_name} not found!"
+          end
+        end        
+      end
+    end
+
+    module InstanceMethods
+
+      protected
+
+      def autosave
+        @autosave_nested_attributes_for ||= {}
+      end
+
+      def marked_for_destruction
+        @marked_for_destruction ||= {}
+      end
+
+      private
+      
+      def save_nested_attributes_for_one_association(association_name)
+        send(association_name).save
+      end
+
+      def save_nested_attributes_for_many_association(association_name)
+        send(association_name).map(&:save)
+      end
+
+      def destroy_marked_for_destruction
+        self.marked_for_destruction.each_pair do |association_name, resources|
+          resources.map(&:destroy)
+          send(association_name).reload
+        end
+      end
+
+      def destroy_nested_many_association(association_name)
+        send(association_name).map(&:destroy)
+      end
+
+      def assign_nested_attributes_for_one_association(association_name, attributes)
+        association = self.class.associations[association_name]
+        if association.embeddable?
+          assign_nested_attributes_for_one_embedded_association(association_name, attributes)
+        else
+          self.autosave[association_name] = true
+          assign_nested_attributes_for_one_linked_association(association_name, attributes)
+        end
+      end
+      
+      def assign_nested_attributes_for_one_embedded_association(association_name, attributes)
+        send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
+      end
+
+      def assign_nested_attributes_for_one_linked_association(association_name, attributes)
+        attributes = attributes.stringify_keys
+        options = nested_attributes_options[association_name]
+
+        if attributes[key_attr.to_s].blank? && !reject_new_record?(association_name, attributes)
+          send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
+        else
+          if ((existing_record = send(association_name)).key.to_s == attributes[key_attr.to_s].to_s)
+            assign_to_or_mark_for_destruction(existing_record, attributes, association_name, options[:allow_destroy])
+          else
+            raise ArgumentError, "Attempting to update a child that isn't already associated to the parent."
+          end
+        end
+      end
+
+      def assign_nested_attributes_for_many_association(association_name, attributes_collection)
+        unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
+          raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
+        end
+
+        if attributes_collection.is_a? Hash
+          attributes_collection = attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
+        end
+
+        association = self.class.associations[association_name]
+        if association.embeddable?
+          assign_nested_attributes_for_many_embedded_association(association_name, attributes_collection)
+        else
+          self.autosave[association_name] = true
+          assign_nested_attributes_for_many_linked_association(association_name, attributes_collection)
+        end
+      end
+
+      def assign_nested_attributes_for_many_embedded_association(association_name, attributes_collection)
+        options = nested_attributes_options[association_name]
+        send(:"#{association_name}=", []) # Clobber existing
+        attributes_collection.each do |attributes|
+          attributes = attributes.stringify_keys
+          if !reject_new_record?(association_name, attributes)
+            send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
+          end
+        end
+      end
+
+      def assign_nested_attributes_for_many_linked_association(association_name, attributes_collection)
+        options = nested_attributes_options[association_name]
+        attributes_collection.each do |attributes|
+          attributes = attributes.stringify_keys
+
+          if attributes[key_attr.to_s].blank? && !reject_new_record?(association_name, attributes)
+            send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
+          elsif existing_record = send(association_name).detect { |record| record.key.to_s == attributes[key_attr.to_s].to_s }
+            assign_to_or_mark_for_destruction(existing_record, attributes, association_name, options[:allow_destroy])
+          end
+        end
+      end
+    end
+
+    def assign_to_or_mark_for_destruction(record, attributes, association_name, allow_destroy)
+      if has_destroy_flag?(attributes) && allow_destroy
+        (self.marked_for_destruction[association_name] ||= []) << record
+      else
+        record.attributes = attributes.except(*UNASSIGNABLE_KEYS)
+      end
+    end
+    
+    def has_destroy_flag?(hash)
+      TRUE_VALUES.include?(hash.stringify_keys['_destroy'])
+    end
+
+    def reject_new_record?(association_name, attributes)
+      has_destroy_flag?(attributes) || call_reject_if(association_name, attributes)
+    end
+
+    def call_reject_if(association_name, attributes)
+      attributes = attributes.stringify_keys
+      case callback = nested_attributes_options[association_name][:reject_if]
+      when Symbol
+        method(callback).arity == 0 ? send(callback) : send(callback, attributes)
+      when Proc
+        callback.call(attributes)
+      end
+    end
+
+  end
+  
+end

ripple/spec/ripple/nested_attributes_spec.rb

+require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
+
+describe Ripple::NestedAttributes do
+  require 'support/models/car'
+  require 'support/models/driver'
+  require 'support/models/passenger'
+  require 'support/models/engine'
+  require 'support/models/seat'
+  require 'support/models/wheel'
+
+
+  context "one :driver (link)" do
+    subject { Car.new }
+
+    it { should respond_to(:driver_attributes=) }
+
+    it "should not have a driver" do
+      subject.driver.should be_nil
+    end
+
+    describe "creation" do
+      subject { Car.new(:make => 'VW', :model => 'Rabbit', :driver_attributes => { :name => 'Speed Racer' }) }
+
+      it "should have a driver of class Driver" do
+        subject.driver.should be_a(Driver)
+      end
+
+      it "should have a driver with name 'Speed Racer'" do
+        subject.driver.name.should == 'Speed Racer'
+      end
+
+      it "should save the child when saving the parent" do
+        subject.driver.should_receive(:save)
+        subject.save
+      end
+    end
+
+    describe "update" do
+      let(:driver) { Driver.create(:name => 'Slow Racer') }
+
+      before do
+        subject.driver = driver
+        subject.save
+      end
+
+      it "should have a driver" do
+        subject.driver.should == driver
+      end
+
+      it "should update attributes" do
+        subject.driver_attributes = { :name => 'Speed Racer' }
+        subject.driver.name.should == 'Speed Racer'
+      end
+
+      it "should not save the child if attributes haven't been updated" do
+        subject.driver.should_not_receive(:save)
+        subject.save
+      end
+
+      it "should save the child when saving the parent" do
+        subject.driver_attributes = { :name => 'Speed Racer' }
+        subject.driver.should_receive(:save)
+        subject.save
+      end
+    end
+  end
+  
+  context "many :passengers (link)" do
+    subject { Car.new }
+
+    it { should respond_to(:passengers_attributes=) }
+
+    it "should not have passengers" do
+      subject.passengers.should == []
+    end
+
+    describe "creation" do
+      subject { Car.new(:make => 'VW', 
+                        :model => 'Rabbit', 
+                        :passengers_attributes => [ { :name => 'Joe' },
+                                                    { :name => 'Sue' },
+                                                    { :name => 'Pat' } ] ) }
+
+      it "should have 3 passengers" do
+        subject.passengers.size.should == 3
+      end
+      
+      it "should have 3 passengers with specified names" do
+        subject.passengers.first.name.should == 'Joe'
+        subject.passengers.second.name.should == 'Sue'
+        subject.passengers.third.name.should == 'Pat'
+      end
+
+      it "should save the children when saving the parent" do
+        subject.passengers.each do |passenger|
+          passenger.should_receive(:save)
+        end
+        subject.save
+      end
+    end
+
+    describe "update" do
+      let(:passenger1) { Passenger.create(:name => 'One') }
+      let(:passenger2) { Passenger.create(:name => 'Two') }
+      let(:passenger3) { Passenger.create(:name => 'Three') }
+
+      before do 
+        subject.passengers << passenger1
+        subject.passengers << passenger2
+        subject.passengers << passenger3
+        subject.save
+      end
+
+      it "should have 3 passengers" do
+        subject.passengers.size.should == 3
+      end
+
+      it "should update attributes" do
+        subject.passengers_attributes = [ { :key => passenger1.key, :name => 'UPDATED One' },
+                                          { :key => passenger2.key, :name => 'UPDATED Two' },
+                                          { :key => passenger3.key, :name => 'UPDATED Three' } ]
+        subject.passengers.first.name.should == 'UPDATED One'
+        subject.passengers.second.name.should == 'UPDATED Two'
+        subject.passengers.third.name.should == 'UPDATED Three'
+      end
+
+      it "should not save the child if attributes haven't been updated" do
+        subject.passengers.each do |passenger|
+          passenger.should_not_receive(:save)
+        end
+        subject.save
+      end
+
+      it "should save the child when saving the parent" do
+        subject.passengers_attributes = [ { :key => passenger1.key, :name => 'UPDATED One' },
+                                          { :key => passenger1.key, :name => 'UPDATED Two' },
+                                          { :key => passenger1.key, :name => 'UPDATED Three' } ]
+        subject.passengers.each do |passenger|
+          passenger.should_receive(:save)
+        end
+        subject.save
+      end
+    end
+  end
+
+  context "one :engine (embedded)" do
+    subject { Car.new }
+
+    it { should respond_to(:engine_attributes=) }
+
+    it "should not have an engine" do
+      subject.engine.should be_nil
+    end
+
+    describe "creation" do
+      subject { Car.new(:make => 'VW', :model => 'Rabbit', :engine_attributes => { :displacement => '2.5L' }) }
+
+      it "should have an engine of class Engine" do
+        subject.engine.should be_a(Engine)
+      end
+
+      it "should have a engine with displacement '2.5L'" do
+        subject.engine.displacement.should == '2.5L'
+      end
+
+      it "should save the child when saving the parent" do
+        subject.engine.should_not_receive(:save)
+        subject.save
+      end
+    end
+
+    describe "update" do
+      before do
+        subject.engine.build(:displacement => '3.6L')
+        subject.save
+      end
+
+      it "should have a specified engine" do
+        subject.engine.displacement.should == '3.6L'
+      end
+
+      it "should update attributes" do
+        subject.engine_attributes = { :displacement => 'UPDATED 3.6L' }
+        subject.engine.displacement.should == 'UPDATED 3.6L'
+      end
+
+      it "should not save the child if attributes haven't been updated" do
+        subject.engine.should_not_receive(:save)
+        subject.save
+      end
+
+      it "should not save the child when saving the parent" do
+        subject.engine_attributes = { :displacement => 'UPDATED 3.6L' }
+        subject.engine.should_not_receive(:save)
+        subject.save
+      end
+    end
+  end
+
+  context "many :seats (embedded)" do
+    subject { Car.new }
+
+    it { should respond_to(:seats_attributes=) }
+
+    it "should not have passengers" do
+      subject.seats.should == []
+    end
+
+    describe "creation" do
+      subject { Car.new(:make => 'VW', 
+                        :model => 'Rabbit', 
+                        :seats_attributes => [ { :color => 'red' },
+                                               { :color => 'blue' },
+                                               { :color => 'brown' } ] ) }
+
+      it "should have 3 seats" do
+        subject.seats.size.should == 3
+      end
+      
+      it "should have 3 passengers with specified names" do
+        subject.seats.first.color.should == 'red'
+        subject.seats.second.color.should == 'blue'
+        subject.seats.third.color.should == 'brown'
+      end
+    
+      specify "replace/clobber" do
+        subject.seats_attributes = [ { :color => 'orange' } ]
+        subject.seats.size.should == 1
+        subject.seats.first.color.should == 'orange'
+      end
+
+    end
+  end
+
+  context ":reject_if" do
+    it "should not create a wheel" do
+      car = Car.new(:wheels_attributes => [ { :diameter => 10 } ])
+      car.wheels.should == []
+    end
+
+    it "should create a wheel" do
+      car = Car.new(:wheels_attributes => [ { :diameter => 16 } ])
+      car.wheels.size.should == 1
+      car.wheels.first.diameter.should == 16
+    end
+  end
+
+  context ":allow_delete" do
+    let(:wheel) { Wheel.create(:diameter => 17) }
+    subject { Car.create(:wheels => [ wheel ] ) }
+
+    it "should allow us to delete the wheel" do
+      subject.wheels_attributes = [ { :key => wheel.key, :_destroy => "1" } ]
+      subject.save
+      subject.wheels.should == []
+    end
+
+  end
+  
+end

ripple/spec/support/models/car.rb

+class Car
+  include Ripple::Document
+  
+  property :make, String
+  property :model, String
+
+  one :driver       # linked, key_on :name
+  many :passengers  # linked, standard :key
+  one :engine       # embedded
+  many :seats       # embedded
+  many :wheels
+
+  accepts_nested_attributes_for :driver, :passengers, :engine, :seats
+  accepts_nested_attributes_for :wheels, :reject_if => proc{|attrs| attrs['diameter'] < 12 }, :allow_destroy => true
+end

ripple/spec/support/models/driver.rb

+class Driver
+  include Ripple::Document
+  property :name, String
+  key_on :name
+end

ripple/spec/support/models/engine.rb

+class Engine
+  include Ripple::EmbeddedDocument
+  property :displacement, String  
+end

ripple/spec/support/models/passenger.rb

+class Passenger
+  include Ripple::Document
+  property :name, String
+
+end

ripple/spec/support/models/seat.rb

+class Seat
+  include Ripple::EmbeddedDocument
+  property :color, String
+end

ripple/spec/support/models/wheel.rb

+class Wheel
+  include Ripple::Document
+  property :diameter, Integer
+
+end
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.