owahab/active_sorting

View on GitHub
lib/active_sorting/model.rb

Summary

Maintainability
A
1 hr
Test Coverage
module ActiveSorting
  module Model # :nodoc:
    def self.included(base)
      base.extend ClassMethods
      # Track all sortable arguments
      base.class_attribute :active_sorting_options
    end

    # Patches ActiveRecord models
    module ClassMethods
      # Sets the sortable options
      #
      # +name+ sortable field name
      # Accepts a Hash of options:
      # +order+ sorting direction, defaults to :asc
      # +step+ stepping value, defaults to 500
      # +scope+ scope field name, defaults to []
      def sortable(name, opts = {})
        self.active_sorting_options = active_sorting_default_options.merge(opts)
        active_sorting_options[:name] = name
        active_sorting_check_options
        validates active_sorting_options[:name], presence: true
        default_scope { active_sorting_default_scope }
        before_validation :active_sorting_callback_before_validation
      end

      # Sorts and updates the database with the given list of items
      # in the given order.
      #
      # +new_list+ List of ids of records in the desired order
      # +id_column+ the field used for fetching records from the databse,
      #             defaults to :id
      def sort_list(new_list, id_column = :id)
        raise ArgumentError, "Sortable list should not be empty" if new_list.empty?
        conditions = { id_column => new_list }
        old_list = unscoped.active_sorting_default_scope.where(conditions).pluck(id_column)
        raise Exceptions::RecordsNotFound, "Sortable list should be persisted to database with #{name} Model" if old_list.empty?
        changes = active_sorting_changes_required(old_list, new_list)
        active_sorting_make_changes(old_list, new_list, changes, id_column)
      end

      def sort_list_slow(new_list, id_column = :id)
        raise ArgumentError, "Sortable list should not be empty" if new_list.empty?
        conditions = { id_column => new_list }
        old_list = unscoped.active_sorting_default_scope.where(conditions)
        old_list_ids = old_list.pluck(id_column)

        raise Exceptions::RecordsNotFound, "Sortable list should be persisted to database with #{name} Model" if old_list_ids.empty?
        raise Exceptions::InvalidListSize, "Sortable new and old lists should be of the same length" if old_list.count != old_list_ids.count

        weight_lists = old_list.map{|speaker| speaker.weight}

        new_list.each_with_index do |id, index|
          current_record = active_sorting_find_by(id_column, id)
          if current_record.active_sorting_value != weight_lists[index] 
            current_record.active_sorting_value = weight_lists[index] 
            current_record.save!
          end
        end
      end

      # Default sorting options
      def active_sorting_default_options
        {
          order: :asc,
          step: 500,
          scope: []
        }
      end

      # Check provided options
      def active_sorting_check_options
        # TODO: columns_hash breaks when database has no tables
        # field_type = columns_hash[active_sorting_field.to_s].type
        # unless field_type == :integer
        #   raise ArgumentError, "Sortable field should be of type Integer, #{field_type} where given"
        # end
        unless active_sorting_step.is_a?(Fixnum)
          raise ArgumentError, "Sortable step should be of type Fixnum, #{active_sorting_step.class.name} where given"
        end
        unless active_sorting_scope.respond_to?(:each)
          raise ArgumentError, "Sortable step should be of type Enumerable, #{active_sorting_scope.class.name} where given"
        end
      end

      def active_sorting_default_scope
        conditions = {}
        conditions[active_sorting_field] = active_sorting_order
        order(conditions)
      end

      # Calculate the least possible changes required to
      # reorder items in +old_list+ to match +new_list+ order
      # by comparing two proposals from
      # +active_sorting_calculate_changes+
      def active_sorting_changes_required(old_list, new_list)
        raise Exceptions::InvalidListSize, "Sortable new and old lists should be of the same length" if old_list.count != new_list.count
        changes = []
        proposal1 = active_sorting_calculate_changes(old_list.dup, new_list.dup)
        if proposal1.count >= (new_list.count / 4)
          proposal2 = active_sorting_calculate_changes(old_list.dup.reverse, new_list.dup.reverse)
          changes = proposal1.count < proposal2.count ? proposal1 : proposal2
        else
          changes = proposal1
        end
        changes
      end

      # Calculate the possible changes required to
      # reorder items in +old_list+ to match +new_list+ order
      def active_sorting_calculate_changes(old_list, new_list, changes = [])
        new_list.each_with_index do |id, index|
          next unless old_list[index] != id
          # This item has changed
          changes << id
          # Remove it from both lists, rinse and repeat
          new_list.delete(id)
          old_list.delete(id)
          # Recur...
          active_sorting_calculate_changes(old_list, new_list, changes)
          break
        end
        changes
      end

      # Commit changes to database
      def active_sorting_make_changes(old_list, new_list, changes, id_column)
        new_list.each_with_index do |id, index|
          next unless changes.include?(id)
          if index == new_list.count.pred
            # We're moving an item to last position,
            # increase the count of last item's position
            # by the step
            n1 = active_sorting_find_by(id_column, new_list[index.pred]).active_sorting_value
            n2 = n1 + active_sorting_step
          elsif index == 0
            # We're moving an item to first position
            # Calculate the gap between following 2 items
            n1 = 0
            n2 = active_sorting_find_by(id_column, old_list[index]).active_sorting_value
          else
            # We're moving a non-terminal item
            n1 = active_sorting_find_by(id_column, new_list[index.pred]).active_sorting_value
            n2 = active_sorting_find_by(id_column, new_list[index.next]).active_sorting_value
          end
          active_sorting_find_by(id_column, id).active_sorting_center_item(n1, n2)
        end
      end

      def active_sorting_field
        active_sorting_options[:name]
      end

      def active_sorting_step
        active_sorting_options[:step]
      end

      def active_sorting_order
        active_sorting_options[:order]
      end

      def active_sorting_scope
        active_sorting_options[:scope]
      end

      def active_sorting_find_by(id_column, value)
        conditions = {}
        conditions[id_column] = value
        find_by(conditions)
      end
    end

    def active_sorting_value
      send(self.class.active_sorting_field)
    end

    def active_sorting_value=(new_value)
      send("#{self.class.active_sorting_field}=", new_value)
    end

    # Centers an item between the where given two positions
    def active_sorting_center_item(n1, n2)
      delta = (n1 - n2).abs
      smaller = [n1, n2].min
      if delta == 1
        new_position = smaller + delta
      elsif delta > 1
        new_position = smaller + (delta / 2)
      end
      self.active_sorting_value = new_position
      save!
      self
    end

    # Generate the next stepping
    def active_sorting_next_step
      conditions = {}
      self.class.active_sorting_scope.each do |s|
        conditions[s] = send(s)
      end
      # Get the maximum value for the sortable field name
      max = self.class
                .unscoped
                .where(conditions)
                .maximum(self.class.active_sorting_field)
      # First value will always equal step
      return self.class.active_sorting_step if max.nil?
      # Increment by the step value configured
      max + self.class.active_sorting_step
    end

    ## Callbacks
    # Generates a new code based on where given options
    def active_sorting_callback_before_validation
      field_name = self.class.active_sorting_field
      send("#{field_name}=", active_sorting_next_step) if send(field_name).nil?
    end
  end
end