0

I am building a question engine with Ruby, I have the question element working well - users can create and attempt questions, and their attempts are saved in a question_attempts table.

I am now trying to build a question bank feature where users can filter all questions by a few different options - tags, difficulty and the result of their last attempt (if it exists). Both the filtering by tag (via a separate tags table and join taggings table), and difficulty (a string of four options saved within the question table) work as expected.

I am having some issues with filter questions based upon the user's attempts. I need to be able to filter for three different conditions:

  • Correct - where question.attempt.score == 100
  • Incorrect - where question.attempt.score < 100
  • Not attempted - where question.attempt == nil / does not exist

The question_attempts models belongs_to: user and belongs_to: question. The score from their attempt is saved as a standardised percentage float with a maximum of 100.0.

For the first two I can use a similar methodology as I have for the tags, both of these work:

.where(question_attempts: { score: 100, user_id: current_user.id }

.where.not(question_attempts: { score: 100 }).where(user_id: current_user.id })

I can get the questions that an individual user has not submitted a response to using the following line:

.where.not(id: QuestionAttempt.where( user_id: current_user.id ).select( "question_id" ) )


My issues are:

  • How do I combine these filter options? I am trying to avoid writing a long series of if else statements, but will do this if required (I can do this without assistance, just want to check there isn't a better way before cracking on)
  • How do I limit the checking of results to their last attempt only? i.e. if they have submitted a question response 2+ times I only want to check the result of the most recent. I would be tempted to use the solution from Mark Swardstrom in this answer, primarily because I understand it, but am concerned it would be an inefficient method.

Thank you for your help

Let me know if you need more details.


Models

class Question < ApplicationRecord
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings
  has_many :question_attempts, dependent: :destroy
end

class QuestionAttempt < ApplicationRecord
  belongs_to :question
  belongs_to :user
end

Controller

# Gather topics / tags
# This is working
if params[:topics] != nil 
  @tags = params[:topics].split(",")
end

# Gather difficulties
# This is working as expected
# If nil, then show all difficulties
if params[:difficulties] != nil 
  @difficulties = params[:difficulties].split(",")
else
  flash[:notice] = 'Showing all difficulties as none selected'   
  @difficulties = ["Untested", "Easy", "Medium", "Hard"]
end

# This is the element not yet working
# I can gather the options Unattempted / Correct / Incorrect from the params
# Not sure how to then join this with my question_attempts table
@attempts = params[:attempts].split(",") 


# Gather questions matching the filters 
if @tags == nil

  @questions = Question.joins(:question_attempts).where(difficulty: @difficulties, private: false, draft: false, approved: true).distinct

else

  @questions = Question.joins(:question_attempts).joins(:tags).where(tags: { name: @tags }, difficulty: @difficulties, private: false, draft: false, approved: true).distinct

end

Current Solution

This is likely not the most efficient way of doing this, but it seems to be working thanks to tips from Tom Lord. Would welcome any constructive feedback / tips.

    def tags
  params[:topics]&.split(",")
end

def difficulties
  params[:difficulties]&.split(",")
end

  def filter

# Check for previous answers
if params[:correct] == "true" && params[:incorrect] != "true"
  @questions = Question.where( id: QuestionAttempt.where(user_id: current_user.id).where('score = 100').pluck(:question_id) )
elsif params[:incorrect] == "true" && params[:correct] != "true"
  @questions = Question.where( id: QuestionAttempt.where(user_id: current_user.id).where('score < 100').pluck(:question_id) )
elsif params[:incorrect] == "true" && params[:correct] == "true"
  @questions = Question.where( id: QuestionAttempt.where(user_id: current_user.id).pluck(:question_id) )
end

# Check for unanswered filters
if params[:unattempted] == "true"

  if @questions == nil

    @questions = Question.where.not( id: QuestionAttempt.where( user_id: current_user.id ).pluck(:question_id) )

  else

    @questions = Question.where.not( id: QuestionAttempt.where( user_id: current_user.id ).pluck(:question_id) ).or (
        @questions
      )

  end

end


# Add all other filters afterwards as they should apply to all    

@questions = @questions.where(difficulty: difficulties) if difficulties

@questions = @questions.joins(:tags).where(tags: { name: tags }) if tags

@questions = @questions.where(
    private: false,
    draft: false,
    approved: true
  )

@questions.distinct
Oliver Trampleasure
  • 3,293
  • 1
  • 10
  • 25
  • 1
    To check their "latest answer", you could consider doing `order(:created_at).limit(1)`. To perform the third check, where a user has not submitted an attempt, it's vastly more efficient to perform an **OUTER JOIN** than to query by IDs like that. This is, literally, what an outer join is designed for; and modern rails has a built-in method for it. – Tom Lord Jun 25 '19 at 19:44
  • 1
    I don't really see a way to "combine" these queries (what does that even mean??); the only thing I can thing of is to define a *scope* with logic like `.where("question_attempts.score >= ? AND question_attempts.score <= ?", ...)` - so you could pass `0, 99` as params or `100, 100` as params. But based on the information you've provided, such a scope may be overkill. – Tom Lord Jun 25 '19 at 19:47
  • 1
    Another thing you could consider doing is - depending on your UI design/requirements - consider restructuring the overall model: A `Quiz` is a collection of `Question`s. And a `QuizAttempt` is a collection of `QuestionAnswer`s (which can be skipped?). When showing the scores, consider the value of each `QuestionAnswer` *associated to a `QuizAttempt`*. This gives a much simpler and more meaningful definition for "latest answer" and "overall score". – Tom Lord Jun 25 '19 at 19:50
  • Thanks for your tips Tom. I don't think restructuring the models will work, unfortunately, as questions can be taken without being in a quiz. By combining I mean exactly what you mention - the user may want questions they've answered correctly and incorrectly in the past - i.e. your suggestion of `.where("question_attempts.score >= ? AND question_attempts.score <= ?", ...)`. I was hoping to avoid creating a series of `if else` checks, but perhaps I could create a series of strings that I could `.join` with an `OR` statements. I'll have a play with the things you've recommended. – Oliver Trampleasure Jun 25 '19 at 20:30
  • 1
    If you want a fourth scope for "answered questions" as opposed to "unanswered questions", then a straightforward **INNER JOIN** would suffice. (The opposite of an outer join, see?) A scope like I suggested would also work - but although it saves a few lines of code, it adds unnecessary checks against the `score`. But either way, there's no need for any `if` statements here. – Tom Lord Jun 25 '19 at 20:34
  • Thanks Tom, I've managed to get it working (I think) due to your help, I've added the code to the bottom of my question. I imagine it's a terribly convoluted solution... would appreciate any input you have – Oliver Trampleasure Jun 25 '19 at 23:05

1 Answers1

0

Here is a quick pass at a cleanup of your current solution:

def tags
  params[:topics]&.split(",")
end

def difficulties
  params[:difficulties]&.split(",")
end

# ...

questions = Question.joins(:question_attempts).where(
    question_attempts: { user_id: current_user.id },
    private: false,
    draft: false,
    approved: true
  )

# If both are true, no need to scope query
if params[:correct] == "true" && params[:incorrect] != "true"
  questions = questions.where('question_attempts.score = 100')
elsif params[:incorrect] == "true"
  questions = questions.where('question_attempts.score < 100')
end

questions = questions.where(difficulty: difficulties) if difficulties

questions = questions.joins(:tags).where(tags: { name: tags }) if tags

if params[:unattempted] == "true"
  questions = questions.or(
    Question.includes(:question_attempts).where(question_attempts: { user_id: nil})
  )
end

questions.distinct

The key difference between my version and yours is that I compose the query in logical components, rather than attempting to write the whole thing in one go.

This reduces the overall length and complexity, whilst maintaining the same result.

You could further simplify this by defining scopes on the Question model, to move much of this logic outside of the controller.

Note: I haven't actually run this code, so it may contain some minor mistake(s). Please test carefully.

Tom Lord
  • 27,404
  • 4
  • 50
  • 77
  • Thanks, that's a lot better for sure. I tweaked the logic for the params[incorrect] to also ensure that params[correct] was false (as otherwise it matched when they were both true). The only issue is that I am getting the error `ArgumentError (Relation passed to #or must be structurally compatible. Incompatible values: [:joins]):`. I've treid a few different things but still crops up - I thought the following would work (as I can't see any join!), it works when not inside an `.or()` - `Question.where.not( id: QuestionAttempt.where( user_id: current_user.id ).pluck(:question_id) )` – Oliver Trampleasure Jun 26 '19 at 09:59
  • Fixed it in the end, it's not as elegant as your solution it's much more elegant than my original. Thanks so much for your help! – Oliver Trampleasure Jun 26 '19 at 10:30
  • I've updated my question to reflect what I'm doing now. Basically a load of `.where` statements, after using `.pluck()` to get the relevant ids. Thanks again for your help. – Oliver Trampleasure Jun 26 '19 at 10:48