diff --git a/README.md b/README.md index 590cdb1b..94c9bc4d 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ $ rails generate couchrest_model:config $ rails generate model person --orm=couchrest_model ``` -## General Usage +## General Usage ```ruby require 'couchrest_model' @@ -140,6 +140,20 @@ end @cat.update_attributes(:name => 'Felix', :random_text => 'feline') @cat.new? # false @cat.random_text # Raises error! + +### reverse associations +class Parent < CouchRest::Model::Base + collection_of :children +end + +class Child < CouchRest::Model::Base + belongs_to :dad, class: Parent, :reverse_association => :children +end + +@bob = Parent.new +@kevin = Child.new +@kevin.dad = @bob +@bob.children.include?(@kevin) # true ``` ## Development diff --git a/lib/couchrest/model/associations.rb b/lib/couchrest/model/associations.rb index 6916069f..9170e51c 100644 --- a/lib/couchrest/model/associations.rb +++ b/lib/couchrest/model/associations.rb @@ -1,13 +1,16 @@ module CouchRest module Model module Associations + extend ActiveSupport::Concern # Basic support for relationships between CouchRest::Model::Base - - def self.included(base) - base.extend(ClassMethods) + + included do + after_save :save_dirty_association if respond_to?(:after_save) end + Association = Struct.new(:type, :attribute, :options, :target) + module ClassMethods # Define an association that this object belongs to. @@ -15,14 +18,14 @@ module ClassMethods # An attribute will be created matching the name of the attribute # with '_id' on the end, or the foreign key (:foreign_key) provided. # - # Searching for the assocated object is performed using a string - # (:proxy) to be evaulated in the context of the owner. Typically + # Searching for the associated object is performed using a string + # (:proxy) to be evaluated in the context of the owner. Typically # this will be set to the class name (:class_name), or determined # automatically if the owner belongs to a proxy object. # # If the association owner is proxied by another model, than an attempt will # be made to automatically determine the correct place to request - # the documents. Typically, this is a method with the pluralized name of the + # the documents. Typically, this is a method with the pluralized name of the # association inside owner's owner, or proxy. # # For example, imagine a company acts as a proxy for invoices and clients. @@ -32,14 +35,23 @@ module ClassMethods # # self.company.clients # - # If the name of the collection proxy is not the pluralized assocation name, + # If the name of the collection proxy is not the pluralized association name, # it can be set with the :proxy_name option. # + # If the owner model define an association back to the belonged model, setting + # the owner will also set the (:reverse_association) attribute of the owner. + # After such affectation, saving the object model will also trigger the save of + # the owner object. + # (:reverse_association) is optional. When used, saving the belonged object will + # trigger the save of the owner object. + # def belongs_to(attrib, *options) opts = merge_belongs_to_association_options(attrib, options.first) property(opts[:foreign_key], String, opts) + associations.push(Association.new(:belongs_to, attrib, opts, nil)) + create_association_property_setter(attrib, opts) create_belongs_to_getter(attrib, opts) create_belongs_to_setter(attrib, opts) @@ -84,6 +96,14 @@ def belongs_to(attrib, *options) # NOTE: This method is *not* recommended for large collections or collections that change # frequently! Use with prudence. # + # If the associated model define an association back to the collection owner model, adding + # or removing from the collection will also populate the (:reverse_association) attribute + # of associated model. + # After such affectation, saving the object model will also trigger the save of + # the associated object. + # (:reverse_association) is optional. When used, saving the object with a collection will + # trigger save of the new members of the collection. + # def collection_of(attrib, *options) opts = merge_belongs_to_association_options(attrib, options.first) opts[:foreign_key] = opts[:foreign_key].pluralize @@ -91,18 +111,26 @@ def collection_of(attrib, *options) property(opts[:foreign_key], [String], opts) + associations.push(Association.new(:collection_of, attrib, opts, nil)) + create_association_property_setter(attrib, opts) create_collection_of_getter(attrib, opts) create_collection_of_setter(attrib, opts) end + def associations + @_associations ||= [] + end + private def merge_belongs_to_association_options(attrib, options = nil) + class_name = options.delete(:class_name) if options.is_a?(Hash) + class_name ||= attrib opts = { :foreign_key => attrib.to_s.singularize + '_id', - :class_name => attrib.to_s.singularize.camelcase, + :class_name => class_name.to_s.singularize.camelcase, :proxy_name => attrib.to_s.pluralize, :allow_blank => false } @@ -146,7 +174,15 @@ def #{attrib} def create_belongs_to_setter(attrib, options) class_eval <<-EOS, __FILE__, __LINE__ + 1 def #{attrib}=(value) - self.#{options[:foreign_key]} = value.nil? ? nil : value.id + binding = @#{attrib} + self.#{options[:foreign_key]} = value.nil? ? nil : value.id + unless value.nil? + binding = value + binding.set_back_association(self, self.class.name, '#{options[:reverse_association]}') + else + binding.set_back_association(nil, self.class.name, '#{options[:reverse_association]}') + end + register_dirty_association(binding) @#{attrib} = value end EOS @@ -159,7 +195,7 @@ def create_collection_of_getter(attrib, options) def #{attrib}(reload = false) return @#{attrib} unless @#{attrib}.nil? or reload ary = self.#{options[:foreign_key]}.collect{|i| #{options[:proxy]}.get(i)} - @#{attrib} = ::CouchRest::Model::CollectionOfProxy.new(ary, find_property('#{options[:foreign_key]}'), self) + @#{attrib} = ::CouchRest::Model::Associations::CollectionOfProxy.new(ary, find_property('#{options[:foreign_key]}'), self) end EOS end @@ -167,76 +203,38 @@ def #{attrib}(reload = false) def create_collection_of_setter(attrib, options) class_eval <<-EOS, __FILE__, __LINE__ + 1 def #{attrib}=(value) - @#{attrib} = ::CouchRest::Model::CollectionOfProxy.new(value, find_property('#{options[:foreign_key]}'), self) + @#{attrib} = ::CouchRest::Model::Associations::CollectionOfProxy.new(value, find_property('#{options[:foreign_key]}'), self) end EOS end end - end - - # Special proxy for a collection of items so that adding and removing - # to the list automatically updates the associated property. - class CollectionOfProxy < CastedArray - - def initialize(array, property, parent) - (array ||= []).compact! - super(array, property, parent) - casted_by[casted_by_property.to_s] = [] # replace the original array! - array.compact.each do |obj| - check_obj(obj) - casted_by[casted_by_property.to_s] << obj.id + def set_back_association(value, class_name, reverse_association = nil) + if reverse_association && !reverse_association.empty? + prop = self.class.properties.detect { |prop| prop.name =~ %r{#{reverse_association.to_s.singularize}_ids?} } + raise "Cannot find reverse association: #{reverse_association}" unless prop + if attributes[prop.name].class.ancestors.include?(Enumerable) + instance_eval("#{prop.name}.push('#{value.nil? ? nil : value.id}')") + else + send("#{prop.name}=", (value.nil? ? nil : value.id)) + end end end - def << obj - check_obj(obj) - casted_by[casted_by_property.to_s] << obj.id - super(obj) + def dirty_associations + @_dirty_associations ||= [] end - def push(obj) - check_obj(obj) - casted_by[casted_by_property.to_s].push obj.id - super(obj) + def register_dirty_association(obj) + dirty_associations << obj unless @_dirty_associations.include?(obj) end - def unshift(obj) - check_obj(obj) - casted_by[casted_by_property.to_s].unshift obj.id - super(obj) - end - - def []= index, obj - check_obj(obj) - casted_by[casted_by_property.to_s][index] = obj.id - super(index, obj) - end - - def pop - casted_by[casted_by_property.to_s].pop - super - end - - def shift - casted_by[casted_by_property.to_s].shift - super - end - - protected - - def check_obj(obj) - raise "Object cannot be added to #{casted_by.class.to_s}##{casted_by_property.to_s} collection unless saved" if obj.new? - end - - # Override CastedArray instantiation_and_cast method for a simpler - # version that will not try to cast the model. - def instantiate_and_cast(obj, change = true) - couchrest_parent_will_change! if change && use_dirty? - obj.casted_by = casted_by if obj.respond_to?(:casted_by) - obj.casted_by_property = casted_by_property if obj.respond_to?(:casted_by_property) - obj + def save_dirty_association + while !dirty_associations.empty? do + obj = dirty_associations.pop + obj.save + end end end diff --git a/lib/couchrest/model/associations/collection_of_proxy.rb b/lib/couchrest/model/associations/collection_of_proxy.rb new file mode 100644 index 00000000..0e772359 --- /dev/null +++ b/lib/couchrest/model/associations/collection_of_proxy.rb @@ -0,0 +1,81 @@ +module CouchRest + module Model + module Associations + # Special proxy for a collection of items so that adding and removing + # to the list automatically updates the associated property. + class CollectionOfProxy < CastedArray + + def initialize(array, property, parent) + (array ||= []).compact! + super(array, property, parent) + casted_by[casted_by_property.to_s] = [] # replace the original array! + array.compact.each do |obj| + check_obj(obj) + casted_by[casted_by_property.to_s] << obj.id + end + end + + def << obj + add_to_collection_with(:<<, obj) + super(obj) + end + + def push(obj) + add_to_collection_with(:push, obj) + super(obj) + end + + def unshift(obj) + add_to_collection_with(:unshift, obj) + super(obj) + end + + def []= index, obj + add_to_collection_with(:[]=, obj, index) + super(index, obj) + end + + def pop + obj = casted_by.send(casted_by_property.options[:proxy_name]).last + casted_by[casted_by_property.to_s].pop + obj.set_back_association(nil, casted_by.class.name, casted_by_property.options[:reverse_association]) + casted_by.register_dirty_association(obj) + super + end + + def shift + obj = casted_by.send(casted_by_property.options[:proxy_name]).first + casted_by[casted_by_property.to_s].shift + obj.set_back_association(nil, casted_by.class.name, casted_by_property.options[:reverse_association]) + casted_by.register_dirty_association(obj) + super + end + + protected + + def check_obj(obj) + raise "Object cannot be added to #{casted_by.class.to_s}##{casted_by_property.to_s} collection unless saved" if obj.new? + end + + def add_to_collection_with(method, obj, index=nil) + check_obj(obj) + args = [ obj.id ] + args = args.insert(0, index) if index + casted_by[casted_by_property.to_s].send(method, *args) + obj.set_back_association(casted_by, casted_by.class.name, casted_by_property.options[:reverse_association]) + casted_by.register_dirty_association(obj) + end + + # Override CastedArray instantiation_and_cast method for a simpler + # version that will not try to cast the model. + def instantiate_and_cast(obj, change = true) + couchrest_parent_will_change! if change && use_dirty? + obj.casted_by = casted_by if obj.respond_to?(:casted_by) + obj.casted_by_property = casted_by_property if obj.respond_to?(:casted_by_property) + obj + end + + end + end + end +end diff --git a/lib/couchrest/model/base.rb b/lib/couchrest/model/base.rb index d6acfd2c..ae0a7387 100644 --- a/lib/couchrest/model/base.rb +++ b/lib/couchrest/model/base.rb @@ -13,13 +13,13 @@ class Base < CouchRest::Document include ExtendedAttachments include Proxyable include PropertyProtection - include Associations include Validations include Callbacks + include Associations include Designs include CastedBy include Dirty - + def self.subclasses @subclasses ||= [] @@ -68,13 +68,13 @@ def initialize(attributes = {}, options = {}) alias :new_record? :new? alias :new_document? :new? - # Compare this model with another by confirming to see + # Compare this model with another by confirming to see # if the IDs and their databases match! # - # Camparison of the database is required in case the + # Camparison of the database is required in case the # model has been proxied or loaded elsewhere. # - # A Basic CouchRest document will only ever compare using + # A Basic CouchRest document will only ever compare using # a Hash comparison on the attributes. def == other return false unless other.is_a?(Base) diff --git a/lib/couchrest_model.rb b/lib/couchrest_model.rb index e66078e7..08c32ab3 100644 --- a/lib/couchrest_model.rb +++ b/lib/couchrest_model.rb @@ -40,6 +40,7 @@ require "couchrest/model/extended_attachments" require "couchrest/model/proxyable" require "couchrest/model/associations" +require "couchrest/model/associations/collection_of_proxy" require "couchrest/model/configuration" require "couchrest/model/connection" require "couchrest/model/design" diff --git a/spec/fixtures/models/kid.rb b/spec/fixtures/models/kid.rb new file mode 100644 index 00000000..fd9b73c1 --- /dev/null +++ b/spec/fixtures/models/kid.rb @@ -0,0 +1,7 @@ +class Kid < CouchRest::Model::Base + property :name, String + + belongs_to :dad, :class_name => 'Parent', :reverse_association => :children + belongs_to :mum, :class_name => 'Parent', :reverse_association => :children + +end diff --git a/spec/fixtures/models/parent.rb b/spec/fixtures/models/parent.rb new file mode 100644 index 00000000..ee1418f5 --- /dev/null +++ b/spec/fixtures/models/parent.rb @@ -0,0 +1,11 @@ +class Parent < CouchRest::Model::Base + property :name, String + + belongs_to :super_power + belongs_to :husband, :class_name => :parent, :reverse_association => :wife + belongs_to :wife, :class_name => :parent, :reverse_association => :husband + belongs_to :lives_with, :class_name => :parent, :reverse_association => :lives_with + + collection_of :children, :class_name => 'Kid' + collection_of :pets , :reverse_association => :owner +end diff --git a/spec/fixtures/models/pet.rb b/spec/fixtures/models/pet.rb new file mode 100644 index 00000000..7f395f23 --- /dev/null +++ b/spec/fixtures/models/pet.rb @@ -0,0 +1,6 @@ +class Pet < CouchRest::Model::Base + property :name, String + + belongs_to :walker, :class_name => 'Parent' + belongs_to :owner, :class_name => 'Parent' +end diff --git a/spec/fixtures/models/sale_entry.rb b/spec/fixtures/models/sale_entry.rb index 4c4b8e38..30741dbc 100644 --- a/spec/fixtures/models/sale_entry.rb +++ b/spec/fixtures/models/sale_entry.rb @@ -7,5 +7,5 @@ class SaleEntry < CouchRest::Model::Base design do view :by_description end - + end diff --git a/spec/fixtures/models/super_power.rb b/spec/fixtures/models/super_power.rb new file mode 100644 index 00000000..3dc58449 --- /dev/null +++ b/spec/fixtures/models/super_power.rb @@ -0,0 +1,5 @@ +class SuperPower < CouchRest::Model::Base + property :description, String + + belongs_to :parent +end diff --git a/spec/unit/assocations_dual_spec.rb b/spec/unit/assocations_dual_spec.rb new file mode 100644 index 00000000..9e9b16bf --- /dev/null +++ b/spec/unit/assocations_dual_spec.rb @@ -0,0 +1,254 @@ +# encoding: utf-8 +require 'spec_helper' + +describe 'Associations' do + + let(:father) { Parent.create(name: 'Bob')} + let(:can_fly){ SuperPower.create(description: 'Can fly when there is no cloud')} + let(:mummy) { Parent.create(name: 'Claire')} + let(:kid) { Kid.create( name: 'Vladimir')} + let(:dog) { Pet.create( name: 'Valdo' )} + + describe 'of type belongs_to' do + context 'with the other side also belongs_to (1-1)' do + context 'when reverse association not specified' do + it 'should NOT set the other side' do + father.super_power = can_fly + can_fly.parent.should be_nil + end + end + + context '[ambiguous association]' do + it 'should set the other side property too' do + father.wife = mummy + mummy.husband.should eql(father) + end + end + + context '[cyclic association]' do + it 'should set the other side property too' do + father.lives_with = mummy + mummy.lives_with.should eql(father) + end + end + end + + context 'with the other side do not back associate (1-0)' do + let(:invoice) { SaleInvoice.create(:price => 2000) } + let(:client) { Client.create(:name => "Sam Lown") } + it 'should set property without error' do + invoice.client = client + lambda { invoice.client = client }.should_not raise_error + end + + end + + context 'with the other side associate as a collection (1-n)' do + it 'should be part of the collection when setting the property' do + kid.dad = father + father.children.should include(kid) + end + end + + describe 'when object is saved' do + it 'should also save other side' do + father.wife = mummy + mummy.should_receive(:save) + father.save + end + it 'should not call save twice in a row' do + father.wife = mummy + mummy.should_receive(:save).exactly(1) + father.save + father.name = 'rogers' + father.save + end + end + end + + describe 'of type collection_of' do + context 'with the other side is a belongs_to (n-1).' do + context 'Adding to the collection using <<' do + context 'when NO reverse_assocition is specified' do + it 'should populate the belongs_to property' do + father.children << kid + kid.dad.should be_nil + end + end + context 'when reverse_association is specified' do + it 'should populate the belongs_to property' do + father.pets << dog + dog.owner.should eq(father) + end + describe 'when object is saved' do + it 'should also save other side' do + father.children << kid + kid.should_receive(:save) + father.save + end + end + end + end + + context 'Adding to the collection using push' do + context 'when NO reverse_assocition is specified' do + it 'should populate the belongs_to property' do + father.children.push kid + kid.dad.should be_nil + end + end + context 'when reverse_association is specified' do + it 'should populate the belongs_to property' do + father.pets.push dog + dog.owner.should eq(father) + end + describe 'when object is saved' do + it 'should also save other side' do + father.children.push kid + kid.should_receive(:save) + father.save + end + end + end + end + + context 'Adding to the collection using unshift' do + context 'when NO reverse_assocition is specified' do + it 'should NOT populate the belongs_to property' do + father.children.unshift kid + kid.dad.should be_nil + end + end + context 'when a reverse_assocition is specified' do + it 'should populate the belongs_to property' do + father.pets.unshift dog + dog.owner.should eq(father) + end + describe 'when object is saved' do + it 'should also save other side' do + father.pets.unshift dog + dog.should_receive(:save) + father.save + end + end + end + end + + context 'Adding to the collection using [n]=' do + context 'when NO reverse_assocition is specified' do + it 'should NOT populate the belongs_to property' do + father.children[3]= kid + kid.dad.should be_nil + end + end + context 'when a reverse_assocition is specified' do + it 'should populate the belongs_to property' do + father.pets[3] = dog + dog.owner.should eq(father) + end + describe 'when object is saved' do + it 'should also save other side' do + father.pets[4] = dog + dog.should_receive(:save) + father.save + end + end + end + end + + context 'removing from the collection using pop' do + it 'should set nil the belongs_to property' do + father.children.push kid + father.children.pop + kid.dad.should be_nil + end + context 'specifying reverse association' do + it 'should set nil the belongs_to property' do + father.pets.push dog + father.pets.pop + dog.owner.should be_nil + end + end + describe 'when object is saved' do + it 'should also save other side' do + father.children.push kid + father.save + father.children.pop + kid.should_receive(:save) + father.save + end + end + end + + context 'removing from the collection using shift' do + it 'should set nil the belongs_to property' do + father.children.push kid + father.children.shift + kid.dad.should be_nil + end + context 'specifying reverse association' do + it 'should set nil the belongs_to property' do + father.pets.push dog + father.pets.shift + dog.owner.should be_nil + end + end + describe 'when object is saved' do + it 'should also save other side' do + father.children.push kid + father.save + father.children.shift + kid.should_receive(:save) + father.save + end + end + end + + end + + context 'with the other side do not back associate (n-0)' do + let(:invoice) { SaleInvoice.create(:price => 2000) } + let(:entry) { SaleEntry.create(:description => 'test line 1', :price => 500) } + + context 'Adding to the collection using <<' do + it 'should set property without error' do + lambda { invoice.entries << entry}.should_not raise_error + end + end + + context 'Adding to the collection using push' do + it 'should set property without error' do + lambda { invoice.entries.push entry}.should_not raise_error + end + + context 'Adding to the collection using unshift' do + it 'should set property without error' do + lambda { invoice.entries.unshift entry}.should_not raise_error + end + end + + context 'Adding to the collection using []=' do + it 'should set property without error' do + lambda { invoice.entries[3]= entry}.should_not raise_error + end + end + + context 'removing from the collection using pop' do + it 'should set nil the belongs_to property' do + invoice.entries.push entry + lambda { invoice.entries.pop }.should_not raise_error + end + end + + context 'removing from the collection using shift' do + it 'should set nil the belongs_to property' do + invoice.entries.push entry + lambda { invoice.entries.shift }.should_not raise_error + end + end + + end + end + end + +end diff --git a/spec/unit/assocations_spec.rb b/spec/unit/assocations_spec.rb index 04e95039..cb2c2eb5 100644 --- a/spec/unit/assocations_spec.rb +++ b/spec/unit/assocations_spec.rb @@ -29,7 +29,7 @@ def SaleInvoice.merge_assoc_opts(*args) o = SaleInvoice.merge_assoc_opts(:cat) o[:proxy].should eql('self.company.cats') end - + end describe "of type belongs to" do @@ -111,7 +111,7 @@ def SaleInvoice.merge_assoc_opts(*args) it "should create an associated property and collection proxy" do @invoice.respond_to?('entry_ids').should be_true @invoice.respond_to?('entry_ids=').should be_true - @invoice.entries.class.should eql(::CouchRest::Model::CollectionOfProxy) + @invoice.entries.class.should eql(::CouchRest::Model::Associations::CollectionOfProxy) end it "should allow replacement of objects" do