require File.join(File.dirname(__FILE__), 'spec_helper.rb') describe "Eagerly loading a tree structure" do before do INTEGRATION_DB.instance_variable_set(:@schemas, nil) INTEGRATION_DB.create_table!(:nodes) do primary_key :id foreign_key :parent_id, :nodes end class ::Node < Sequel::Model many_to_one :parent one_to_many :children, :key=>:parent_id # Only useful when eager loading many_to_one :ancestors, :eager_loader=>(proc do |key_hash, nodes, associations| # Handle cases where the root node has the same parent_id as primary_key # and also when it is NULL non_root_nodes = nodes.reject do |n| if [nil, n.pk].include?(n.parent_id) # Make sure root nodes have their parent association set to nil n.associations[:parent] = nil true else false end end unless non_root_nodes.empty? id_map = {} # Create an map of parent_ids to nodes that have that parent id non_root_nodes.each{|n| (id_map[n.parent_id] ||= []) << n} # Doesn't cause an infinte loop, because when only the root node # is left, this is not called. Node.filter(Node.primary_key=>id_map.keys.sort).eager(:ancestors).all do |node| # Populate the parent association for each node id_map[node.pk].each{|n| n.associations[:parent] = node} end end end) many_to_one :descendants, :eager_loader=>(proc do |key_hash, nodes, associations| id_map = {} nodes.each do |n| # Initialize an empty array of child associations for each parent node n.associations[:children] = [] # Populate identity map of nodes id_map[n.pk] = n end # Doesn't cause an infinite loop, because the :eager_loader is not called # if no records are returned. Exclude id = parent_id to avoid infinite loop # if the root note is one of the returned records and it has parent_id = id # instead of parent_id = NULL. Node.filter(:parent_id=>id_map.keys.sort).exclude(:id=>:parent_id).eager(:descendants).all do |node| # Get the parent from the identity map parent = id_map[node.parent_id] # Set the child's parent association to the parent node.associations[:parent] = parent # Add the child association to the array of children in the parent parent.associations[:children] << node end end) end Node.insert(:parent_id=>1) Node.insert(:parent_id=>1) Node.insert(:parent_id=>1) Node.insert(:parent_id=>2) Node.insert(:parent_id=>4) Node.insert(:parent_id=>5) Node.insert(:parent_id=>6) clear_sqls end after do Node.drop_table Object.send(:remove_const, :Node) end it "#descendants should get all descendants in one call" do nodes = Node.filter(:id=>1).eager(:descendants).all sqls_should_be('SELECT * FROM nodes WHERE (id = 1)', 'SELECT * FROM nodes WHERE ((parent_id IN (1)) AND (id != parent_id))', 'SELECT * FROM nodes WHERE ((parent_id IN (2, 3)) AND (id != parent_id))', 'SELECT * FROM nodes WHERE ((parent_id IN (4)) AND (id != parent_id))', 'SELECT * FROM nodes WHERE ((parent_id IN (5)) AND (id != parent_id))', 'SELECT * FROM nodes WHERE ((parent_id IN (6)) AND (id != parent_id))', 'SELECT * FROM nodes WHERE ((parent_id IN (7)) AND (id != parent_id))') nodes.length.should == 1 node = nodes.first node.pk.should == 1 node.children.length.should == 2 node.children.collect{|x| x.pk}.sort.should == [2, 3] node.children.collect{|x| x.parent}.should == [node, node] node = nodes.first.children.find{|x| x.pk == 2} node.children.length.should == 1 node.children.first.pk.should == 4 node.children.first.parent.should == node node = node.children.first node.children.length.should == 1 node.children.first.pk.should == 5 node.children.first.parent.should == node node = node.children.first node.children.length.should == 1 node.children.first.pk.should == 6 node.children.first.parent.should == node node = node.children.first node.children.length.should == 1 node.children.first.pk.should == 7 node.children.first.parent.should == node sqls_should_be end it "#ancestors should get all ancestors in one call" do nodes = Node.filter(:id=>[7,3]).order(:id).eager(:ancestors).all sqls_should_be('SELECT * FROM nodes WHERE (id IN (7, 3)) ORDER BY id', 'SELECT * FROM nodes WHERE (id IN (1, 6))', 'SELECT * FROM nodes WHERE (id IN (5))', 'SELECT * FROM nodes WHERE (id IN (4))', 'SELECT * FROM nodes WHERE (id IN (2))', 'SELECT * FROM nodes WHERE (id IN (1))') nodes.length.should == 2 nodes.collect{|x| x.pk}.should == [3, 7] nodes.first.parent.pk.should == 1 nodes.first.parent.parent.should == nil node = nodes.last node.parent.pk.should == 6 node = node.parent node.parent.pk.should == 5 node = node.parent node.parent.pk.should == 4 node = node.parent node.parent.pk.should == 2 node = node.parent node.parent.pk.should == 1 node.parent.parent.should == nil sqls_should_be end end describe "Association Extensions" do before do module ::FindOrCreate def find_or_create(vals) # Exploits the fact that Sequel filters are ruby objects that # can be introspected. author_id = @opts[:where].args[1] first(vals) || \ @opts[:models][nil].create(vals.merge(:author_id=>author_id)) end end INTEGRATION_DB.instance_variable_set(:@schemas, nil) INTEGRATION_DB.create_table!(:authors) do primary_key :id end class ::Author < Sequel::Model one_to_many :authorships, :extend=>FindOrCreate, :dataset=>(proc do key = pk ds = Authorship.filter(:author_id=>key) ds.meta_def(:find_or_create_by_name) do |name| first(:name=>name) || Authorship.create(:name=>name, :author_id=>key) end ds end) end INTEGRATION_DB.create_table!(:authorships) do primary_key :id foreign_key :author_id, :authors text :name end class ::Authorship < Sequel::Model many_to_one :author end @author = Author.create clear_sqls end after do Authorship.drop_table Author.drop_table Object.send(:remove_const, :Author) Object.send(:remove_const, :Authorship) end it "should allow methods to be called on the dataset method" do Authorship.count.should == 0 sqls_should_be('SELECT COUNT(*) FROM authorships LIMIT 1') authorship = @author.authorships_dataset.find_or_create_by_name('Bob') sqls_should_be("SELECT * FROM authorships WHERE ((author_id = 1) AND (name = 'Bob')) LIMIT 1", /INSERT INTO authorships \((author_id, name|name, author_id)\) VALUES \((1, 'Bob'|'Bob', 1)\)/, "SELECT * FROM authorships WHERE (id = 1) LIMIT 1") Authorship.count.should == 1 Authorship.first.should == authorship sqls_should_be('SELECT COUNT(*) FROM authorships LIMIT 1', "SELECT * FROM authorships LIMIT 1") authorship.name.should == 'Bob' authorship.author_id.should == @author.id @author.authorships_dataset.find_or_create_by_name('Bob').should == authorship sqls_should_be("SELECT * FROM authorships WHERE ((author_id = 1) AND (name = 'Bob')) LIMIT 1") Authorship.count.should == 1 sqls_should_be('SELECT COUNT(*) FROM authorships LIMIT 1') authorship2 = @author.authorships_dataset.find_or_create(:name=>'Jim') sqls_should_be("SELECT * FROM authorships WHERE ((author_id = 1) AND (name = 'Jim')) LIMIT 1", /INSERT INTO authorships \((author_id, name|name, author_id)\) VALUES \((1, 'Jim'|'Jim', 1)\)/, "SELECT * FROM authorships WHERE (id = 2) LIMIT 1") Authorship.count.should == 2 sqls_should_be('SELECT COUNT(*) FROM authorships LIMIT 1') Authorship.order(:name).map(:name).should == ['Bob', 'Jim'] sqls_should_be('SELECT * FROM authorships ORDER BY name') authorship2.name.should == 'Jim' authorship2.author_id.should == @author.id @author.authorships_dataset.find_or_create(:name=>'Jim').should == authorship2 sqls_should_be("SELECT * FROM authorships WHERE ((author_id = 1) AND (name = 'Jim')) LIMIT 1") end end describe "has_many :through has_many and has_one :through belongs_to" do before do INTEGRATION_DB.instance_variable_set(:@schemas, nil) INTEGRATION_DB.create_table!(:firms) do primary_key :id end class ::Firm < Sequel::Model one_to_many :clients one_to_many :invoices, :read_only=>true, \ :dataset=>proc{Invoice.eager_graph(:client).filter(:client__firm_id=>pk)}, \ :after_load=>(proc do |firm, invs| invs.each do |inv| inv.client.associations[:firm] = inv.associations[:firm] = firm end end), \ :eager_loader=>(proc do |key_hash, firms, associations| id_map = key_hash[Firm.primary_key] firms.each{|firm| firm.associations[:invoices] = []} Invoice.eager_graph(:client).filter(:client__firm_id=>id_map.keys).all do |inv| id_map[inv.client.firm_id].each do |firm| firm.associations[:invoices] << inv end end end) end INTEGRATION_DB.create_table!(:clients) do primary_key :id foreign_key :firm_id, :firms end class ::Client < Sequel::Model many_to_one :firm one_to_many :invoices end INTEGRATION_DB.create_table!(:invoices) do primary_key :id foreign_key :client_id, :clients end class ::Invoice < Sequel::Model many_to_one :client many_to_one :firm, :key=>nil, :read_only=>true, \ :dataset=>proc{Firm.eager_graph(:clients).filter(:clients__id=>client_id)}, \ :after_load=>(proc do |inv, firm| # Delete the cached associations from firm, because it only has the # client with this invoice, instead of all clients of the firm if c = firm.associations.delete(:clients) firm.associations[:invoice_client] = c.first end inv.associations[:client] ||= firm.associations[:invoice_client] end), \ :eager_loader=>(proc do |key_hash, invoices, associations| id_map = {} invoices.each do |inv| inv.associations[:firm] = nil (id_map[inv.client_id] ||= []) << inv end Firm.eager_graph(:clients).filter(:clients__id=>id_map.keys).all do |firm| # Delete the cached associations from firm, because it only has the # clients related the invoices being eagerly loaded, instead of all # clients of the firm. firm.associations[:clients].each do |client| id_map[client.pk].each do |inv| inv.associations[:firm] = firm inv.associations[:client] = client end end end end) end @firm1 = Firm.create @firm2 = Firm.create @client1 = Client.create(:firm => @firm1) @client2 = Client.create(:firm => @firm1) @client3 = Client.create(:firm => @firm2) @invoice1 = Invoice.create(:client => @client1) @invoice2 = Invoice.create(:client => @client1) @invoice3 = Invoice.create(:client => @client2) @invoice4 = Invoice.create(:client => @client3) @invoice5 = Invoice.create(:client => @client3) clear_sqls end after do Invoice.drop_table Client.drop_table Firm.drop_table Object.send(:remove_const, :Firm) Object.send(:remove_const, :Client) Object.send(:remove_const, :Invoice) end it "should return has_many :through has_many records for a single object" do invs = @firm1.invoices.sort_by{|x| x.pk} sqls_should_be("SELECT invoices.id, invoices.client_id, client.id AS 'client_id_0', client.firm_id FROM invoices LEFT OUTER JOIN clients AS 'client' ON (client.id = invoices.client_id) WHERE (client.firm_id = 1)") invs.should == [@invoice1, @invoice2, @invoice3] invs[0].client.should == @client1 invs[1].client.should == @client1 invs[2].client.should == @client2 invs.collect{|i| i.firm}.should == [@firm1, @firm1, @firm1] invs.collect{|i| i.client.firm}.should == [@firm1, @firm1, @firm1] sqls_should_be end it "should eagerly load has_many :through has_many records for multiple objects" do firms = Firm.order(:id).eager(:invoices).all sqls_should_be("SELECT * FROM firms ORDER BY id", "SELECT invoices.id, invoices.client_id, client.id AS 'client_id_0', client.firm_id FROM invoices LEFT OUTER JOIN clients AS 'client' ON (client.id = invoices.client_id) WHERE (client.firm_id IN (1, 2))") firms.should == [@firm1, @firm2] firm1, firm2 = firms invs1 = firm1.invoices.sort_by{|x| x.pk} invs2 = firm2.invoices.sort_by{|x| x.pk} invs1.should == [@invoice1, @invoice2, @invoice3] invs2.should == [@invoice4, @invoice5] invs1[0].client.should == @client1 invs1[1].client.should == @client1 invs1[2].client.should == @client2 invs2[0].client.should == @client3 invs2[1].client.should == @client3 invs1.collect{|i| i.firm}.should == [@firm1, @firm1, @firm1] invs2.collect{|i| i.firm}.should == [@firm2, @firm2] invs1.collect{|i| i.client.firm}.should == [@firm1, @firm1, @firm1] invs2.collect{|i| i.client.firm}.should == [@firm2, @firm2] sqls_should_be end it "should return has_one :through belongs_to records for a single object" do firm = @invoice1.firm sqls_should_be("SELECT firms.id, clients.id AS 'clients_id', clients.firm_id FROM firms LEFT OUTER JOIN clients ON (clients.firm_id = firms.id) WHERE (clients.id = 1)") firm.should == @firm1 @invoice1.client.should == @client1 @invoice1.client.firm.should == @firm1 sqls_should_be firm.associations[:clients].should == nil end it "should eagerly load has_one :through belongs_to records for multiple objects" do invs = Invoice.order(:id).eager(:firm).all sqls_should_be("SELECT * FROM invoices ORDER BY id", "SELECT firms.id, clients.id AS 'clients_id', clients.firm_id FROM firms LEFT OUTER JOIN clients ON (clients.firm_id = firms.id) WHERE (clients.id IN (1, 2, 3))") invs.should == [@invoice1, @invoice2, @invoice3, @invoice4, @invoice5] invs[0].firm.should == @firm1 invs[0].client.should == @client1 invs[0].client.firm.should == @firm1 invs[0].firm.associations[:clients].should == nil invs[1].firm.should == @firm1 invs[1].client.should == @client1 invs[1].client.firm.should == @firm1 invs[1].firm.associations[:clients].should == nil invs[2].firm.should == @firm1 invs[2].client.should == @client2 invs[2].client.firm.should == @firm1 invs[2].firm.associations[:clients].should == nil invs[3].firm.should == @firm2 invs[3].client.should == @client3 invs[3].client.firm.should == @firm2 invs[3].firm.associations[:clients].should == nil invs[4].firm.should == @firm2 invs[4].client.should == @client3 invs[4].client.firm.should == @firm2 invs[4].firm.associations[:clients].should == nil sqls_should_be end end describe "Polymorphic Associations" do before do INTEGRATION_DB.instance_variable_set(:@schemas, nil) INTEGRATION_DB.create_table!(:assets) do primary_key :id integer :attachable_id text :attachable_type end class ::Asset < Sequel::Model many_to_one :attachable, :reciprocal=>:assets, \ :dataset=>(proc do klass = attachable_type.constantize klass.filter(klass.primary_key=>attachable_id) end), \ :eager_loader=>(proc do |key_hash, assets, associations| id_map = {} assets.each do |asset| asset.associations[:attachable] = nil ((id_map[asset.attachable_type] ||= {})[asset.attachable_id] ||= []) << asset end id_map.each do |klass_name, id_map| klass = klass_name.constantize klass.filter(klass.primary_key=>id_map.keys).all do |attach| id_map[attach.pk].each do |asset| asset.associations[:attachable] = attach end end end end) private def _attachable=(attachable) self[:attachable_id] = (attachable.pk if attachable) self[:attachable_type] = (attachable.class.name if attachable) end end INTEGRATION_DB.create_table!(:posts) do primary_key :id end class ::Post < Sequel::Model one_to_many :assets, :key=>:attachable_id do |ds| ds.filter(:attachable_type=>'Post') end private def _add_asset(asset) asset.attachable_id = pk asset.attachable_type = 'Post' asset.save end def _remove_asset(asset) asset.attachable_id = nil asset.attachable_type = nil asset.save end def _remove_all_assets Asset.filter(:attachable_id=>pk, :attachable_type=>'Post')\ .update(:attachable_id=>nil, :attachable_type=>nil) end end INTEGRATION_DB.create_table!(:notes) do primary_key :id end class ::Note < Sequel::Model one_to_many :assets, :key=>:attachable_id do |ds| ds.filter(:attachable_type=>'Note') end private def _add_asset(asset) asset.attachable_id = pk asset.attachable_type = 'Note' asset.save end def _remove_asset(asset) asset.attachable_id = nil asset.attachable_type = nil asset.save end def _remove_all_assets Asset.filter(:attachable_id=>pk, :attachable_type=>'Note')\ .update(:attachable_id=>nil, :attachable_type=>nil) end end @post = Post.create Note.create @note = Note.create @asset1 = Asset.create(:attachable=>@post) @asset2 = Asset.create(:attachable=>@note) @asset1.associations.clear @asset2.associations.clear clear_sqls end after do Asset.drop_table Post.drop_table Note.drop_table Object.send(:remove_const, :Asset) Object.send(:remove_const, :Post) Object.send(:remove_const, :Note) end it "should load the correct associated object for a single object" do @asset1.attachable.should == @post @asset2.attachable.should == @note sqls_should_be("SELECT * FROM posts WHERE (id = 1) LIMIT 1", "SELECT * FROM notes WHERE (id = 2) LIMIT 1") end it "should eagerly load the correct associated object for a group of objects" do assets = Asset.order(:id).eager(:attachable).all sqls_should_be("SELECT * FROM assets ORDER BY id", "SELECT * FROM posts WHERE (id IN (1))", "SELECT * FROM notes WHERE (id IN (2))") assets.should == [@asset1, @asset2] assets[0].attachable.should == @post assets[1].attachable.should == @note sqls_should_be end it "should set items correctly" do @asset1.attachable = @note @asset2.attachable = @post sqls_should_be("SELECT * FROM posts WHERE (id = 1) LIMIT 1", "SELECT * FROM notes WHERE (id = 2) LIMIT 1") @asset1.attachable.should == @note @asset1.attachable_id.should == @note.pk @asset1.attachable_type.should == 'Note' @asset2.attachable.should == @post @asset2.attachable_id.should == @post.pk @asset2.attachable_type.should == 'Post' @asset1.attachable = nil @asset1.attachable.should == nil @asset1.attachable_id.should == nil @asset1.attachable_type.should == nil sqls_should_be end it "should add items correctly" do @post.assets.should == [@asset1] sqls_should_be("SELECT * FROM assets WHERE ((assets.attachable_id = 1) AND (attachable_type = 'Post'))") @post.add_asset(@asset2) sqls_should_be(/UPDATE assets SET ((attachable_id = 1|attachable_type = 'Post'|id = 2)(, )?){3} WHERE \(id = 2\)/) @post.assets.should == [@asset1, @asset2] @asset2.attachable.should == @post @asset2.attachable_id.should == @post.pk @asset2.attachable_type.should == 'Post' sqls_should_be end it "should remove items correctly" do @note.assets.should == [@asset2] sqls_should_be("SELECT * FROM assets WHERE ((assets.attachable_id = 2) AND (attachable_type = 'Note'))") @note.remove_asset(@asset2) sqls_should_be(/UPDATE assets SET ((attachable_id = NULL|attachable_type = NULL|id = 2)(, )?){3} WHERE \(id = 2\)/) @note.assets.should == [] @asset2.attachable.should == nil @asset2.attachable_id.should == nil @asset2.attachable_type.should == nil sqls_should_be end it "should remove all items correctly" do @post.remove_all_assets @note.remove_all_assets sqls_should_be(/UPDATE assets SET attachable_(id|type) = NULL, attachable_(type|id) = NULL WHERE \(\(attachable_(id|type) = (1|'Post')\) AND \(attachable_(type|id) = ('Post'|1)\)\)/, /UPDATE assets SET attachable_(id|type) = NULL, attachable_(type|id) = NULL WHERE \(\(attachable_(id|type) = (2|'Note')\) AND \(attachable_(type|id) = ('Note'|2)\)\)/) @asset1.reload.attachable.should == nil @asset2.reload.attachable.should == nil end end describe "many_to_one/one_to_many not referencing primary key" do before do INTEGRATION_DB.instance_variable_set(:@schemas, nil) INTEGRATION_DB.create_table!(:clients) do primary_key :id text :name end class ::Client < Sequel::Model one_to_many :invoices, :reciprocal=>:client, \ :dataset=>proc{Invoice.filter(:client_name=>name)}, \ :eager_loader=>(proc do |key_hash, clients, associations| id_map = {} clients.each do |client| id_map[client.name] = client client.associations[:invoices] = [] end Invoice.filter(:client_name=>id_map.keys.sort).all do |inv| inv.associations[:client] = client = id_map[inv.client_name] client.associations[:invoices] << inv end end) private def _add_invoice(invoice) invoice.client_name = name invoice.save end def _remove_invoice(invoice) invoice.client_name = nil invoice.save end def _remove_all_invoices Invoice.filter(:client_name=>name).update(:client_name=>nil) end end INTEGRATION_DB.create_table!(:invoices) do primary_key :id text :client_name end class ::Invoice < Sequel::Model many_to_one :client, :key=>:client_name, \ :dataset=>proc{Client.filter(:name=>client_name)}, \ :eager_loader=>(proc do |key_hash, invoices, associations| id_map = key_hash[:client_name] invoices.each{|inv| inv.associations[:client] = nil} Client.filter(:name=>id_map.keys).all do |client| id_map[client.name].each{|inv| inv.associations[:client] = client} end end) private def _client=(client) self.client_name = (client.name if client) end end @client1 = Client.create(:name=>'X') @client2 = Client.create(:name=>'Y') @invoice1 = Invoice.create(:client_name=>'X') @invoice2 = Invoice.create(:client_name=>'X') clear_sqls end after do Invoice.drop_table Client.drop_table Object.send(:remove_const, :Client) Object.send(:remove_const, :Invoice) end it "should load all associated one_to_many objects for a single object" do invs = @client1.invoices sqls_should_be("SELECT * FROM invoices WHERE (client_name = 'X')") invs.should == [@invoice1, @invoice2] invs[0].client.should == @client1 invs[1].client.should == @client1 sqls_should_be end it "should load the associated many_to_one object for a single object" do client = @invoice1.client sqls_should_be("SELECT * FROM clients WHERE (name = 'X') LIMIT 1") client.should == @client1 end it "should eagerly load all associated one_to_many objects for a group of objects" do clients = Client.order(:id).eager(:invoices).all sqls_should_be("SELECT * FROM clients ORDER BY id", "SELECT * FROM invoices WHERE (client_name IN ('X', 'Y'))") clients.should == [@client1, @client2] clients[1].invoices.should == [] invs = clients[0].invoices.sort_by{|x| x.pk} invs.should == [@invoice1, @invoice2] invs[0].client.should == @client1 invs[1].client.should == @client1 sqls_should_be end it "should eagerly load the associated many_to_one object for a group of objects" do invoices = Invoice.order(:id).eager(:client).all sqls_should_be("SELECT * FROM invoices ORDER BY id", "SELECT * FROM clients WHERE (name IN ('X'))") invoices.should == [@invoice1, @invoice2] invoices[0].client.should == @client1 invoices[1].client.should == @client1 sqls_should_be end it "should set the associated object correctly" do @invoice1.client = @client2 sqls_should_be("SELECT * FROM clients WHERE (name = 'X') LIMIT 1") @invoice1.client.should == @client2 @invoice1.client_name.should == 'Y' @invoice1.client = nil @invoice1.client_name.should == nil sqls_should_be end it "should add the associated object correctly" do @client2.invoices.should == [] sqls_should_be("SELECT * FROM invoices WHERE (client_name = 'Y')") @client2.add_invoice(@invoice1) sqls_should_be(/UPDATE invoices SET (client_name = 'Y'|id = 1), (client_name = 'Y'|id = 1) WHERE \(id = 1\)/) @client2.invoices.should == [@invoice1] @invoice1.client_name.should == 'Y' @invoice1.client = nil @invoice1.client_name.should == nil sqls_should_be end it "should remove the associated object correctly" do invs = @client1.invoices.sort_by{|x| x.pk} sqls_should_be("SELECT * FROM invoices WHERE (client_name = 'X')") invs.should == [@invoice1, @invoice2] @client1.remove_invoice(@invoice1) sqls_should_be(/UPDATE invoices SET (client_name = NULL|id = 1), (client_name = NULL|id = 1) WHERE \(id = 1\)/) @client1.invoices.should == [@invoice2] @invoice1.client_name.should == nil @invoice1.client.should == nil sqls_should_be end it "should remove all associated objects correctly" do invs = @client1.remove_all_invoices sqls_should_be("UPDATE invoices SET client_name = NULL WHERE (client_name = 'X')") @invoice1.refresh.client.should == nil @invoice1.client_name.should == nil @invoice2.refresh.client.should == nil @invoice2.client_name.should == nil end end