diff --git a/lib/generators/active_record/templates/migration.rb b/lib/generators/active_record/templates/migration.rb index 2f3bed6..390e83c 100644 --- a/lib/generators/active_record/templates/migration.rb +++ b/lib/generators/active_record/templates/migration.rb @@ -3,6 +3,7 @@ class AddResortFieldsTo<%= table_name.camelize %> < ActiveRecord::Migration # Adds Resort fields, next_id and first, and indexes to a given model def self.up add_column :<%= table_name %>, :next_id, :integer + add_column :<%= table_name %>, :sort, :integer add_column :<%= table_name %>, :first, :boolean add_index :<%= table_name %>, :next_id add_index :<%= table_name %>, :first @@ -11,6 +12,7 @@ def self.up # Removes Resort fields def self.down remove_column :<%= table_name %>, :next_id + remove_column :<%= table_name %>, :sort remove_column :<%= table_name %>, :first end end diff --git a/lib/resort.rb b/lib/resort.rb index 9a8518c..1815820 100644 --- a/lib/resort.rb +++ b/lib/resort.rb @@ -62,7 +62,7 @@ def included(base) base.send :include, InstanceMethods base.has_one :previous, class_name: base.name, foreign_key: 'next_id', inverse_of: :next - base.belongs_to :next, class_name: base.name, inverse_of: :previous + base.belongs_to :next, class_name: base.name, inverse_of: :previous, optional: true base.after_create :include_in_list! base.after_destroy :delete_from_list @@ -85,11 +85,17 @@ def last_in_order all.where(next_id: nil).first end + # Returns chain in order. + # @return ActiveRecord::Relation the ordered elements + def ordered + all.except(:order).order(:sort) + end + # Returns eager-loaded Components in order. # # OPTIMIZE: Use IdentityMap when available # @return [Array] the ordered elements - def ordered + def eager_ordered ordered_elements = [] elements = {} @@ -135,6 +141,17 @@ def siblings self.class.all end + # Regenerate the sort based on the eager ordered list + def regenerate_sort! + transaction do + elements = siblings.eager_ordered + elements.each { |element| element.lock! } + elements.each_with_index do |element, index| + raise(ActiveRecord::RecordNotSaved) unless element.update_attributes(sort: index + 1) + end + end + end + # Includes the object in the linked list. # # If there are no other objects, it prepends the object so that it is @@ -156,10 +173,11 @@ def prepend if _siblings.count > 0 delete_from_list old_first = _siblings.first_in_order - raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set next_id from previous first element." unless update_attribute(:next_id, old_first.id) - raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't reset previous first element" unless old_first.update_attribute(:first, false) + raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set next_id from previous first element." unless update_attributes(next_id: old_first.id) + raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't reset previous first element" unless old_first.update_attributes(first: false) end - raise(ActiveRecord::RecordNotSaved) unless update_attribute(:first, true) + raise(ActiveRecord::RecordNotSaved) unless update_attributes(first: true, sort: 1) + _increase_next_element_sort(self) if siblings.count > 0 end end @@ -177,13 +195,24 @@ def append_to(another) lock! return if another.next_id == id another.lock! + old_sort = sort delete_from_list if next_id || (another && another.next_id) - raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't append element" unless update_attribute(:next_id, another.next_id) + raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't append element" unless update_attributes(next_id: another.next_id) end if another - raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set this element to another's next" unless another.update_attribute(:next_id, id) + raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set this element to another's next" unless another.update_attributes(next_id: id) end + new_sort = + if old_sort.nil? + another.sort + 1 + elsif old_sort < another.sort + another.sort - 1 + elsif old_sort > another.sort + another.sort + end + raise ActiveRecord::RecordNotSaved unless update_attributes(sort: new_sort) + _increase_next_element_sort(another) end end @@ -194,7 +223,8 @@ def last? def last! self.class.transaction do lock! - raise(ActiveRecord::RecordNotSaved) unless _siblings.last_in_order.update_attribute(:next_id, id) + raise(ActiveRecord::RecordNotSaved) unless _siblings.last_in_order.update_attributes(next_id: id) + raise(ActiveRecord::RecordNotSaved) unless update_attributes(sort: _siblings.count + 1) end end @@ -203,16 +233,19 @@ def last! def delete_from_list if first? && self.next self.next.lock! - raise(ActiveRecord::RecordNotSaved) unless self.next.update_attribute(:first, true) + raise(ActiveRecord::RecordNotSaved) unless self.next.update_attributes(first: true) + _decrease_next_element_sort(self) elsif previous previous.lock! p = previous self.previous = nil unless frozen? - raise(ActiveRecord::RecordNotSaved) unless p.update_column(:next_id, next_id) + raise(ActiveRecord::RecordNotSaved) unless p.update_attributes(next_id: next_id) + _decrease_next_element_sort(p) if p.next end unless frozen? self.first = false self.next = nil + self.sort = nil save! end end @@ -221,6 +254,31 @@ def _siblings table = self.class.arel_table siblings.where(table[:id].not_eq(id)) end + + def _increase_previous_element_sort(element, sort_limit = nil) + _change_element_sort(element, :+, :previous, sort_limit, :<) + end + + def _decrease_previous_element_sort(element, sort_limit = nil) + _change_element_sort(element, :-, :previous, sort_limit, :<) + end + + def _increase_next_element_sort(element, sort_limit = nil) + _change_element_sort(element, :+, :next, sort_limit, :>) + end + + def _decrease_next_element_sort(element, sort_limit = nil) + _change_element_sort(element, :-, :next, sort_limit, :>) + end + + def _change_element_sort(element, operation_method, sibling_method, sort_limit = nil, sort_operator = nil) + loop do + element = element.public_send(sibling_method) + break if element.nil? or (sort_limit and element.sort.send(sort_operator, sort_limit)) + element.lock! + raise(ActiveRecord::RecordNotSaved) unless element.update_attributes(sort: element.sort.public_send(operation_method, 1)) + end + end end end # Helper class methods to be injected into ActiveRecord::Base class. diff --git a/spec/resort_spec.rb b/spec/resort_spec.rb index 42539df..7ab0584 100644 --- a/spec/resort_spec.rb +++ b/spec/resort_spec.rb @@ -98,6 +98,19 @@ module Resort it 'raises when ordering without scope' do expect do ListItem.ordered + end.to_not raise_error(NoMethodError) + end + end + + describe '#eager_ordered' do + it 'returns all elements ordered' do + expect(OrderedList.find_by_name('My list').items.eager_ordered.map(&:name)).to eq ['My list item 0', 'My list item 1', 'My list item 2', 'My list item 3'] + expect(OrderedList.find_by_name('My other list').items.eager_ordered.map(&:name)).to eq ['My other list item 0', 'My other list item 1', 'My other list item 2', 'My other list item 3'] + end + + it 'raises when ordering without scope' do + expect do + ListItem.eager_ordered end.to raise_error(NoMethodError) end end @@ -116,6 +129,7 @@ module Resort expect(article).to be_first expect(article.next).to be_nil expect(article.previous).to be_nil + expect(article.sort).to eq(1) end end context 'otherwise' do @@ -129,8 +143,10 @@ module Resort expect(article).to be_last expect(article.next_id).to be_nil expect(article.previous.name).to eq '1' + expect(article.sort).to eq 2 expect(first.next_id).to eq(article.id) + expect(first.sort).to eq 1 end end after do @@ -147,6 +163,7 @@ module Resort expect(item).to be_first expect(item.next).to be_nil expect(item.previous).to be_nil + expect(item.sort).to eq 1 end end context 'otherwise' do @@ -162,8 +179,10 @@ module Resort expect(last).to be_last expect(last.next_id).to be_nil expect(last.previous.name).to eq '1' + expect(last.sort).to eq 2 expect(first.next_id).to eq(last.id) + expect(first.sort).to eq(1) end it 'prepends the last element' do @@ -178,8 +197,11 @@ module Resort third = ListItem.where(name: 'Third', ordered_list_id: one_list).first expect(first).to_not be_first + expect(first.sort).to eq 2 expect(second).to_not be_first + expect(second.sort).to eq 3 expect(third).to be_first + expect(third.sort).to eq 1 expect(third.next.name).to eq 'First' expect(first.next.name).to eq 'Second' expect(second.next).to be_nil @@ -196,16 +218,21 @@ module Resort context 'when the element is the first' do it 'removes the element' do article = Article.create(name: 'first!') - article2 = Article.create(name: 'second!') + Article.create(name: 'second!') Article.create(name: 'last!') article = Article.find_by_name('first!') article.destroy article2 = Article.find_by_name('second!') + article3 = Article.find_by_name('last!') expect(article2).to be_first expect(article2.previous).to be_nil + expect(article2.sort).to eq 1 + expect(article3).to be_last + expect(article3.previous).to eq(article2) + expect(article3.sort).to eq 2 end end context 'when the element is in the middle' do @@ -223,19 +250,23 @@ module Resort article3 = Article.find_by_name('last!') expect(article).to be_first + expect(article.sort).to eq 1 expect(article.next.name).to eq 'last!' expect(article3.previous.name).to eq 'first!' + expect(article3.sort).to eq 2 end end context 'when the element is last' do it 'removes the element' do - Article.create(name: 'first!') + article = Article.create(name: 'first!') article2 = Article.create(name: 'second!') article3 = Article.create(name: 'last!') article3.destroy + expect(article.sort).to eq 1 expect(article2.next).to be_nil + expect(article2.sort).to eq 2 end end after do @@ -264,13 +295,21 @@ module Resort article1 = Article.find_by_name('1') expect(article1.previous).to eq @article4 expect(article1.next).to be_nil + expect(article1.sort).to eq 4 + + expect(@article4.reload.sort).to eq 3 + expect(@article3.reload.sort).to eq 2 + expect(@article2.reload.sort).to eq 1 end context 'when the article is already last' do it 'does nothing' do @article4.push + @article4 = @article4.reload + expect(@article4.previous.name).to eq '3' expect(@article4.next).to be_nil + expect(@article4.sort).to eq 4 end end end @@ -284,6 +323,11 @@ module Resort expect(article3).to be_first expect(article3.previous).to be_nil expect(article3.next.name).to eq '1' + expect(article3.sort).to eq 1 + + expect(@article1.reload.sort).to eq 2 + expect(@article2.reload.sort).to eq 3 + expect(@article4.reload.sort).to eq 4 end it 'prepends the last element' do @@ -294,10 +338,15 @@ module Resort expect(article4).to be_first expect(article4.previous).to be_nil expect(article4.next.name).to eq '1' + expect(article4.sort).to eq 1 + + expect(@article1.reload.sort).to eq 2 + expect(@article2.reload.sort).to eq 3 + expect(@article3.reload.sort).to eq 4 end it 'will raise ActiveRecord::RecordNotSaved if update fails' do - expect(@article2).to receive(:update_attribute).and_return(false) + expect(@article2).to receive(:update_attributes).and_return(false) expect { @article2.prepend }.to raise_error(ActiveRecord::RecordNotSaved) end @@ -305,15 +354,22 @@ module Resort it 'does nothing' do @article1.prepend + @article1 = @article1.reload + expect(@article1.previous).to be_nil expect(@article1.next.name).to eq '2' + expect(@article1.sort).to eq 1 + + expect(@article2.reload.sort).to eq 2 + expect(@article3.reload.sort).to eq 3 + expect(@article4.reload.sort).to eq 4 end end end describe '#append_to' do it 'will raise ActiveRecord::RecordNotSaved if update fails' do - expect(@article2).to receive(:update_attribute).and_return(false) + expect(@article2).to receive(:update_attributes).and_return(false) expect { @article2.append_to(@article3) }.to raise_error(ActiveRecord::RecordNotSaved) end @@ -324,7 +380,12 @@ module Resort article1 = Article.find_by_name('1') expect(article1.next.name).to eq '3' expect(article1.previous.name).to eq '2' + expect(article1.sort).to eq 2 expect(@article3.previous.name).to eq '1' + + expect(@article2.reload.sort).to eq 1 + expect(@article3.reload.sort).to eq 3 + expect(@article4.reload.sort).to eq 4 end it 'sets the other element as first' do @@ -333,6 +394,11 @@ module Resort article2 = Article.find_by_name('2') expect(article2.next.name).to eq '1' expect(article2).to be_first + expect(article2.sort).to eq 1 + + expect(@article1.reload.sort).to eq 2 + expect(@article3.reload.sort).to eq 3 + expect(@article4.reload.sort).to eq 4 end end @@ -344,9 +410,14 @@ module Resort expect(article1).to_not be_first expect(article1.previous.name).to eq '3' expect(article1.next.name).to eq '4' + expect(article1.sort).to eq 3 expect(@article3.next.name).to eq '1' expect(@article4.previous.name).to eq '1' + + expect(@article2.reload.sort).to eq 1 + expect(@article3.reload.sort).to eq 2 + expect(@article4.reload.sort).to eq 4 end it 'resets the first element' do @@ -355,6 +426,7 @@ module Resort article2 = Article.find_by_name('2') expect(article2).to be_first expect(article2.previous).to be_nil + expect(article2.sort).to eq 1 end end @@ -364,15 +436,19 @@ module Resort article1 = Article.find_by_name('1') expect(article1.next.name).to eq '3' + expect(article1.sort).to eq 1 article2 = Article.find_by_name('2') expect(article2.previous.name).to eq '3' expect(article2.next.name).to eq '4' + expect(article2.sort).to eq 3 expect(@article3.previous.name).to eq '1' expect(@article3.next.name).to eq '2' + expect(@article3.reload.sort).to eq 2 expect(@article4.previous.name).to eq '2' + expect(@article4.reload.sort).to eq 4 end end context 'appending 2 after 4' do @@ -383,13 +459,17 @@ module Resort article3 = Article.find_by_name('3') expect(article1.next.name).to eq '3' + expect(article1.sort).to eq 1 expect(article3.previous.name).to eq '1' + expect(article3.sort).to eq 2 article2 = Article.find_by_name('2') expect(article2.previous.name).to eq '4' expect(article2).to be_last + expect(article2.sort).to eq 4 expect(@article4.next.name).to eq '2' + expect(@article4.reload.sort).to eq 3 end end context 'appending 4 after 2' do @@ -399,11 +479,17 @@ module Resort article3 = Article.find_by_name('3') expect(article3.next).to be_nil expect(article3.previous.name).to eq '4' + expect(article3.sort).to eq 4 article4 = Article.find_by_name('4') - expect(@article2.next.name).to eq '4' expect(article4.previous.name).to eq '2' expect(article4.next.name).to eq '3' + expect(article4.sort).to eq 3 + + expect(@article2.next.name).to eq '4' + expect(@article2.reload.sort).to eq 2 + + expect(@article1.reload.sort).to eq 1 end end context 'appending 3 after 1' do @@ -412,16 +498,20 @@ module Resort article1 = Article.find_by_name('1') expect(article1.next.name).to eq '3' + expect(article1.sort).to eq 1 article2 = Article.find_by_name('2') expect(article2.previous.name).to eq '3' expect(article2.next.name).to eq '4' + expect(article2.sort).to eq 3 article3 = Article.find_by_name('3') expect(article3.previous.name).to eq '1' expect(article3.next.name).to eq '2' + expect(article3.sort).to eq 2 expect(@article4.previous.name).to eq '2' + expect(@article4.reload.sort).to eq 4 end end @@ -433,7 +523,12 @@ module Resort article2 = Article.find_by_name('2') expect(article1.next.name).to eq '2' + expect(article1.sort).to eq 1 expect(article2.previous.name).to eq '1' + expect(article2.sort).to eq 2 + + expect(@article3.reload.sort).to eq 3 + expect(@article4.reload.sort).to eq 4 end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f12f1e1..e02b2c1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -23,6 +23,7 @@ def application t.string :name t.integer :price + t.integer :sort t.boolean :first t.references :next @@ -36,6 +37,7 @@ def application create_table :list_items do |t| t.string :name + t.integer :sort t.boolean :first t.references :next t.references :ordered_list