What You'll Build
You will build the query layer for a cricket-league analytics dashboard, combining everything from this module: joins across teams, players, matches, and appearances; aggregations and window functions for rankings and running totals; CTEs to structure multi-step computations; and a materialized view to serve the expensive standings instantly. The result is a set of queries and one materialized view that a dashboard could read directly.
The aim is to practice analytical SQL end to end: turning raw event rows into the leaderboards, trends, and standings a dashboard displays, and then precomputing the costliest of those into a refreshable materialized view. By the end you will have produced top-scorer leaderboards, per-match rankings, form trends, and a fast standings table backed by a concurrent refresh.
Prerequisites
- Completion of lessons 06–09, or equivalent comfort with joins, aggregation, window functions, CTEs, and views.
- The league schema from lesson 05 (team, player, match, appearance) with a reasonable amount of sample data.
- A running PostgreSQL 14+ instance and the psql client.
- Familiarity with GROUP BY/HAVING, OVER/PARTITION BY, WITH (CTEs), and CREATE MATERIALIZED VIEW.
- Understanding of concurrent refresh and the unique-index requirement.
Setup & Project Structure
Work in the league database from the previous exercise, ensuring it has enough data to make aggregates interesting — several teams, a couple of dozen players, and many appearances across multiple matches. If needed, generate sample appearances with a quick INSERT ... SELECT using generate_series so leaderboards and trends have substance.
Plan the dashboard's panels before writing SQL: a top-scorers leaderboard, a per-match batting ranking, a player form trend (runs vs previous match), and a team standings table. Each maps to a query technique from this module, and the standings — the most expensive — will become the materialized view.
-- Seed extra appearances so analytics have substance (adjust ids to your data)
INSERT INTO match (format, starts_at)
SELECT 'T20', now() - (g || ' days')::interval
FROM generate_series(1, 10) g;
INSERT INTO appearance (match_id, player_id, runs)
SELECT m.id, p.id, (random() * 80)::int
FROM match m CROSS JOIN player p
ON CONFLICT (match_id, player_id) DO NOTHING; -- safe if some pairs existStep 1 — Leaderboards with Aggregation and Ranking
Build the top-scorers leaderboard: sum each player's runs across all appearances, then rank them. Use a CTE to compute per-player totals and a window RANK over that result so ties are handled, returning the top players with their rank. This combines GROUP BY aggregation with a window ranking in a readable two-step pipeline.
Then build a per-match batting ranking that, within each match, ranks players by runs using PARTITION BY. This keeps every appearance row while annotating it with its position in that match — exactly the window-function pattern for top-N-per-group, which you can filter in an outer query to show only the top three per match.
-- Global top-scorer leaderboard (aggregate + window rank)
WITH totals AS (
SELECT player_id, SUM(runs) AS runs FROM appearance GROUP BY player_id)
SELECT p.name, t.runs, RANK() OVER (ORDER BY t.runs DESC) AS rank
FROM totals t JOIN player p ON p.id = t.player_id
ORDER BY rank LIMIT 10;
-- Top 3 batters per match (partitioned ranking, filtered in outer query)
WITH ranked AS (
SELECT a.match_id, p.name, a.runs,
RANK() OVER (PARTITION BY a.match_id ORDER BY a.runs DESC) AS r
FROM appearance a JOIN player p ON p.id = a.player_id)
SELECT * FROM ranked WHERE r <= 3 ORDER BY match_id, r;Step 2 — Trends with Window Functions and CTEs
Build a player form trend: for each player, show each match's runs alongside the change from their previous match using LAG, and a running cumulative total using a windowed SUM. Partition by player and order by match time so each player's sequence is computed independently and in order.
This panel demonstrates the comparative power of window functions: LAG gives period-over-period change without a self-join, and the running SUM produces a cumulative line for a chart. Both keep every row, so the dashboard can plot the full sequence per player rather than a single summary number.
SELECT p.name, a.match_id, m.starts_at, a.runs,
a.runs - LAG(a.runs) OVER w AS change_vs_prev,
SUM(a.runs) OVER w AS season_running_total
FROM appearance a
JOIN player p ON p.id = a.player_id
JOIN match m ON m.id = a.match_id
WINDOW w AS (PARTITION BY a.player_id ORDER BY m.starts_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
ORDER BY p.name, m.starts_at;Step 3 — Materialized Standings with Concurrent Refresh
Build the team standings as a materialized view: per team, the number of matches, total runs, and average runs per appearance — an expensive multi-table aggregation the dashboard reads constantly. Create a unique index on the team id so the view can be refreshed concurrently without blocking the dashboard's readers.
Then demonstrate the refresh workflow: query the materialized view (fast), simulate new data, and run REFRESH MATERIALIZED VIEW CONCURRENTLY to update it without a stall. In production this refresh would be scheduled with pg_cron at an interval matching how fresh the standings need to be.
CREATE MATERIALIZED VIEW dashboard_standings AS
SELECT t.id AS team_id, t.name,
COUNT(DISTINCT a.match_id) AS matches_played,
SUM(a.runs) AS total_runs,
ROUND(AVG(a.runs), 1) AS avg_runs_per_innings
FROM team t
JOIN player p ON p.team_id = t.id
JOIN appearance a ON a.player_id = p.id
GROUP BY t.id, t.name;
CREATE UNIQUE INDEX ON dashboard_standings (team_id); -- enables concurrent refresh
SELECT * FROM dashboard_standings ORDER BY total_runs DESC; -- fast read
REFRESH MATERIALIZED VIEW CONCURRENTLY dashboard_standings; -- non-blocking updateStep 4 — Testing & Verification
Confirm each dashboard panel produces correct, sensible results and that the materialized view refreshes without blocking. Cross-check the materialized standings against a live equivalent query to ensure the precomputation matches reality, and verify the leaderboard ranks and running totals are internally consistent.
-- Materialized standings should match a live aggregation (same numbers)
SELECT team_id, total_runs FROM dashboard_standings ORDER BY team_id;
SELECT p.team_id, SUM(a.runs)
FROM player p JOIN appearance a ON a.player_id = p.id
GROUP BY p.team_id ORDER BY p.team_id; -- compare to the row above
-- Running total at a player's last match should equal their grand total
WITH t AS (
SELECT player_id, SUM(runs) AS grand FROM appearance GROUP BY player_id)
SELECT * FROM t ORDER BY grand DESC LIMIT 5;
-- Confirm the unique index exists (required for concurrent refresh)
SELECT indexname FROM pg_indexes WHERE tablename = 'dashboard_standings';Warning: A materialized view serves stale data until refreshed, so a dashboard reading it can silently show outdated standings if no refresh is scheduled. Decide the acceptable staleness, schedule a concurrent refresh to match, and never point a panel that must be transactionally current at a materialized view — use a live query or regular view there instead.
Extension Challenge: Add a 'last 5 matches form' panel using a window frame of ROWS BETWEEN 4 PRECEDING AND CURRENT ROW to compute a rolling average, and a percentile ranking of players using PERCENT_RANK or NTILE(4) to bucket them into quartiles. Then schedule the materialized-view refresh with pg_cron (previewed now, covered fully in a later lesson) and measure the read latency difference versus the live aggregation.
- Combine CTEs, aggregation, and window ranking to build leaderboards and top-N-per-group panels.
- Use LAG for period-over-period change and windowed SUM for running totals — sequential, row-preserving analytics.
- Precompute expensive, read-heavy aggregations (standings) into a materialized view for instant reads.
- Create a unique index on the materialized view to enable non-blocking REFRESH ... CONCURRENTLY.
- Validate a materialized view against a live equivalent query to ensure the snapshot is correct.
- Match refresh frequency to required freshness; never use a stale snapshot where current data is required.