Skip to content

Loading…

Inverse of many-to-many association #259

Open
wants to merge 1 commit into from
@hubertlepicki

Hi guys,

I just made inverse of many-to-many association support into MongoMapper. Essentially, it's using (and depends on having defined) normal "many :in => xxx" association. You use it with the following API:

class Book
key :author_ids, Array
many :authors, :in => :author_ids
end

class Author
many :books, :in_foreign => {:author_ids}, :as => :author
end

Please let me know if that's any good. I was thinking there is a possibility to have some base class for InArrayProxy and InForeignArrayProxy, as well as test cases, but would be good if you could let me know if you want to keep those separate or I should do it.

@blakeeb

I like this method, good work on a much-needed feature. Works great for me.

@abhishiv

has it(or an alternative) been pulled in?

@scudco

I'd also like to know if this is going to make it into the next release. I would appreciate it just for the sake of completeness in the API, but I'm guessing there must be some reason this commit has sat around for so long. Can anyone comment on what might be holding this up?

@jnunemaker

Just looked it over. Somehow I just missed it.

I think my only problem is with the syntax. many :books, :in_foreign => {:author_ids}, :as => :author doesn't feel right. As should only be used for polymorphic probably or it could lead to confusion. :in_foreign feels awkward. Its a tough problem naming this thing, I know. That is the main reason I haven't really attacked it.

I'm wondering if something like one of these would be better.

# assumes List from lists and uses user_ids key in List
many :lists, :from => :user_ids

# when lists => List convention can't be used, maybe class name can clear things up?
many :odd_lists, :from => :user_ids, :class_name => 'List'

# or maybe we go the way of rails routing. Class#key.
many :lists, :in => 'List#user_ids'

If :in option has Class#key string do the reverse of the normal :in. Though part of me feels this could be confusing too, using :in for both sides. Also, many :lists, :in => '#user_ids' or something to default lists => List would seem awkward.

Anyone have any thoughts or suggestions?

@mbleigh

I like the :from and :class_name option the best syntactically. Just use Rails as an example and you will cause the least confusion. The router syntax is interesting, but I don't think the new learned behavior is worth the tradeoff. I'd love to see this included!

@bkeepers

I pushed this "into a branch":https://github.com/jnunemaker/mongomapper/commits/from-array with the change of :in_foreign to :from. I'm still not sure about the :class_name option or if there's a better way to define it. Comment with your thoughts.

@divoxx

Hey guys,

Is this going to be pulled in? What needs to be done so that happens?

@tomas

Bump!

Brandon, do you think this will be merged anytime soon?

@leifcr

Feature that would be great to have. :inverse_of => z seems like a good syntax Syntax discussion #e755e1aa

@reconbot

I'm with @leifcr I like that too

@emirkin

Gentlemen, fyi there's currently an issue with many-to-many that I described in #488

@archSeer

@jnunemaker Any progress on this?

@tylrtrmbl

Holy cow @jnunemaker. This is the only thing holding me back from using MongoMapper right now. You're so close, just go for it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
View
1 lib/mongo_mapper.rb
@@ -74,6 +74,7 @@ module Associations
autoload :OneProxy, 'mongo_mapper/plugins/associations/one_proxy'
autoload :OneEmbeddedProxy, 'mongo_mapper/plugins/associations/one_embedded_proxy'
autoload :InArrayProxy, 'mongo_mapper/plugins/associations/in_array_proxy'
+ autoload :InForeignArrayProxy, 'mongo_mapper/plugins/associations/in_foreign_array_proxy'
end
end
View
8 lib/mongo_mapper/plugins/associations/base.rb
@@ -6,7 +6,7 @@ class Base
attr_reader :name, :options, :query_options
# Options that should not be considered MongoDB query options/criteria
- AssociationOptions = [:as, :class, :class_name, :dependent, :extend, :foreign_key, :in, :polymorphic, :autosave]
+ AssociationOptions = [:as, :class, :class_name, :dependent, :extend, :foreign_key, :in, :in_foreign, :polymorphic, :autosave]
def initialize(name, options={}, &extension)
@name, @options, @query_options, @original_options = name.to_sym, {}, {}, options
@@ -28,13 +28,17 @@ def polymorphic?
end
def as?
- !!@options[:as]
+ !in_foreign_array? && !!@options[:as]
end
def in_array?
!!@options[:in]
end
+ def in_foreign_array?
+ !!@options[:in_foreign]
+ end
+
def embeddable?
klass.embeddable?
end
View
135 lib/mongo_mapper/plugins/associations/in_foreign_array_proxy.rb
@@ -0,0 +1,135 @@
+# encoding: UTF-8
+module MongoMapper
+ module Plugins
+ module Associations
+ class InForeignArrayProxy < Collection
+ include DynamicQuerying::ClassMethods
+
+ def find(*args)
+ query.find(*scoped_ids(args))
+ end
+
+ def find!(*args)
+ query.find!(*scoped_ids(args))
+ end
+
+ def paginate(options)
+ query.paginate(options)
+ end
+
+ def all(options={})
+ query(options).all
+ end
+
+ def first(options={})
+ query(options).first
+ end
+
+ def last(options={})
+ query(options).last
+ end
+
+ def count(options={})
+ query(options).count
+ end
+
+ def destroy_all(options={})
+ all(options).each do |doc|
+ doc.destroy
+ end
+ reset
+ end
+
+ def delete_all(options={})
+ docs = query(options).fields(:_id).all
+ klass.delete(docs.map { |d| d.id })
+ reset
+ end
+
+ def nullify
+ replace([])
+ reset
+ end
+
+ def create(attrs={})
+ doc = klass.create(attrs)
+ if doc.persisted?
+ inverse_association(doc) << proxy_owner
+ doc.save
+ reset
+ end
+ doc
+ end
+
+ def create!(attrs={})
+ doc = klass.create!(attrs)
+
+ if doc.persisted?
+ inverse_association(doc) << proxy_owner
+ doc.save
+ reset
+ end
+ doc
+ end
+
+ def <<(*docs)
+ flatten_deeper(docs).each do |doc|
+ inverse_association(doc) << proxy_owner
+ doc.save
+ end
+ reset
+ end
+ alias_method :push, :<<
+ alias_method :concat, :<<
+
+ def replace(docs)
+ doc_ids = docs.map do |doc|
+ doc.save unless doc.persisted?
+ inverse_association(doc) << proxy_owner
+ doc.save
+ doc.id
+ end
+
+ replace_selector = { options[:in_foreign] => proxy_owner.id }
+ unless doc_ids.empty?
+ replace_selector[:_id] = {"$not" => {"$in" => doc_ids}}
+ end
+
+ klass.collection.update( replace_selector,
+ { "$pull" => { options[:in_foreign] => proxy_owner.id}},
+ { :multi => true } )
+
+ reset
+ end
+
+ private
+ def query(options={})
+ klass.
+ query(association.query_options).
+ update(options).
+ update(criteria)
+ end
+
+ def criteria
+ {options[:in_foreign] => proxy_owner.id}
+ end
+
+ def scoped_ids(args)
+ valid = args.flatten.map do |id|
+ id = ObjectId.to_mongo(id) if klass.using_object_id?
+ id
+ end
+ valid.empty? ? nil : valid
+ end
+
+ def find_target
+ all
+ end
+
+ def inverse_association(doc)
+ doc.send(options[:as].to_s.pluralize)
+ end
+ end
+ end
+ end
+end
View
4 lib/mongo_mapper/plugins/associations/many_association.rb
@@ -21,6 +21,8 @@ def proxy_class
ManyPolymorphicProxy
elsif as?
ManyDocumentsAsProxy
+ elsif in_foreign_array?
+ InForeignArrayProxy
elsif in_array?
InArrayProxy
else
@@ -64,4 +66,4 @@ def autosave?
end
end
end
-end
+end
View
321 test/functional/associations/test_in_foreign_array_proxy.rb
@@ -0,0 +1,321 @@
+require 'test_helper'
+
+class InArrayProxyTest < Test::Unit::TestCase
+ context "description" do
+ setup do
+ class ::List
+ include MongoMapper::Document
+ key :name, String, :required => true
+ key :user_ids, Array
+ many :users, :in => :user_ids
+ end
+
+ class ::User
+ include MongoMapper::Document
+ key :name, String, :required => true
+ many :lists, :in_foreign => :user_ids, :as => :user
+ end
+ User.collection.remove
+ List.collection.remove
+ end
+
+ teardown do
+ Object.send :remove_const, 'List' if defined?(::List)
+ Object.send :remove_const, 'User' if defined?(::User)
+ end
+
+ should "default reader to empty array" do
+ User.new.lists.should == []
+ end
+
+ should "allow adding to association like it was an array" do
+ user = User.new(:name => 'John')
+ user.lists << List.new(:name => 'Foo1!')
+ user.lists.push List.new(:name => 'Foo2!')
+ user.lists.concat List.new(:name => 'Foo3!')
+ user.lists.size.should == 3
+ end
+
+ should "ignore adding duplicate ids" do
+ user = User.create(:name => 'John')
+ list = List.create(:name => 'Foo')
+ user.lists << list
+ user.lists << list
+ user.lists << list
+
+ list.reload.user_ids.should == [user.id]
+ user.lists.count.should == 1
+ end
+
+ should "be able to replace the association" do
+ user = User.new(:name => 'John')
+ list = List.new(:name => 'Foo')
+ user.lists = [list]
+ user.save.should be_true
+
+ user.reload
+ list.reload
+ list.user_ids.should == [user.id]
+ user.lists.size.should == 1
+ user.lists[0].name.should == 'Foo'
+ end
+
+ context "create" do
+ setup do
+ @user = User.create(:name => 'John')
+ @list = @user.lists.create(:name => 'Foo!')
+ end
+
+ should "add id to key" do
+ @list.user_ids.should include(@user.id)
+ end
+
+ should "persist id addition to key in database" do
+ @list.reload
+ @list.user_ids.should include(@user.id)
+ end
+
+ should "add doc to association" do
+ @user.lists.should include(@list)
+ end
+
+ should "save doc" do
+ @list.should_not be_new
+ end
+
+ should "reset cache" do
+ @user.lists.size.should == 1
+ @user.lists.create(:name => 'Moo!')
+ @user.lists.size.should == 2
+ end
+ end
+
+ context "create!" do
+ setup do
+ @user = User.create(:name => 'John')
+ @list = @user.lists.create!(:name => 'Foo!')
+ end
+
+ should "add id to key" do
+ @list.user_ids.should include(@user.id)
+ end
+
+ should "persist id addition to key in database" do
+ @list.reload
+ @list.user_ids.should include(@user.id)
+ end
+
+ should "add doc to association" do
+ @user.lists.should include(@list)
+ end
+
+ should "save doc" do
+ @list.should_not be_new
+ end
+
+ should "raise exception if invalid" do
+ assert_raises(MongoMapper::DocumentNotValid) do
+ @user.lists.create!
+ end
+ end
+
+ should "reset cache" do
+ @user.lists.size.should == 1
+ @user.lists.create!(:name => 'Moo!')
+ @user.lists.size.should == 2
+ end
+ end
+
+ context "Finding scoped to association" do
+ setup do
+ @user = User.create(:name => 'John')
+ @user2 = User.create(:name => 'Brandon')
+ @list1 = @user.lists.create!(:name => 'Foo 1', :position => 1)
+ @list2 = @user.lists.create!(:name => 'Foo 2', :position => 2)
+ @list3 = @user2.lists.create!(:name => 'Foo 3', :position => 1)
+ end
+
+ context "all" do
+ should "work" do
+ @user.lists.all(:order => :position.asc).should == [@list1, @list2]
+ end
+
+ should "work with conditions" do
+ @user.lists.all(:name => 'Foo 1').should == [@list1]
+ end
+ end
+
+ context "first" do
+ should "work" do
+ @user.lists.first(:order => 'position').should == @list1
+ end
+
+ should "work with conditions" do
+ @user.lists.first(:position => 2).should == @list2
+ end
+ end
+
+ context "last" do
+ should "work" do
+ @user.lists.last(:order => 'position').should == @list2
+ end
+
+ should "work with conditions" do
+ @user.lists.last(:position => 2, :order => 'position').should == @list2
+ end
+ end
+
+ context "with one id" do
+ should "work for id in association" do
+ @user.lists.find(@list1.id).should == @list1
+ end
+
+ should "work with string ids" do
+ @user.lists.find(@list1.id.to_s).should == @list1
+ end
+
+ should "not work for id not in association" do
+ @user.lists.find(@list3.id).should be_nil
+ end
+
+ should "raise error when using ! and not found" do
+ assert_raises MongoMapper::DocumentNotFound do
+ @user.lists.find!(@list3.id)
+ end
+ end
+ end
+
+ context "with multiple ids" do
+ should "work for ids in association" do
+ @user.lists.find(@list1.id, @list2.id).should == [@list1, @list2]
+ end
+
+ should "not work for ids not in association" do
+ @user.lists.find(@list1.id, @list2.id, @list3.id).should == [@list1, @list2]
+ end
+ end
+
+ context "with #paginate" do
+ setup do
+ @lists = @user.lists.paginate(:per_page => 1, :page => 1, :order => 'position')
+ end
+
+ should "return total pages" do
+ @lists.total_pages.should == 2
+ end
+
+ should "return total entries" do
+ @lists.total_entries.should == 2
+ end
+
+ should "return the subject" do
+ @lists.collect(&:name).should == ['Foo 1']
+ end
+ end
+
+ context "dynamic finders" do
+ should "work with single key" do
+ @user.lists.find_by_name('Foo 1').should == @list1
+ @user.lists.find_by_name!('Foo 1').should == @list1
+ @user.lists.find_by_name('Foo 3').should be_nil
+ end
+
+ should "work with multiple keys" do
+ @user.lists.find_by_name_and_position('Foo 1', 1).should == @list1
+ @user.lists.find_by_name_and_position!('Foo 1', 1).should == @list1
+ @user.lists.find_by_name_and_position('Foo 3', 1).should be_nil
+ end
+
+ should "raise error when using ! and not found" do
+ assert_raises(MongoMapper::DocumentNotFound) do
+ @user.lists.find_by_name!('Foo 3')
+ end
+ end
+
+ context "find_or_create_by" do
+ should "not create document if found" do
+ lambda {
+ list = @user.lists.find_or_create_by_name('Foo 1')
+ list.should == @list1
+ }.should_not change { List.count }
+ end
+
+ should "create document if not found" do
+ lambda {
+ list = @user.lists.find_or_create_by_name('Home')
+ @user.lists.should include(list)
+ }.should change { List.count }
+ end
+ end
+ end
+ end
+
+ context "count" do
+ setup do
+ @user = User.create(:name => 'John')
+ @user2 = User.create(:name => 'Brandon')
+ @list1 = @user.lists.create!(:name => 'Foo 1')
+ @list2 = @user.lists.create!(:name => 'Foo 2')
+ @list3 = @user2.lists.create!(:name => 'Foo 3')
+ end
+
+ should "return number of ids" do
+ @user.lists.count.should == 2
+ @user2.lists.count.should == 1
+ end
+
+ should "return correct count when given criteria" do
+ @user.lists.count(:name => 'Foo 1').should == 1
+ @user2.lists.count(:name => 'Foo 1').should == 0
+ end
+ end
+
+ context "Removing documents" do
+ setup do
+ @user = User.create(:name => 'John')
+ @user2 = User.create(:name => 'Brandon')
+ @list1 = @user.lists.create!(:name => 'Foo 1', :position => 1)
+ @list2 = @user.lists.create!(:name => 'Foo 2', :position => 2)
+ @list3 = @user2.lists.create!(:name => 'Foo 3', :position => 1)
+ end
+
+ context "destroy_all" do
+ should "work" do
+ @user.lists.count.should == 2
+ @user.lists.destroy_all
+ @user.lists.count.should == 0
+ end
+
+ should "work with conditions" do
+ @user.lists.count.should == 2
+ @user.lists.destroy_all(:name => 'Foo 1')
+ @user.lists.count.should == 1
+ end
+ end
+
+ context "delete_all" do
+ should "work" do
+ @user.lists.count.should == 2
+ @user.lists.delete_all
+ @user.lists.count.should == 0
+ end
+
+ should "work with conditions" do
+ @user.lists.count.should == 2
+ @user.lists.delete_all(:name => 'Foo 1')
+ @user.lists.count.should == 1
+ end
+ end
+
+ should "work with nullify" do
+ @user.lists.count.should == 2
+
+ lambda {
+ @user.lists.nullify
+ }.should_not change { List.count }
+
+ @user.lists.count.should == 0
+ end
+ end
+ end
+end
Something went wrong with that request. Please try again.