require File.join(File.dirname(__FILE__), 'spec_helper.rb') describe "Eagerly loading a tree structure" do before do INTEGRATION_DB.instance_variable_set(:@schemas, {}) 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 INTEGRATION_DB.drop_table :nodes Object.send(:remove_const, :Node) end it "#descendants should get all descendants in one call" do nodes = Node.filter(:id=>1).eager(:descendants).all 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 end it "#ancestors should get all ancestors in one call" do nodes = Node.filter(:id=>[7,3]).order(:id).eager(:ancestors).all 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 end end describe "Association Extensions" do before do module ::FindOrCreate def find_or_create(vals) first(vals) || model.create(vals.merge(:author_id=>model_object.pk)) end def find_or_create_by_name(name) first(:name=>name) || model.create(:name=>name, :author_id=>model_object.pk) end end INTEGRATION_DB.instance_variable_set(:@schemas, {}) INTEGRATION_DB.create_table!(:authors) do primary_key :id end class ::Author < Sequel::Model one_to_many :authorships, :extend=>FindOrCreate end INTEGRATION_DB.create_table!(:authorships) do primary_key :id foreign_key :author_id, :authors String :name end class ::Authorship < Sequel::Model many_to_one :author end @author = Author.create clear_sqls end after do INTEGRATION_DB.drop_table :authorships, :authors 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 authorship = @author.authorships_dataset.find_or_create_by_name('Bob') Authorship.count.should == 1 Authorship.first.should == authorship authorship.name.should == 'Bob' authorship.author_id.should == @author.id @author.authorships_dataset.find_or_create_by_name('Bob').should == authorship Authorship.count.should == 1 authorship2 = @author.authorships_dataset.find_or_create(:name=>'Jim') Authorship.count.should == 2 Authorship.order(:name).map(:name).should == ['Bob', 'Jim'] authorship2.name.should == 'Jim' authorship2.author_id.should == @author.id @author.authorships_dataset.find_or_create(:name=>'Jim').should == authorship2 end end describe "has_many :through has_many and has_one :through belongs_to" do before do INTEGRATION_DB.instance_variable_set(:@schemas, {}) 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 INTEGRATION_DB.drop_table :invoices, :clients, :firms 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} 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] end it "should eagerly load has_many :through has_many records for multiple objects" do firms = Firm.order(:id).eager(:invoices).all 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] end it "should return has_one :through belongs_to records for a single object" do firm = @invoice1.firm firm.should == @firm1 @invoice1.client.should == @client1 @invoice1.client.firm.should == @firm1 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 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 end end describe "Polymorphic Associations" do before do INTEGRATION_DB.instance_variable_set(:@schemas, {}) INTEGRATION_DB.create_table!(:assets) do primary_key :id Integer :attachable_id String :attachable_type end class ::Asset < Sequel::Model m = method(:constantize) many_to_one :attachable, :reciprocal=>:assets, \ :dataset=>(proc do klass = m.call(attachable_type) 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 = m.call(klass_name) 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, :reciprocal=>:attachable 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, :reciprocal=>:attachable 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 INTEGRATION_DB.drop_table :assets, :posts, :notes 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 end it "should eagerly load the correct associated object for a group of objects" do assets = Asset.order(:id).eager(:attachable).all assets.should == [@asset1, @asset2] assets[0].attachable.should == @post assets[1].attachable.should == @note end it "should set items correctly" do @asset1.attachable = @note @asset2.attachable = @post @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 end it "should add items correctly" do @post.assets.should == [@asset1] @post.add_asset(@asset2) @post.assets.should == [@asset1, @asset2] @asset2.attachable.should == @post @asset2.attachable_id.should == @post.pk @asset2.attachable_type.should == 'Post' end it "should remove items correctly" do @note.assets.should == [@asset2] @note.remove_asset(@asset2) @note.assets.should == [] @asset2.attachable.should == nil @asset2.attachable_id.should == nil @asset2.attachable_type.should == nil end it "should remove all items correctly" do @post.remove_all_assets @note.remove_all_assets @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, {}) INTEGRATION_DB.create_table!(:clients) do primary_key :id String :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 String :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 INTEGRATION_DB.drop_table :invoices, :clients 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 invs.should == [@invoice1, @invoice2] invs[0].client.should == @client1 invs[1].client.should == @client1 end it "should load the associated many_to_one object for a single object" do client = @invoice1.client 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 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 end it "should eagerly load the associated many_to_one object for a group of objects" do invoices = Invoice.order(:id).eager(:client).all invoices.should == [@invoice1, @invoice2] invoices[0].client.should == @client1 invoices[1].client.should == @client1 end it "should set the associated object correctly" do @invoice1.client = @client2 @invoice1.client.should == @client2 @invoice1.client_name.should == 'Y' @invoice1.client = nil @invoice1.client_name.should == nil end it "should add the associated object correctly" do @client2.invoices.should == [] @client2.add_invoice(@invoice1) @client2.invoices.should == [@invoice1] @invoice1.client_name.should == 'Y' @invoice1.client = nil @invoice1.client_name.should == nil end it "should remove the associated object correctly" do invs = @client1.invoices.sort_by{|x| x.pk} invs.should == [@invoice1, @invoice2] @client1.remove_invoice(@invoice1) @client1.invoices.should == [@invoice2] @invoice1.client_name.should == nil @invoice1.client.should == nil end it "should remove all associated objects correctly" do invs = @client1.remove_all_invoices @invoice1.refresh.client.should == nil @invoice1.client_name.should == nil @invoice2.refresh.client.should == nil @invoice2.client_name.should == nil end end describe "statistics associations" do before do INTEGRATION_DB.create_table!(:projects) do primary_key :id String :name end class ::Project < Sequel::Model many_to_one :ticket_hours, :read_only=>true, :key=>:id, :class=>:Ticket, :dataset=>proc{Ticket.filter(:project_id=>id).select{sum(hours).as(hours)}}, :eager_loader=>(proc do |kh, projects, a| projects.each{|p| p.associations[:ticket_hours] = nil} Ticket.filter(:project_id=>kh[:id].keys). group(:project_id). select{[project_id.as(project_id), sum(hours).as(hours)]}. all do |t| p = kh[:id][t.values.delete(:project_id)].first p.associations[:ticket_hours] = t end end) def ticket_hours if s = super s[:hours] end end end INTEGRATION_DB.create_table!(:tickets) do primary_key :id foreign_key :project_id, :projects Integer :hours end class ::Ticket < Sequel::Model many_to_one :project end @project1 = Project.create(:name=>'X') @project2 = Project.create(:name=>'Y') @ticket1 = Ticket.create(:project=>@project1, :hours=>1) @ticket2 = Ticket.create(:project=>@project1, :hours=>10) @ticket3 = Ticket.create(:project=>@project2, :hours=>2) @ticket4 = Ticket.create(:project=>@project2, :hours=>20) clear_sqls end after do INTEGRATION_DB.drop_table :tickets, :projects Object.send(:remove_const, :Project) Object.send(:remove_const, :Ticket) end it "should give the correct sum of ticket hours for each project" do @project1.ticket_hours.to_i.should == 11 @project2.ticket_hours.to_i.should == 22 end it "should give the correct sum of ticket hours for each project when eager loading" do p1, p2 = Project.order(:name).eager(:ticket_hours).all p1.ticket_hours.to_i.should == 11 p2.ticket_hours.to_i.should == 22 end end describe "one to one associations" do before do INTEGRATION_DB.create_table!(:books) do primary_key :id end class ::Book < Sequel::Model one_to_one :first_page, :class=>:Page, :conditions=>{:page_number=>1} one_to_one :second_page, :class=>:Page, :conditions=>{:page_number=>2} end INTEGRATION_DB.create_table!(:pages) do primary_key :id foreign_key :book_id, :books Integer :page_number end class ::Page < Sequel::Model many_to_one :book end @book1 = Book.create @book2 = Book.create @page1 = Page.create(:book=>@book1, :page_number=>1) @page2 = Page.create(:book=>@book1, :page_number=>2) @page3 = Page.create(:book=>@book2, :page_number=>1) @page4 = Page.create(:book=>@book2, :page_number=>2) clear_sqls end after do INTEGRATION_DB.drop_table :pages, :books Object.send(:remove_const, :Book) Object.send(:remove_const, :Page) end it "should be eager loadable" do bk1, bk2 = Book.filter(:books__id=>[1,2]).eager(:first_page).all bk1.first_page.should == @page1 bk2.first_page.should == @page3 end it "should be eager graphable" do bk1, bk2 = Book.filter(:books__id=>[1,2]).eager_graph(:first_page).all bk1.first_page.should == @page1 bk2.first_page.should == @page3 end it "should be eager graphable two at once" do bk1, bk2 = Book.filter(:books__id=>[1,2]).eager_graph(:first_page, :second_page).all bk1.first_page.should == @page1 bk1.second_page.should == @page2 bk2.first_page.should == @page3 bk2.second_page.should == @page4 end end