From dd5546676415dc7475359a05488aa621978ef90b Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 17 Jun 2010 15:02:33 +0200 Subject: [PATCH] adding support for collection_of association --- history.txt | 1 + lib/couchrest/casted_model.rb | 11 +- lib/couchrest/mixins/associations.rb | 159 ++++++++++++++++++++++++++- spec/couchrest/assocations_spec.rb | 121 ++++++++++++++++++++ 4 files changed, 281 insertions(+), 11 deletions(-) diff --git a/history.txt b/history.txt index 4c41ef6..95997e7 100644 --- a/history.txt +++ b/history.txt @@ -16,6 +16,7 @@ * Major enhancements * Added support for anonymous CastedModels defined in Documents * Added initial support for simple belongs_to associations + * Added support for basic collection_of association (unique to document databases!) == 1.0.0.beta5 diff --git a/lib/couchrest/casted_model.rb b/lib/couchrest/casted_model.rb index 8f0ed16..cbe2638 100644 --- a/lib/couchrest/casted_model.rb +++ b/lib/couchrest/casted_model.rb @@ -2,19 +2,18 @@ module CouchRest module CastedModel def self.included(base) + base.send(:include, ::CouchRest::Mixins::AttributeProtection) + base.send(:include, ::CouchRest::Mixins::Attributes) base.send(:include, ::CouchRest::Mixins::Callbacks) base.send(:include, ::CouchRest::Mixins::Properties) - base.send(:include, ::CouchRest::Mixins::Assocations) + base.send(:include, ::CouchRest::Mixins::Associations) base.send(:attr_accessor, :casted_by) end - def initialize(keys={}) + def initialize(keys = {}) raise StandardError unless self.is_a? Hash - apply_all_property_defaults # defined in CouchRest::Mixins::Properties + prepare_all_attributes(keys) super() - keys.each do |k,v| - write_attribute(k.to_s, v) - end if keys end def []= key, value diff --git a/lib/couchrest/mixins/associations.rb b/lib/couchrest/mixins/associations.rb index 68b2e84..c524774 100644 --- a/lib/couchrest/mixins/associations.rb +++ b/lib/couchrest/mixins/associations.rb @@ -1,4 +1,6 @@ module CouchRest + + module Mixins module Associations @@ -10,10 +12,13 @@ module CouchRest module ClassMethods + # Define an association that this object belongs to. + # def belongs_to(attrib, *options) opts = { :foreign_key => attrib.to_s + '_id', - :class_name => attrib.to_s.camelcase + :class_name => attrib.to_s.camelcase, + :proxy => nil } case options.first when Hash @@ -23,10 +28,10 @@ module CouchRest begin opts[:class] = opts[:class_name].constantize rescue - raise "Unable to convert belongs_to class name into Constant for #{self.name}##{attrib}" + raise "Unable to convert class name into Constant for #{self.name}##{attrib}" end - prop = property(opts[:foreign_key]) + prop = property(opts[:foreign_key], opts) create_belongs_to_getter(attrib, prop, opts) create_belongs_to_setter(attrib, prop, opts) @@ -34,10 +39,76 @@ module CouchRest prop end + # Provide access to a collection of objects where the associated + # property contains a list of the collection item ids. + # + # The following: + # + # collection_of :groups + # + # creates a pseudo property called "groups" which allows access + # to a CollectionProxy object. Adding, replacing or removing entries in this + # proxy will cause the matching property array, in this case "group_ids", to + # be kept in sync. + # + # Any manual changes made to the collection ids property (group_ids), unless replaced, will require + # a reload of the CollectionProxy for the two sets of data to be in sync: + # + # group_ids = ['123'] + # groups == [Group.get('123')] + # group_ids << '321' + # groups == [Group.get('123')] + # groups(true) == [Group.get('123'), Group.get('321')] + # + # Of course, saving the parent record will store the collection ids as they are + # found. + # + # The CollectionProxy supports the following array functions, anything else will cause + # a mismatch between the collection objects and collection ids: + # + # groups << obj + # groups.push obj + # groups.unshift obj + # groups[0] = obj + # groups.pop == obj + # groups.shift == obj + # + # Addtional options match those of the the belongs_to method. + # + def collection_of(attrib, *options) + opts = { + :foreign_key => attrib.to_s.singularize + '_ids', + :class_name => attrib.to_s.singularize.camelcase, + :proxy => nil + } + case options.first + when Hash + opts.merge!(options.first) + end + begin + opts[:class] = opts[:class_name].constantize + rescue + raise "Unable to convert class name into Constant for #{self.name}##{attrib}" + end + opts[:readonly] = true + + prop = property(opts[:foreign_key], [], opts) + + create_collection_of_property_setter(attrib, prop, opts) + create_collection_of_getter(attrib, prop, opts) + create_collection_of_setter(attrib, prop, opts) + + prop + end + + + private + def create_belongs_to_getter(attrib, property, options) + base = options[:proxy] || options[:class_name] class_eval <<-EOS, __FILE__, __LINE__ + 1 def #{attrib} - @#{attrib} ||= #{options[:class_name]}.get(self.#{options[:foreign_key]}) + @#{attrib} ||= #{base}.get(self.#{options[:foreign_key]}) end EOS end @@ -45,14 +116,92 @@ module CouchRest def create_belongs_to_setter(attrib, property, options) class_eval <<-EOS, __FILE__, __LINE__ + 1 def #{attrib}=(value) - @#{attrib} = value self.#{options[:foreign_key]} = value.nil? ? nil : value.id + @#{attrib} = value end EOS end + ### collection_of support methods + + def create_collection_of_property_setter(attrib, property, options) + # ensure CollectionProxy is nil, ready to be reloaded on request + class_eval <<-EOS, __FILE__, __LINE__ + 1 + def #{options[:foreign_key]}=(value) + @#{attrib} = nil + write_attribute("#{options[:foreign_key]}", value) + end + EOS + end + + def create_collection_of_getter(attrib, property, options) + base = options[:proxy] || options[:class_name] + class_eval <<-EOS, __FILE__, __LINE__ + 1 + def #{attrib}(reload = false) + return @#{attrib} unless @#{attrib}.nil? or reload + ary = self.#{options[:foreign_key]}.collect{|i| #{base}.get(i)} + @#{attrib} = ::CouchRest::CollectionProxy.new(ary, self, '#{options[:foreign_key]}') + end + EOS + end + + def create_collection_of_setter(attrib, property, options) + class_eval <<-EOS, __FILE__, __LINE__ + 1 + def #{attrib}=(value) + @#{attrib} = ::CouchRest::CollectionProxy.new(value, self, '#{options[:foreign_key]}') + end + EOS + end + + end end end + + # Special proxy for a collection of items so that adding and removing + # to the list automatically updates the associated property. + class CollectionProxy < Array + attr_accessor :property + attr_accessor :casted_by + + def initialize(array, casted_by, property) + self.property = property + self.casted_by = casted_by + array ||= [] + casted_by[property.to_s] = [] # replace the original array! + array.each do |obj| + casted_by[property.to_s] << obj.id + end + super(array) + end + def << obj + casted_by[property.to_s] << obj.id + super(obj) + end + def push(obj) + casted_by[property.to_s].push obj.id + super(obj) + end + def unshift(obj) + casted_by[property.to_s].unshift obj.id + super(obj) + end + + def []= index, obj + casted_by[property.to_s][index] = obj.id + super(index, obj) + end + + def pop + casted_by[property.to_s].pop + super + end + def shift + casted_by[property.to_s].shift + super + end + end + + end diff --git a/spec/couchrest/assocations_spec.rb b/spec/couchrest/assocations_spec.rb index 88ca2f6..88852a0 100644 --- a/spec/couchrest/assocations_spec.rb +++ b/spec/couchrest/assocations_spec.rb @@ -8,12 +8,21 @@ class Client < CouchRest::ExtendedDocument property :tax_code end +class SaleEntry < CouchRest::ExtendedDocument + use_database DB + + property :description + property :price +end + class SaleInvoice < CouchRest::ExtendedDocument use_database DB belongs_to :client belongs_to :alternate_client, :class_name => 'Client', :foreign_key => 'alt_client_id' + collection_of :entries, :class_name => 'SaleEntry' + property :date, Date property :price, Integer end @@ -76,5 +85,117 @@ describe "Assocations" do end + describe "of type collection_of" do + + before(:each) do + @invoice = SaleInvoice.create(:price => "sam", :price => 2000) + @entries = [ + SaleEntry.create(:description => 'test line 1', :price => 500), + SaleEntry.create(:description => 'test line 2', :price => 500), + SaleEntry.create(:description => 'test line 3', :price => 1000) + ] + end + + it "should create an associated property and collection proxy" do + @invoice.respond_to?('entry_ids') + @invoice.respond_to?('entry_ids=') + @invoice.entries.class.should eql(::CouchRest::CollectionProxy) + end + + it "should allow replacement of objects" do + @invoice.entries = @entries + @invoice.entries.length.should eql(3) + @invoice.entry_ids.length.should eql(3) + @invoice.entries.first.should eql(@entries.first) + @invoice.entry_ids.first.should eql(@entries.first.id) + end + + it "should allow ids to be set directly and load entries" do + @invoice.entry_ids = @entries.collect{|i| i.id} + @invoice.entries.length.should eql(3) + @invoice.entries.first.should eql(@entries.first) + end + + it "should replace collection if ids replaced" do + @invoice.entry_ids = @entries.collect{|i| i.id} + @invoice.entries.length.should eql(3) # load once + @invoice.entry_ids = @entries[0..1].collect{|i| i.id} + @invoice.entries.length.should eql(2) + end + + it "should allow forced collection update if ids changed" do + @invoice.entry_ids = @entries[0..1].collect{|i| i.id} + @invoice.entries.length.should eql(2) # load once + @invoice.entry_ids << @entries[2].id + @invoice.entry_ids.length.should eql(3) + @invoice.entries.length.should eql(2) # cached! + @invoice.entries(true).length.should eql(3) + end + + it "should empty arrays when nil collection provided" do + @invoice.entries = @entries + @invoice.entries = nil + @invoice.entry_ids.should be_empty + @invoice.entries.should be_empty + end + + it "should empty arrays when nil ids array provided" do + @invoice.entries = @entries + @invoice.entry_ids = nil + @invoice.entry_ids.should be_empty + @invoice.entries.should be_empty + end + + describe "proxy" do + + it "should ensure new entries to proxy are matched" do + @invoice.entries << @entries.first + @invoice.entry_ids.first.should eql(@entries.first.id) + @invoice.entries.first.should eql(@entries.first) + @invoice.entries << @entries[1] + @invoice.entries.count.should eql(2) + @invoice.entry_ids.count.should eql(2) + @invoice.entry_ids.last.should eql(@entries[1].id) + @invoice.entries.last.should eql(@entries[1]) + end + + it "should support push method" do + @invoice.entries.push(@entries.first) + @invoice.entry_ids.first.should eql(@entries.first.id) + end + + it "should support []= method" do + @invoice.entries[0] = @entries.first + @invoice.entry_ids.first.should eql(@entries.first.id) + end + + it "should support unshift method" do + @invoice.entries.unshift(@entries.first) + @invoice.entry_ids.first.should eql(@entries.first.id) + @invoice.entries.unshift(@entries[1]) + @invoice.entry_ids.first.should eql(@entries[1].id) + end + + it "should support pop method" do + @invoice.entries.push(@entries.first) + @invoice.entries.pop.should eql(@entries.first) + @invoice.entries.empty?.should be_true + @invoice.entry_ids.empty?.should be_true + end + + it "should support shift method" do + @invoice.entries.push(@entries[0]) + @invoice.entries.push(@entries[1]) + @invoice.entries.shift.should eql(@entries[0]) + @invoice.entries.first.should eql(@entries[1]) + @invoice.entry_ids.first.should eql(@entries[1].id) + end + + + end + + + end + end