Scott Goci
Scott Goci

Reputation: 106

Rails associations and complex forms -- how validate correctly?

Imagine you were building a pretty complex application for professors to give exams to their students. You might imagine the rails model layer might look like:

class Exam < ActiveRecord::Base
    belongs_to :professor
    has_many :questions
    has_many :student_exams
    # exam_name: string
    # exam_start_time: string
end

class StudentExam < ActiveRecord::Base
    belongs_to :student
    belongs_to :exam
end

class Question < ActiveRecord::Base
    belongs_to :exam
    has_many :answers
    # question_text: text
end

class Answer < ActiveRecord::Base
    belongs_to :student_exam
    belongs_to :question
    has_many :gradings
    # answer_text: text
end

class Grading < ActiveRecord::Base
    belongs_to :answer
    belongs_to :grader
    # score: integer
    # grader_notes: text
end

Ignore, at least for the point of this question, any other models that could exist (Why isn't there a Course model that belongs_to :professor and then has_many :students? What does Professor and Student model look like?).

The idea is that a Professor creates an Exam with Questions, associates Students with an Exam (via StudentExam), and those Students then create a unique Answer to each Question. Each Answer then gets a Grading by one or more Graders.

The problem comes when I'm trying to make the grading form -- what would the controller update and create look like, along with the form? As a Grader, you would want the entire exam laid out, where you could then evaluate a student's answers. So I was thinking a form might look like:

= simple_form_for @exam do |f|
    = f.object.exam_name
    = f.simple_fields_for :questions do |q|
        = q.object.question_text
        = q.simple_fields_for :answers do |a|
            = a.object.response_text
            = a.simple_fields_for :gradings do |g|
                = g.score
                = g.grader_notes

(This assumes, of course, that Exam accepts_nested_attributes_for :questions, Question accepts_nested_attributes_for :answers, Answer accepts_nested_attributes_for :evaluations).

The problem, in my mind, with this form is that I feel it is pretty deeply nested and hard to validate. Thoughts that come to mind:

On the controller side when I submit, how can I validate that as a Grader, I'm not modifying another Grader's Grading? Or that a Grader isn't maliciously associating a Grading with a different Answer that's part of a different Exam they might not have access to? What would the create/update look like? What would the strong_parameters look like? My first guess would be something like:

class GradingController < ActionController::Base
    # ....
    create
      @exam = grader.exams.find(params[:id])
      @exam.new(grading_params)
    end

    private
        def grading_params
            params(:exam).permit(:questions_attributes => [:answers_attributes => [:grading_attributes => [:score, :grader_notes]])
        end
end

But now how do I associate the Grader with the Gradings? I didn't add :grader_id to the strong_params for grading_attributes, because then they could submit up parameters with a different grader_id than their own.

Similarly, I didn't put the :id column in the answers_attributes, because I feel someone could then maliciously change that ID and associate one of their Gradings with a different Answer on a different Exam, especially because an Answer is only tied to a Question with a foreign_key, and not back to an Exam (right?).

The only way I can think to solve this then would be to break apart the parameters submitted and check them all along the way, which seems crazy. IE something like:

class GradingController < ActionController::Base
    # ....
    def create
        @exam = grader.exams.find(params[:id])
        params[:exam][:question_attributes].each do |question_hash|
            question = @exam.questions.find(question_hash[:id])
            if question.present?
                answer_hashes = question_hash[:answer_attributes]
                answer_hashes.each do |answer_hash|
                    answer = question.answers.find(answer_hash[:id])
                    if answer.present?
                        grading_hashes = answer_hash[:grading_attributes]
                        grading_hashes.each do |grading_hash|
                            grading = answer.gradings.where(grading_hash[:id]).first_or_initialize
                            grading.grader = current_grader
                            grading.score = grading_hash[:score]
                            grading.grader_notes = grading_hash[:grader_notes]
                        end
                    end
                end
            end
        end
    end
end

This seems very brittle, and also incredibly complex, along with impossible to return from correctly (How do we handle or display errors back on the form if Answer or Question isn't present, etc). But what's the other option here?

Upvotes: 4

Views: 162

Answers (1)

smallbutton
smallbutton

Reputation: 3437

First off, the approach you described above if not very good for complex froms as in your case, as you already found out yourself. Using accepts_nested_attributes and nesting all fields inside other fields is getting complex very fast, and has a very static nature in my oppinion (and its also hell to maintain).

What you can do in cases, that are too complex for the simple accepts_nested_attirbute functions of rails is use form objects to handle validation and saving of multiple models. This approach does not need this nesting of models and pays off the more complex your form gets. As a first reference of what form objects are you can read in THIS very nice post (section 3).

Basically, what you do is you move some, or if you create a form classes, that can store all information a form picks up and save all models, once the validation succeeds. In contrast to the above metioned blog post, that uses the virtus gem for creating virtual fields, I personally use ActiveData for this job, but you can also stick with your ActiveRecord models and keep the validation there and forward the errors to your form object.

What your form object should do is:

  • Get the parameters of the form and instatiate objects from them
  • Issue validation and make errors available on the form object
  • Handle object creation/saving

You can get this to work with simple_forms, by manually setting the parameters to a structure, that you think makes sense, while your form object can work with those structures.

I think there is no general recepie for creating your form object but I think with the above information you should have a good start into figuring out the best way to create your form object.

As an example I can show you a form object, of a shopping cart that I created, which works without an extra gem and also works with multiple steps, by saving data to the session: Form Object, Step 1, Step 2, Step 3

Hope this helps.

Upvotes: 1

Related Questions