Permalink
Fetching contributors…
Cannot retrieve contributors at this time
238 lines (217 sloc) 7.3 KB
# frozen_string_literal: true
require 'generators/active_record/resort_generator' if defined?(Rails)
require 'active_record' unless defined?(ActiveRecord)
# # Resort
#
# A tool that allows any ActiveRecord model to be sorted.
#
# Unlike most Rails sorting plugins (acts_as_list, etc), Resort is based
# on linked lists rather than absolute position fields.
#
# @example Using Resort in an ActiveRecord model
# # In the migration
# create_table :products do |t|
# t.text :name
# t.references :next
# t.boolean :first
# end
#
# # Model
# class Product < ActiveRecord::Base
# resort!
#
# # A sortable model must implement #siblings method, which should
# # return and ActiveRecord::Relation with all the models to be
# # considered as `peers` in the list representing the sorted
# # products, i.e. its siblings.
# def siblings
# self.class.all
# end
# end
#
# product = Product.create(:name => 'Bread')
# product.first? # => true
#
# another_product = Product.create(:name => 'Milk')
# yet_another_product = Product.create(:name => 'Salami')
#
# yet_another_product.append_to(product)
#
# Product.ordered.map(&:name)
# # => ['Bread', 'Salami', 'Milk']
module Resort
# The module encapsulating all the Resort functionality.
#
# @todo Refactor into a more OO solution, maybe implementing a LinkedList
# object.
module Sortable
class << self
# When included, extends the includer with {ClassMethods}, and includes
# {InstanceMethods} in it.
#
# It also establishes the required relationships. It is necessary that
# the includer table has the following database columns:
#
# t.references :next
# t.boolean :first
#
# @param [ActiveRecord::Base] base the includer `ActiveRecord` model.
def included(base)
base.extend ClassMethods
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.after_create :include_in_list!
base.after_destroy :delete_from_list
end
end
# Class methods to be used from the model class.
module ClassMethods
# Returns the first element of the list.
#
# @return [ActiveRecord::Base] the first element of the list.
def first_in_order
all.where(first: true).first
end
# Returns the last element of the list.
#
# @return [ActiveRecord::Base] the last element of the list.
def last_in_order
all.where(next_id: nil).first
end
# Returns eager-loaded Components in order.
#
# OPTIMIZE: Use IdentityMap when available
# @return [Array<ActiveRecord::Base>] the ordered elements
def ordered
ordered_elements = []
elements = {}
all.each do |element|
if ordered_elements.empty? && element.first?
ordered_elements << element
else
elements[element.id] = element
end
end
raise 'Multiple or no first items in the list where found. Consider defining a siblings method' if ordered_elements.length != 1 && elements.length > 0
elements.length.times do
ordered_elements << elements[ordered_elements.last.next_id]
end
ordered_elements.compact
end
end
# Instance methods to use.
module InstanceMethods
# Default definition of siblings, i.e. every instance of the model.
#
# Can be overriden to specify a different scope for the siblings.
# For example, if we wanted to limit a products tree inside a ProductLine
# scope, we would do the following:
#
# class Product < ActiveRecord::Base
# belongs_to :product_line
#
# resort!
#
# def siblings
# self.product_line.products
# end
#
# This way, every product line is an independent tree of sortable
# products.
#
# @return [ActiveRecord::Relation] the element's siblings relation.
def siblings
self.class.all
end
# Includes the object in the linked list.
#
# If there are no other objects, it prepends the object so that it is
# in the first position. Otherwise, it appends it to the end of the
# empty list.
def include_in_list!
self.class.transaction do
lock!
_siblings.count > 0 ? last!\
: prepend
end
end
# Puts the object in the first position of the list.
def prepend
self.class.transaction do
lock!
return if first?
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)
end
raise(ActiveRecord::RecordNotSaved) unless update_attribute(:first, true)
end
end
# Puts the object in the last position of the list.
def push
self.class.transaction do
lock!
append_to(_siblings.last_in_order) unless last?
end
end
# Puts the object right after another object in the list.
def append_to(another)
self.class.transaction do
lock!
return if another.next_id == id
another.lock!
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)
end
if another
raise ActiveRecord::RecordNotSaved, "[Resort] - Couldn't set this element to another's next" unless another.update_attribute(:next_id, id)
end
end
end
def last?
!first && !next_id
end
def last!
self.class.transaction do
lock!
raise(ActiveRecord::RecordNotSaved) unless _siblings.last_in_order.update_attribute(:next_id, id)
end
end
private
def delete_from_list
if first? && self.next
self.next.lock!
raise(ActiveRecord::RecordNotSaved) unless self.next.update_attribute(:first, true)
elsif previous
previous.lock!
p = previous
self.previous = nil unless frozen?
raise(ActiveRecord::RecordNotSaved) unless p.update_column(:next_id, next_id)
end
unless frozen?
self.first = false
self.next = nil
save!
end
end
def _siblings
table = self.class.arel_table
siblings.where(table[:id].not_eq(id))
end
end
end
# Helper class methods to be injected into ActiveRecord::Base class.
# They will be available to every model.
module ClassMethods
# Helper class method to include Resort::Sortable in an ActiveRecord
# model.
def resort!
include Sortable
end
end
end
ActiveRecord::Base.extend Resort::ClassMethods