openstax/sortability

View on GitHub
lib/sortability/active_record/base.rb

Summary

Maintainability
C
1 day
Test Coverage
module Sortability
  module ActiveRecord
    module Base
      def self.included(base)
        base.extend(ClassMethods)
      end

      module ClassMethods
        # Defines methods that are used to sort records
        # Use via sortable_belongs_to or sortable_class
        def sortable_methods(options = {})
          on = options[:on] || :sort_position
          container = options[:container]
          inverse_of = options[:inverse_of]
          scope_array = [options[:scope]].flatten.compact
          onname = on.to_s
          setter_mname = "#{onname}="
          peers_mname = "#{onname}_peers"
          before_validation_mname = "#{onname}_before_validation"
          next_by_mname = "next_by_#{onname}"
          prev_by_mname = "previous_by_#{onname}"
          compact_peers_mname = "compact_#{onname}_peers!"

          class_exec do
            before_validation before_validation_mname.to_sym

            # Returns all the sort peers for this record, including self
            define_method peers_mname do |force_scope_load = false|
              unless force_scope_load || container.nil? || inverse_of.nil?
                cont = send(container)
                return cont.send(inverse_of) unless cont.nil?
              end

              relation = self.class.unscoped
              scope_array.each do |s|
                relation = relation.where(s => send(s))
              end
              relation
            end

            # Assigns the "on" field's value if needed
            # Adds 1 to any conflicting fields
            define_method before_validation_mname do
              val = send(on)
              scope_changed = scope_array.any? { |s|
                                !changed_attributes[s].nil? }

              return unless val.nil? || scope_changed || changes[on]

              peers = send(peers_mname, scope_changed)
              if val.nil?
                # Assign the next available number to the record
                max_val = (peers.loaded? ? \
                            peers.to_a.max_by{|r| r.send(on) || 0}.try(on) : \
                            peers.maximum(on)) || 0
                send(setter_mname, max_val + 1)
              elsif peers.to_a.any? { |p| p != self && p.send(on) == val }
                # Make a gap for the record
                at = self.class.arel_table
                peers.where(at[on].gteq(val))
                     .reorder(nil)
                     .update_all("#{onname} = - (#{onname} + 1)")
                peers.where(at[on].lt(0))
                     .reorder(nil)
                     .update_all("#{onname} = - #{onname}")

                # Cause peers to load from the DB the next time they are used
                peers.reset
              end
            end

            # Gets the next record among the peers
            define_method next_by_mname do
              val = send(on)
              peers = send(peers_mname)
              peers.loaded? ? \
                peers.to_a.detect { |p| p.send(on) > val } : \
                peers.where(peers.arel_table[on].gt(val)).first
            end

            # Gets the previous record among the peers
            define_method prev_by_mname do
              val = send(on)
              peers = send(peers_mname)
              peers.loaded? ? \
                peers.to_a.reverse.detect { |p| p.send(on) < val } : \
                peers.where(peers.arel_table[on].lt(val)).last
            end

            # Renumbers the peers so that their numbers are sequential,
            # starting at 1
            define_method compact_peers_mname do
              needs_update = false
              peers = send(peers_mname)
              cases = peers.to_a.collect.with_index do |p, i|
                old_val = p.send(on)
                new_val = i + 1
                needs_update = true if old_val != new_val

                # Make sure "on" field in self is up to date
                send(setter_mname, new_val) if p == self

                "WHEN #{old_val} THEN #{- new_val}"
              end.join(' ')

              return peers unless needs_update

              mysql = \
                defined?(ActiveRecord::ConnectionAdapters::MysqlAdapter) && \
                ActiveRecord::Base.connection.instance_of?(
                  ActiveRecord::ConnectionAdapters::MysqlAdapter)
              cend = mysql ? 'END CASE' : 'END'

              self.class.transaction do
                peers.reorder(nil)
                     .update_all("#{onname} = CASE #{onname} #{cases} #{cend}")
                peers.reorder(nil).update_all("#{onname} = - #{onname}")
              end

              # Mark self as not dirty
              changes_applied
              # Force peers to load from the DB the next time they are used
              peers.reset
            end
          end
        end

        # Defines a sortable has_many relation on the container
        def sortable_has_many(records, scope_or_options = nil, options_with_scope = {}, &extension)
          scope, options = extract_association_params(scope_or_options, options_with_scope)
          if scope.nil?
            on = options[:on] || :sort_position
            scope = -> { order(on) }
          end

          class_exec { has_many records, scope, options.except(:on), &extension }
        end

        # Defines a sortable belongs_to relation on the child records
        def sortable_belongs_to(container, scope_or_options = nil,
                                options_with_scope = {}, &extension)
          scope, options = extract_association_params(scope_or_options, options_with_scope)
          on = options[:on] || :sort_position

          class_exec do
            belongs_to container, scope, options.except(:on, :scope), &extension

            reflection = reflect_on_association(container)
            options[:scope] ||= reflection.polymorphic? ? \
                                  [reflection.foreign_type,
                                   reflection.foreign_key] : \
                                  reflection.foreign_key
            options[:inverse_of] ||= reflection.inverse_of.try(:name)

            validates on, presence: true,
                          numericality: { only_integer: true,
                                          greater_than: 0 },
                          uniqueness: { scope: options[:scope] }
          end

          options[:container] = container
          sortable_methods(options)
        end

        # Defines a sortable class without a container
        def sortable_class(options = {})
          on = options[:on] || :sort_position
          scope = options[:scope]

          class_exec do
            default_scope { order(on) }

            validates on, presence: true,
                          numericality: { only_integer: true,
                                          greater_than: 0 },
                          uniqueness: (scope.nil? ? true : { scope: scope })
          end

          sortable_methods(options)
        end

        protected

        def extract_association_params(scope_or_options, options_with_scope)
          if scope_or_options.is_a?(Hash)
            [nil, scope_or_options]
          else
            [scope_or_options, options_with_scope]
          end
        end
      end
    end
  end
end

ActiveRecord::Base.send :include, Sortability::ActiveRecord::Base