Permalink
Browse files

Add filter_by_associations plugin that allows model datasets to filte…

…r using associations

See the plugin RDoc for a description of how to use this.
  • Loading branch information...
1 parent 84277d7 commit 302b35e7b3886e7113aab6ae747c1fec1d8291d4 @jeremyevans committed Apr 28, 2011
Showing with 167 additions and 0 deletions.
  1. +2 −0 CHANGELOG
  2. +77 −0 lib/sequel/plugins/filter_by_associations.rb
  3. +87 −0 spec/extensions/filter_by_associations_spec.rb
  4. +1 −0 www/pages/plugins
View
@@ -1,5 +1,7 @@
=== HEAD
+* Add filter_by_associations plugin that allows model datasets to filter using associations (jeremyevans)
+
* Don't call insert_select when saving a model that doesn't select all columns of the table (jeremyevans)
* Fix bug when using :select=>[] option for a many_to_many association (jeremyevans)
@@ -0,0 +1,77 @@
+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
@@ -0,0 +1,87 @@
+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
View
@@ -14,6 +14,7 @@
<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 302b35e

Please sign in to comment.