adding support for collection_of association

This commit is contained in:
Sam Lown 2010-06-17 15:02:33 +02:00
parent fa0ab968a8
commit dd55466764
4 changed files with 281 additions and 11 deletions

View file

@ -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

View file

@ -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 = {})
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

View file

@ -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

View file

@ -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