Ruby on Rails
BetterHabtmList

UPDATE: This has finally been packaged into a proper plugin on Github. See: http://github.com/SFEley/habtm_list/

Bit rough and ready but works alright for me. Until someone creates a nicer plugin, you’ll need to cut and paste code from this page into two new files.

Note that to grab code from a wiki, you need to ‘edit’ the page below and capture the raw code between the pre tags. Then back out without saving. If you grab the HTML, double equals will disappear, and quotes will be converted into the wrong characters.

Create a new file at vendor/plugins/habtm_list/lib/habtm_list.rb and paste

module RailsExtensions
  module HabtmList
  
    def self.append_features(base) #:nodoc:
      super
      base.extend(ClassMethods)
      base.class_eval do
        class << self
          alias_method :has_and_belongs_to_many_without_list_handling, :has_and_belongs_to_many
          alias_method :has_and_belongs_to_many, :has_and_belongs_to_many_with_list_handling
        end
      end
    end

    module ClassMethods
      def has_and_belongs_to_many_with_list_handling(name, options={})
        if options.delete(:list)
          options[:extend] = RailsExtensions::HabtmList::AssociationListMethods

          after_add_callback_symbol = "maintain_list_after_add_for_#{name}".to_sym
          before_remove_callback_symbol = "maintain_list_before_remove_for_#{name}".to_sym
          
          options[:after_add] ||= []
          options[:after_add] << after_add_callback_symbol

          options[:before_remove] ||= []
          options[:before_remove] << before_remove_callback_symbol

          class_eval <<-EOV
            def #{after_add_callback_symbol}(added)
              self.#{name}.add_to_list_bottom(added)
            end
            
            def #{before_remove_callback_symbol}(removed)
              self.#{name}.remove_from_list(removed)
            end
          EOV
        end

        has_and_belongs_to_many_without_list_handling(name, options)
      end
    end
      
    module AssociationListMethods
      def move_to_position(item, position)
        return if !in_list?(item) || position.to_i == list_position(item)
        list_item_class.transaction do
          remove_from_list(item)
          insert_at_position(item, position)
        end
        resort_array
      end
  
      def move_lower(item)
        list_item_class.transaction do
          lower = lower_item(item)
          return unless lower
          decrement_position(lower)
          increment_position(item)
        end
        resort_array
      end
  
      def move_higher(item)
        list_item_class.transaction do
          higher = higher_item(item)
          return unless higher
          increment_position(higher)
          decrement_position(item)
        end
        resort_array
      end
  
      def move_to_bottom(item)
        return unless in_list?(item)
        list_item_class.transaction do
          decrement_positions_on_lower_items(item)
          assume_bottom_position(item)
        end
        resort_array
      end
  
      def move_to_top(item)
        return unless in_list?(item)
        list_item_class.transaction do
          increment_positions_on_higher_items(item)
          assume_top_position(item)
        end
        resort_array
      end

      # should only be called externally from the before_remove callback
      def remove_from_list(item)
        decrement_positions_on_lower_items(item) if in_list?(item)
        item[position_column] = nil
      end

      def first?(item)
        item == self.first
      end
  
      def last?(item)
        item == self.last
      end
  
      def higher_item(item)
        return nil unless in_list?(item)
        self.find(:first, :conditions => "#{position_column} = #{(list_position(item) - 1).to_s}")
      end

      def lower_item(item)
        return nil unless in_list?(item)
        self.find(:first, :conditions => "#{position_column} = #{(list_position(item) + 1).to_s}")
      end

      def in_list?(item)
        self.include?(item)
      end

      def add_to_list_bottom(item)
        item.save! if item.id.nil? # Rails 2.0.2 - Callbacks don't save first on association.create() 
        list_item_class.transaction do
          assume_bottom_position(item)
        end
        resort_array
      end
      
      def add_to_list_top
        list_item_class.transaction do
          increment_positions_on_all_items
          assume_top_position(item)
        end
        resort_array
      end
      
      # "First aid" method in case someone shifts the array around outside these methods, or
      # the positions in the joins table go totally out of whack.  Don't use it for
      # simple ordering because it's inefficient.
      def reset_positions
        self.each_index do |i|
          item = self[i]
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = #{i} " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{list_item_foreign_key} = #{item.id}"
          )
        end
      end
          
      
  
      private
        def position_column
          @reflection.options[:order] || 'position'
        end
        
        def list_item_class
          @reflection.klass
        end
        
        def join_table
          @reflection.options[:join_table]
        end
        
        def foreign_key
          @reflection.primary_key_name
        end
        
        def list_item_foreign_key
          @reflection.association_foreign_key
        end
        
        def list_position(item)
          self.index(item)
        end


        def set_position(item, position)
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = #{position} " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{list_item_foreign_key} = #{item.id}"
          )
          if @target
            obj = @target.find {|obj| obj.id == item.id}
            obj[position_column] = position if obj
          end
        end

        def assume_bottom_position(item)
          set_position(item, self.length - 1)
        end

        def assume_top_position(item)
          set_position(item, 0)
        end

        def increment_position_by(item, increment)
          return unless in_list?(item)
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = #{position_column} + (#{increment}) " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{list_item_foreign_key} = #{item.id}"
          )
          if @target
            obj = @target.find {|obj| obj.id == item.id}
            obj[position_column] = obj[position_column].to_i + increment if obj
          end
        end

        def increment_position(item)
          increment_position_by(item, 1)
        end

        def decrement_position(item)
          increment_position_by(item, -1)
        end

        # This has the effect of moving all the higher items up one.
        def decrement_positions_on_higher_items(position)
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = (#{position_column} - 1) " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{position_column} <= #{position}"
          )
          @target.each { |obj|
            obj[position_column] = obj[position_column].to_i - 1 if in_list?(obj) && obj[position_column].to_i <= position
          } if @target
        end
    
        # This has the effect of moving all the lower items up one.
        def decrement_positions_on_lower_items(item)
          return unless in_list?(item)
          position = list_position(item)
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = (#{position_column} - 1) " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{position_column} > #{position}"
          )
          @target.each { |obj|
            obj[position_column] = obj[position_column].to_i - 1 if in_list?(obj) && obj[position_column].to_i > position
          } if @target
        end

        # This has the effect of moving all the higher items down one.
        def increment_positions_on_higher_items(item)
          return unless in_list?(item)
          position = list_position(item)
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = (#{position_column} + 1) " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{position_column} < #{position}"
          )
          @target.each { |obj|
            obj[position_column] = obj[position_column].to_i + 1 if in_list?(obj) && obj[position_column].to_i < position
          } if @target
        end

        # This has the effect of moving all the lower items down one.
        def increment_positions_on_lower_items(position)
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = (#{position_column} + 1) " +
            "WHERE #{foreign_key} = #{@owner.id} AND #{position_column} >= #{position}"
          )
          @target.each { |obj|
            obj[position_column] = obj[position_column].to_i + 1 if in_list?(obj) && obj[position_column].to_i >= position
          } if @target
        end

        def increment_positions_on_all_items
          connection.update(
            "UPDATE #{join_table} SET #{position_column} = (#{position_column} + 1) " +
            "WHERE #{foreign_key} = #{@owner.id}"
          )
          @target.each { |obj|
            obj[position_column] = obj[position_column].to_i + 1 if in_list?(obj)
          } if @target
        end

        def insert_at_position(item, position)
          remove_from_list(item)
          increment_positions_on_lower_items(position)
          set_position(item, position)
        end
        
        # called after changing position values so the array reflects the updated ordering
        def resort_array
          @target.sort! {|x,y| x[position_column].to_i <=> y[position_column].to_i} if @target
        end      
    end
  end
end

and then for vendor/plugins/habtm_list/init.rb:

require 'habtm_list'

ActiveRecord::Base.class_eval do
  include RailsExtensions::HabtmList
end

See background and example usage

Revision History

3/18/08: Rewrote several methods to be more robust. (There were a few obvious bugs, too much SQL where simple array methods would work, and too much reliance on item[position], which is only accessible if the item was first loaded from the association.) At some point soon I may make a proper plug-in out of this and put it up for download, if I can figure out how to find and credit the original author(s). — sfeley@gmail.com

Comments

It would be nice to see a brief example of how to use this code :