Skip to content

Commit

Permalink
Merge the filter_by_associations plugin into Sequel's default associa…
Browse files Browse the repository at this point in the history
…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 47f934e
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 166 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG
@@ -1,6 +1,6 @@
=== HEAD === 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) * Don't call insert_select when saving a model that doesn't select all columns of the table (jeremyevans)


Expand Down
54 changes: 54 additions & 0 deletions doc/association_basics.rdoc
Expand Up @@ -378,6 +378,60 @@ method, you have to pass a proc as an argument:


@artist.albums(proc{|ds| ds.filter(:name.like('A%'))}) @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 == Name Collisions


Because associations create instance methods, it's possible to override Because associations create instance methods, it's possible to override
Expand Down
34 changes: 34 additions & 0 deletions lib/sequel/model/associations.rb
Expand Up @@ -1408,6 +1408,40 @@ def eager_graph(*associations)
ds.eager_graph_associations(ds, model, ds.opts[:eager_graph][:master], [], *associations) ds.eager_graph_associations(ds, model, ds.opts[:eager_graph][:master], [], *associations)
end 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, # Do not attempt to split the result set into associations,
# just return results as simple objects. This is useful if you # 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 # want to use eager_graph as a shortcut to have all of the joins
Expand Down
77 changes: 0 additions & 77 deletions lib/sequel/plugins/filter_by_associations.rb

This file was deleted.

87 changes: 0 additions & 87 deletions spec/extensions/filter_by_associations_spec.rb

This file was deleted.

16 changes: 16 additions & 0 deletions spec/integration/associations_test.rb
Expand Up @@ -22,6 +22,22 @@
@tag.albums.should == [@album] @tag.albums.should == [@album]
end 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 specify "should have remove methods work" do
@album.update(:artist => @artist) @album.update(:artist => @artist)
@album.add_tag(@tag) @album.add_tag(@tag)
Expand Down
85 changes: 85 additions & 0 deletions spec/model/associations_spec.rb
Expand Up @@ -2756,3 +2756,88 @@ def self.to_s; 'Node'; end
c.instance_methods.map{|x| x.to_s}.should include('parent') c.instance_methods.map{|x| x.to_s}.should include('parent')
end end
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
1 change: 0 additions & 1 deletion www/pages/plugins
Expand Up @@ -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/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/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/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/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/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> <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>
Expand Down

0 comments on commit 47f934e

Please sign in to comment.