Permalink
Browse files

Merge the filter_by_associations plugin into Sequel's default associa…

…tion support

This breaks backwards compatibility for people who added an
sql_literal method to Sequel::Model so that Sequel::Model
instances could be used in filters.

While here, add an integration test for filtering by associations.
  • Loading branch information...
jeremyevans committed Apr 29, 2011
1 parent f87103a commit 47f934e04a94b43103b23684c4a62c9342b544dd
View
@@ -1,6 +1,6 @@
=== HEAD
-* Add filter_by_associations plugin that allows model datasets to filter using associations (jeremyevans)
+* Add support for filtering by associations to model datasets (jeremyevans)
* Don't call insert_select when saving a model that doesn't select all columns of the table (jeremyevans)
@@ -378,6 +378,60 @@ method, you have to pass a proc as an argument:
@artist.albums(proc{|ds| ds.filter(:name.like('A%'))})
+== Filtering By Associations
+
+In addition to using the association method to get associated objects, you
+can also use associated objects in filters. For example, while to get
+all albums for a given artist, you would usually do:
+
+ @artist.albums
+ # or @artist.albums_dataset for a dataset
+
+You can also do the following:
+
+ Album.filter(:artist=>@artist).all
+ # or leave off the .all for a dataset
+
+For filtering by a single association, this isn't very useful. However, unlike
+using the association method, using a filter allows you to filter by multiple
+associations:
+
+ Album.filter(:artist=>@artist, :publisher=>@publisher)
+
+This will return all albums by that artist and published by that publisher.
+This isn't possible using just the association method approach, though you
+can combine the approaches:
+
+ @artist.albums_dataset.filter(:publisher=>@publisher)
+
+This doesn't just work for +many_to_one+ associations, it also works for
++one_to_one+, +one_to_many+, and +many_to_many+ associations:
+
+ Album.one_to_one :album_info
+ # The album related to that AlbumInfo instance
+ Album.filter(:album_info=>AlbumInfo[2])
+
+ Album.one_to_many :tracks
+ # The album related to that Track instance
+ Album.filter(:tracks=>Track[3])
+
+ Album.many_to_many :tags
+ # All albums related to that Tag instance
+ Album.filter(:tags=>Tag[4])
+
+Note that for +one_to_many+ and +many_to_many+ associations, you still
+use the plural form even though only a single model object is given.
+You cannot use an array of model objects as the value, only a single model
+object. To use separate model objects for the same association, you can
+use the array form of condition specifiers:
+
+ Album.filter([[:tags, Tag[1]], [:tags, Tag[2]]])
+
+That will return albums associated with both tag 1 and tag 2.
+
+Note that filtering by associations only works correctly for simple
+associations (ones without conditions).
+
== Name Collisions
Because associations create instance methods, it's possible to override
@@ -1408,6 +1408,40 @@ def eager_graph(*associations)
ds.eager_graph_associations(ds, model, ds.opts[:eager_graph][:master], [], *associations)
end
+ # If the expression is in the form <tt>x = y</tt> where +y+ is a <tt>Sequel::Model</tt>
+ # instance, assume +x+ is an association symbol and look up the association reflection
+ # via the dataset's model. From there, return the appropriate SQL based on the type of
+ # association and the values of the foreign/primary keys of +y+. For most association
+ # types, this is a simple transformation, but for +many_to_many+ associations this
+ # creates a subquery to the join table.
+ def complex_expression_sql(op, args)
+ if op == :'=' and args.at(1).is_a?(Sequel::Model)
+ l, r = args
+ if a = model.association_reflections[l]
+ unless r.is_a?(a.associated_class)
+ raise Sequel::Error, "invalid association class #{r.class.inspect} for association #{l.inspect} used in dataset filter for model #{model.inspect}, expected class #{a.associated_class.inspect}"
+ end
+
+ case a[:type]
+ when :many_to_one
+ literal(SQL::BooleanExpression.from_value_pairs(a[:keys].zip(a.primary_keys.map{|k| r.send(k)})))
+ when :one_to_one, :one_to_many
+ literal(SQL::BooleanExpression.from_value_pairs(a[:primary_keys].zip(a[:keys].map{|k| r.send(k)})))
+ when :many_to_many
+ lpks, lks, rks = a.values_at(:left_primary_keys, :left_keys, :right_keys)
+ lpks = lpks.first if lpks.length == 1
+ literal(SQL::BooleanExpression.from_value_pairs(lpks=>model.db[a[:join_table]].select(*lks).where(rks.zip(a.right_primary_keys.map{|k| r.send(k)}))))
+ else
+ raise Sequel::Error, "invalid association type #{a[:type].inspect} for association #{l.inspect} used in dataset filter for model #{model.inspect}"
+ end
+ else
+ raise Sequel::Error, "invalid association #{l.inspect} used in dataset filter for model #{model.inspect}"
+ end
+ else
+ super
+ end
+ end
+
# Do not attempt to split the result set into associations,
# just return results as simple objects. This is useful if you
# want to use eager_graph as a shortcut to have all of the joins
@@ -1,77 +0,0 @@
-module Sequel
- module Plugins
- # The filter_by_associations plugin allows filtering by associations defined
- # in the model. To use this, you should use the association name as a hash key
- # and the associated model object as a hash value:
- #
- # Album.many_to_one :artist
- # Album.filter(:artist=>Artist[1])
- #
- # This doesn't just work for +many_to_one+ associations, it also works for
- # +one_to_one+, +one_to_many+, and +many_to_many+ associations:
- #
- # Album.one_to_one :album_info
- # Album.filter(:album_info=>AlbumInfo[2])
- #
- # Album.one_to_many :tracks
- # Album.filter(:tracks=>Track[3])
- #
- # Album.many_to_many :tags
- # Album.filter(:tags=>Tag[4])
- #
- # Note that for +one_to_many+ and +many_to_many+ associations, you still
- # use the plural form even though only a single model object is given.
- # You cannot use an array of model objects as the value, only a single model
- # object. To use separate model objects for the same association, you can
- # use the array form of condition specifiers:
- #
- # Album.filter([[:tags, Tag[1]], [:tags, Tag[2]]])
- #
- # That will return albums associated with both tag 1 and tag 2.
- #
- # Usage:
- #
- # # Enable filtering by associations for all model datasets
- # Sequel::Model.plugin :filter_by_associations
- #
- # # Enable filtering by associations for just the Album dataset
- # Album.plugin :filter_by_associations
- module FilterByAssociations
- module DatasetMethods
- # If the expression is in the form <tt>x = y</tt> where +y+ is a <tt>Sequel::Model</tt>
- # instance, assume +x+ is an association symbol and look up the association reflection
- # via the dataset's model. From there, return the appropriate SQL based on the type of
- # association and the values of the foreign/primary keys of +y+. For most association
- # types, this is a simple transformation, but for +many_to_many+ associations this
- # creates a subquery to the join table.
- def complex_expression_sql(op, args)
- if op == :'=' and args.at(1).is_a?(Sequel::Model)
- l, r = args
- if a = model.association_reflections[l]
- unless r.is_a?(a.associated_class)
- raise Sequel::Error, "invalid association class #{r.class.inspect} for association #{l.inspect} used in dataset filter for model #{model.inspect}, expected class #{a.associated_class.inspect}"
- end
-
- case a[:type]
- when :many_to_one
- literal(SQL::BooleanExpression.from_value_pairs(a[:keys].zip(a.primary_keys.map{|k| r.send(k)})))
- when :one_to_one, :one_to_many
- literal(SQL::BooleanExpression.from_value_pairs(a[:primary_keys].zip(a[:keys].map{|k| r.send(k)})))
- when :many_to_many
- lpks, lks, rks = a.values_at(:left_primary_keys, :left_keys, :right_keys)
- lpks = lpks.first if lpks.length == 1
- literal(SQL::BooleanExpression.from_value_pairs(lpks=>model.db[a[:join_table]].select(*lks).where(rks.zip(a.right_primary_keys.map{|k| r.send(k)}))))
- else
- raise Sequel::Error, "invalid association type #{a[:type].inspect} for association #{l.inspect} used in dataset filter for model #{model.inspect}"
- end
- else
- raise Sequel::Error, "invalid association #{l.inspect} used in dataset filter for model #{model.inspect}"
- end
- else
- super
- end
- end
- end
- end
- end
-end
@@ -1,87 +0,0 @@
-require File.join(File.dirname(File.expand_path(__FILE__)), "spec_helper")
-
-describe "Sequel::Plugins::FilterByAssociations" do
- before do
- @Album = Class.new(Sequel::Model(:albums))
- artist = @Artist = Class.new(Sequel::Model(:artists))
- tag = @Tag = Class.new(Sequel::Model(:tags))
- track = @Track = Class.new(Sequel::Model(:tracks))
- album_info = @AlbumInfo = Class.new(Sequel::Model(:album_infos))
- @Artist.columns :id, :id1, :id2
- @Tag.columns :id, :tid1, :tid2
- @Track.columns :id, :album_id, :album_id1, :album_id2
- @AlbumInfo.columns :id, :album_id, :album_id1, :album_id2
- @Album.class_eval do
- plugin :filter_by_associations
- columns :id, :id1, :id2, :artist_id, :artist_id1, :artist_id2
- many_to_one :artist, :class=>artist
- one_to_many :tracks, :class=>track, :key=>:album_id
- one_to_one :album_info, :class=>album_info, :key=>:album_id
- many_to_many :tags, :class=>tag, :left_key=>:album_id, :join_table=>:albums_tags
-
- many_to_one :cartist, :class=>artist, :key=>[:artist_id1, :artist_id2], :primary_key=>[:id1, :id2]
- one_to_many :ctracks, :class=>track, :key=>[:album_id1, :album_id2], :primary_key=>[:id1, :id2]
- one_to_one :calbum_info, :class=>album_info, :key=>[:album_id1, :album_id2], :primary_key=>[:id1, :id2]
- many_to_many :ctags, :class=>tag, :left_key=>[:album_id1, :album_id2], :left_primary_key=>[:id1, :id2], :right_key=>[:tag_id1, :tag_id2], :right_primary_key=>[:tid1, :tid2], :join_table=>:albums_tags
- end
- end
-
- it "should be able to filter on many_to_one associations" do
- @Album.filter(:artist=>@Artist.load(:id=>3)).sql.should == 'SELECT * FROM albums WHERE (artist_id = 3)'
- end
-
- it "should be able to filter on one_to_many associations" do
- @Album.filter(:tracks=>@Track.load(:album_id=>3)).sql.should == 'SELECT * FROM albums WHERE (id = 3)'
- end
-
- it "should be able to filter on one_to_one associations" do
- @Album.filter(:album_info=>@AlbumInfo.load(:album_id=>3)).sql.should == 'SELECT * FROM albums WHERE (id = 3)'
- end
-
- it "should be able to filter on many_to_many associations" do
- @Album.filter(:tags=>@Tag.load(:id=>3)).sql.should == 'SELECT * FROM albums WHERE (id IN (SELECT album_id FROM albums_tags WHERE (tag_id = 3)))'
- end
-
- it "should be able to filter on many_to_one associations with composite keys" do
- @Album.filter(:cartist=>@Artist.load(:id1=>3, :id2=>4)).sql.should == 'SELECT * FROM albums WHERE ((artist_id1 = 3) AND (artist_id2 = 4))'
- end
-
- it "should be able to filter on one_to_many associations with composite keys" do
- @Album.filter(:ctracks=>@Track.load(:album_id1=>3, :album_id2=>4)).sql.should == 'SELECT * FROM albums WHERE ((id1 = 3) AND (id2 = 4))'
- end
-
- it "should be able to filter on one_to_one associations with composite keys" do
- @Album.filter(:calbum_info=>@AlbumInfo.load(:album_id1=>3, :album_id2=>4)).sql.should == 'SELECT * FROM albums WHERE ((id1 = 3) AND (id2 = 4))'
- end
-
- it "should be able to filter on many_to_many associations with composite keys" do
- @Album.filter(:ctags=>@Tag.load(:tid1=>3, :tid2=>4)).sql.should == 'SELECT * FROM albums WHERE ((id1, id2) IN (SELECT album_id1, album_id2 FROM albums_tags WHERE ((tag_id1 = 3) AND (tag_id2 = 4))))'
- end
-
- it "should work inside a complex filter" do
- artist = @Artist.load(:id=>3)
- @Album.filter{foo & {:artist=>artist}}.sql.should == 'SELECT * FROM albums WHERE (foo AND (artist_id = 3))'
- track = @Track.load(:album_id=>4)
- @Album.filter{foo & [[:artist, artist], [:tracks, track]]}.sql.should == 'SELECT * FROM albums WHERE (foo AND (artist_id = 3) AND (id = 4))'
- end
-
- it "should raise for an invalid association name" do
- proc{@Album.filter(:foo=>@Artist.load(:id=>3)).sql}.should raise_error(Sequel::Error)
- end
-
- it "should raise for an invalid association type" do
- @Album.plugin :many_through_many
- @Album.many_through_many :mtmtags, [[:album_id, :album_tags, :tag_id]], :class=>@Tag
- proc{@Album.filter(:mtmtags=>@Tag.load(:id=>3)).sql}.should raise_error(Sequel::Error)
- end
-
- it "should raise for an invalid associated object class " do
- proc{@Album.filter(:tags=>@Artist.load(:id=>3)).sql}.should raise_error(Sequel::Error)
- end
-
- it "should work correctly in subclasses" do
- c = Class.new(@Album)
- c.many_to_one :sartist, :class=>@Artist
- c.filter(:sartist=>@Artist.load(:id=>3)).sql.should == 'SELECT * FROM albums WHERE (sartist_id = 3)'
- end
-end
@@ -22,6 +22,22 @@
@tag.albums.should == [@album]
end
+ specify "should work correctly when filtering by associations" do
+ @album.update(:artist => @artist)
+ @album.add_tag(@tag)
+
+ @album.reload
+ @artist.reload
+ @tag.reload
+
+ Artist.filter(:albums=>@album).all.should == [@artist]
+ Album.filter(:artist=>@artist).all.should == [@album]
+ Album.filter(:tags=>@tag).all.should == [@album]
+ Tag.filter(:albums=>@album).all.should == [@tag]
+ Album.filter(:artist=>@artist, :tags=>@tag).all.should == [@album]
+ @artist.albums_dataset.filter(:tags=>@tag).all.should == [@album]
+ end
+
specify "should have remove methods work" do
@album.update(:artist => @artist)
@album.add_tag(@tag)
@@ -2756,3 +2756,88 @@ def self.to_s; 'Node'; end
c.instance_methods.map{|x| x.to_s}.should include('parent')
end
end
+
+describe "Filtering by associations" do
+ before do
+ @Album = Class.new(Sequel::Model(:albums))
+ artist = @Artist = Class.new(Sequel::Model(:artists))
+ tag = @Tag = Class.new(Sequel::Model(:tags))
+ track = @Track = Class.new(Sequel::Model(:tracks))
+ album_info = @AlbumInfo = Class.new(Sequel::Model(:album_infos))
+ @Artist.columns :id, :id1, :id2
+ @Tag.columns :id, :tid1, :tid2
+ @Track.columns :id, :album_id, :album_id1, :album_id2
+ @AlbumInfo.columns :id, :album_id, :album_id1, :album_id2
+ @Album.class_eval do
+ columns :id, :id1, :id2, :artist_id, :artist_id1, :artist_id2
+ many_to_one :artist, :class=>artist
+ one_to_many :tracks, :class=>track, :key=>:album_id
+ one_to_one :album_info, :class=>album_info, :key=>:album_id
+ many_to_many :tags, :class=>tag, :left_key=>:album_id, :join_table=>:albums_tags
+
+ many_to_one :cartist, :class=>artist, :key=>[:artist_id1, :artist_id2], :primary_key=>[:id1, :id2]
+ one_to_many :ctracks, :class=>track, :key=>[:album_id1, :album_id2], :primary_key=>[:id1, :id2]
+ one_to_one :calbum_info, :class=>album_info, :key=>[:album_id1, :album_id2], :primary_key=>[:id1, :id2]
+ many_to_many :ctags, :class=>tag, :left_key=>[:album_id1, :album_id2], :left_primary_key=>[:id1, :id2], :right_key=>[:tag_id1, :tag_id2], :right_primary_key=>[:tid1, :tid2], :join_table=>:albums_tags
+ end
+ end
+
+ it "should be able to filter on many_to_one associations" do
+ @Album.filter(:artist=>@Artist.load(:id=>3)).sql.should == 'SELECT * FROM albums WHERE (artist_id = 3)'
+ end
+
+ it "should be able to filter on one_to_many associations" do
+ @Album.filter(:tracks=>@Track.load(:album_id=>3)).sql.should == 'SELECT * FROM albums WHERE (id = 3)'
+ end
+
+ it "should be able to filter on one_to_one associations" do
+ @Album.filter(:album_info=>@AlbumInfo.load(:album_id=>3)).sql.should == 'SELECT * FROM albums WHERE (id = 3)'
+ end
+
+ it "should be able to filter on many_to_many associations" do
+ @Album.filter(:tags=>@Tag.load(:id=>3)).sql.should == 'SELECT * FROM albums WHERE (id IN (SELECT album_id FROM albums_tags WHERE (tag_id = 3)))'
+ end
+
+ it "should be able to filter on many_to_one associations with composite keys" do
+ @Album.filter(:cartist=>@Artist.load(:id1=>3, :id2=>4)).sql.should == 'SELECT * FROM albums WHERE ((artist_id1 = 3) AND (artist_id2 = 4))'
+ end
+
+ it "should be able to filter on one_to_many associations with composite keys" do
+ @Album.filter(:ctracks=>@Track.load(:album_id1=>3, :album_id2=>4)).sql.should == 'SELECT * FROM albums WHERE ((id1 = 3) AND (id2 = 4))'
+ end
+
+ it "should be able to filter on one_to_one associations with composite keys" do
+ @Album.filter(:calbum_info=>@AlbumInfo.load(:album_id1=>3, :album_id2=>4)).sql.should == 'SELECT * FROM albums WHERE ((id1 = 3) AND (id2 = 4))'
+ end
+
+ it "should be able to filter on many_to_many associations with composite keys" do
+ @Album.filter(:ctags=>@Tag.load(:tid1=>3, :tid2=>4)).sql.should == 'SELECT * FROM albums WHERE ((id1, id2) IN (SELECT album_id1, album_id2 FROM albums_tags WHERE ((tag_id1 = 3) AND (tag_id2 = 4))))'
+ end
+
+ it "should work inside a complex filter" do
+ artist = @Artist.load(:id=>3)
+ @Album.filter{foo & {:artist=>artist}}.sql.should == 'SELECT * FROM albums WHERE (foo AND (artist_id = 3))'
+ track = @Track.load(:album_id=>4)
+ @Album.filter{foo & [[:artist, artist], [:tracks, track]]}.sql.should == 'SELECT * FROM albums WHERE (foo AND (artist_id = 3) AND (id = 4))'
+ end
+
+ it "should raise for an invalid association name" do
+ proc{@Album.filter(:foo=>@Artist.load(:id=>3)).sql}.should raise_error(Sequel::Error)
+ end
+
+ it "should raise for an invalid association type" do
+ @Album.plugin :many_through_many
+ @Album.many_through_many :mtmtags, [[:album_id, :album_tags, :tag_id]], :class=>@Tag
+ proc{@Album.filter(:mtmtags=>@Tag.load(:id=>3)).sql}.should raise_error(Sequel::Error)
+ end
+
+ it "should raise for an invalid associated object class " do
+ proc{@Album.filter(:tags=>@Artist.load(:id=>3)).sql}.should raise_error(Sequel::Error)
+ end
+
+ it "should work correctly in subclasses" do
+ c = Class.new(@Album)
+ c.many_to_one :sartist, :class=>@Artist
+ c.filter(:sartist=>@Artist.load(:id=>3)).sql.should == 'SELECT * FROM albums WHERE (sartist_id = 3)'
+ end
+end
View
@@ -14,7 +14,6 @@
<li><a href="rdoc-plugins/classes/Sequel/Plugins/Caching.html">caching</a>: Supports caching primary key lookups of model objects to any object that supports the Ruby-Memcache API.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/ClassTableInheritance.html">class_table_inheritance</a>: Supports inheritance in the database by using a single database table for each class in a class hierarchy.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/Composition.html">composition</a>: Supports defining getters/setters for objects with data backed by the model's columns.</li>
-<li><a href="rdoc-plugins/classes/Sequel/Plugins/FilterByAssociations.html">filter_by_associations</a>: Allows filtering by associations for model datasets.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/ForceEncoding.html">force_encoding</a>: Forces the all model column string values to a given encoding.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/HookClassMethods.html">hook_class_methods</a>: Adds backwards compatiblity for the legacy class-level hook methods (e.g. before_save :do_something).</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/IdentityMap.html">identity_map</a>: Allows you to create temporary identity maps which ensure a 1-1 correspondence of model objects to database rows.</li>

0 comments on commit 47f934e

Please sign in to comment.