Recipe
Latest (or Highest-Scoring) Quiz Attempt per Learner
Reduce the quizattempts table to one canonical row per learner per quiz — either the most recent or the highest scoring.
Tables
quizattempts, quizattemptsresponses
Tags
TL;DR
quizattempts stores every attempt. Use ROW_NUMBER() OVER (PARTITION BY userid, topicid ORDER BY ...) and filter status = 'finished' to collapse to one canonical row per learner per quiz.
Problem
The quizattempts table contains every attempt a learner has ever made at a quiz — including in-progress (status = 'started'), abandoned, and resumed attempts. For most reporting questions (pass/fail dashboards, score distributions, completion certificates) you really want a single canonical row per learner per quiz.
Quizzes are identified by topicid in this table (each Quiz Page is a topic), so the partition key for "one row per learner per quiz" is (userid, topicid).
Two business definitions are common:
- **Most recent attempt** — reports on the learner's *current* state ("did they pass the last time they tried?").
- **Highest-scoring attempt** — the rule for most graded courses ("best attempt counts").
Solution
-- Most recent finished attempt per learner per quiz
WITH ranked AS (
SELECT
quizattempts.*,
ROW_NUMBER() OVER (
PARTITION BY userid, topicid
ORDER BY createdat DESC
) AS attempt_rank
FROM quizattempts
WHERE status = 'finished'
)
SELECT *
FROM ranked
WHERE attempt_rank = 1;-- Highest-scoring finished attempt per learner per quiz
-- (ties broken by most recent)
WITH ranked AS (
SELECT
quizattempts.*,
ROW_NUMBER() OVER (
PARTITION BY userid, topicid
ORDER BY grade DESC NULLS LAST, createdat DESC
) AS score_rank
FROM quizattempts
WHERE status = 'finished'
)
SELECT *
FROM ranked
WHERE score_rank = 1;How it works
PARTITION BY userid, topicidgroups attempts by learner and quiz.ORDER BY createdat DESC(orgrade DESC, createdat DESC) picks the winner per group.status = 'finished'excludes in-progress and abandoned attempts — these havenullgrades and would skew ranking.
Variations
- **Per course rather than per quiz:** swap
topicidforcourseidin the partition. - **Joined to learner detail:**
LEFT JOIN usersafter the CTE onuserid. - **Per attempt response details:** join the resulting
idset back toquizattemptsresponses— and apply the historicalcorrectrecalculation if needed.
Empty answers and the correct column
For pre-2025-07-04 attempts, empty responses incorrectly defaulted to correct = TRUE in quizattemptsresponses. If you're doing question-level analysis on top of these results, layer on the recalculated_correct recipe.