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:
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:
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:
# 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
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:
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:
You can now also do:
questionnaire.last?(question)