Sorting with has_many :through and acts_as_list

I’m currently working on a questionnaire system which has two main objects: Question and Questionnaire. I had been associating them using has_and_belongs_to_many, but a new requirement means that I now need to be able to define a question order within each questionnaire.

act_as_list gives me much of the functionality I want. However, I could not just add that to the Question model, because I may need a question to be at position 1 in one questionnaire, and position 4 within another.

The solution was to refactor the has_and_belongs_to_many association to use has_many :through. To do this I had to create a joining class. I called this new class QuestionnairesQuestion. Using a plural on the first word of the class name allowed me to use the has_and_belongs_to_many join table, so I wouldn’t lose the existing relationships.

The required migration took a little work. First I had to add in an id and make it a primary key (the existing table only had question_id and questionnaire_id fields). Then I also needed to generate some initial values for the new fields I was adding. This was my solution:

class AddIdsToQuestionnairesQuestions < ActiveRecord::Migration
  def up
    add_column :questionnaires_questions, :id, :primary_key
    add_column :questionnaires_questions, :position, :integer
    add_column :questionnaires_questions, :created_at, :datetime
    add_column :questionnaires_questions, :updated_at, :datetime
   
    execute "UPDATE questionnaires_questions SET position = id, created_at = now(), updated_at = now()"
  end
 
  def down
    remove_column :questionnaires_questions, :id
    remove_column :questionnaires_questions, :position
    remove_column :questionnaires_questions, :created_at
    remove_column :questionnaires_questions, :updated_at
  end
 
end

I then needed to add the acts_as_list declaration to QuestionnairresQuestion with a scope option so that sorting was per questionnaire. These are the associations that worked for me:

class Question < ActiveRecord::Base
 
  has_many :questionnaires_questions
 
  has_many(
    :questionnaires,
    :through => :questionnaires_questions,
    :uniq => true
  )
end

class Questionnaire < ActiveRecord::Base

  has_many(
    :questionnaires_questions,
    :order => 'position'
  )
 
  has_many(
    :questions,
    :uniq => true,
    :through => :questionnaires_questions,
    :order => 'position'
  )
end

class QuestionnairesQuestion < ActiveRecord::Base
 
  belongs_to :questionnaire
  belongs_to :question
 
  acts_as_list :scope => :questionnaire
end

This worked, but I did have an issue with determining which question was top within each scope. The problem was that act_as_list assumes the top item will have the position ‘1’. However, I’d made the position match the id, which meant that at best only one item would have a position of ‘1’. To fix this I created this rake task:

namespace :data do

  # Usage: rake data:reset_positions RAILS_ENV=production
  desc "Goes through each of the acts_as_list objects and resets the positions based on order they were added to the database"
  task :reset_positions => :environment do
    ActiveRecord::Base.connection.execute "START TRANSACTION;"
    Questionnaire.all.each do |questionnaire|
      first_id = questionnaire.questionnaires_questions.minimum(:id)
      if first_id   # nil if questionnaire has no questions
        sql = "UPDATE questionnaires_questions SET position = (1 + id - #{first_id}) WHERE questionnaire_id = #{questionnaire.id};"
        ActiveRecord::Base.connection.execute sql
      end
    end
    ActiveRecord::Base.connection.execute "COMMIT;"
    puts "Positions reset"
  end
end

Running that got the associations working. There is probably a better way of doing this, but this will do for now.

My next problem was how to access the act_as_list functionality.

If questions were the act_as_list object, you could do things like this

questionnaire.questions.last.move_higher

However, as questions are used on multiple questionnaires and they need to be independently sortable within each questionnaire, it is the through table model QuestionnairesQuestion that acts_as_list. To change position the change must be made in the context of the questionnaire.

My solution was to add these methods to Questionnaire:

  private
  def method_missing(symbol, *args, &block)
    if acts_as_list_method?(symbol)
      pass_method_to_questionnaires_question(symbol, args.first)
    else
      super
    end
  end
 
  def pass_method_to_questionnaires_question(symbol, question)
    raise "A Question is needed to identify QuestionnairesQuestion" unless question.kind_of? Question
    questionnaires_question = questionnaires_questions.where(:question_id => question.id).first
    questionnaires_question.send(symbol) if questionnaires_question
  end
 
  def acts_as_list_method?(symbol)
    ActiveRecord::Acts::List::InstanceMethods.instance_methods.include?(symbol.to_sym)
  end

pass_method_to_questionnaires_question in combination with method_missing, allows you to pass to a questionnaire the acts_as_list method together with the question it needs to effect. The equivalent move_higher call then becomes:

questionnaire.move_higher(questionnaire.questions.last)

You can now also do:

questionnaire.move_to_top(question)
questionnaire.last?(question)
This entry was posted in Ruby. Bookmark the permalink.