As part of a technical take-home assessment, I was presented with the following three tables and asked to manipulate them using SQL.
Article Summary
Domain: Gaming Data Engineering & Technical SQL Assessment
Core Objective: Demonstrate production-grade SQL data manipulation, analytical CTE structuring, timestamp/epoch arithmetic, window function navigation, and dynamic Jinja/dbt metaprogramming across player telemetry tables.
Methodology: Explicit type casting (
::INT,TO_TIMESTAMP), defensive joining (LEFT JOINto identify orphan records and unobserved churn buckets), windowedLEAD()event intervals, andgenerate_series()array expansions for dynamic unpivoting.Key Solutions:
Data Cleaning & Aggregation: Materialized cleaned timestamp views and computed active session lengths using epoch difference extraction (
EXTRACT(EPOCH FROM ...)).Windowed Inter-Event Timing: Deployed
LEAD(ts, 1) OVER (PARTITION BY player_id, session_id)to isolate intra-session action latencies while explicitly flagging stakeholder ambiguity regarding grouping granularities.Dynamic Pivot & Metaprogramming: Handled sparse dimensional data (missing Level 3 churn) via
COALESCEwithgenerate_series(), and provided a scalable dbt/Jinja macro solution to dynamically unpivot rows to columns based on target table invariants.
Table name: player
| player_id | rank | gold | joined_date |
|---|---|---|---|
| 275 | 35 | 184060 | 23/10/2014 03:02 |
| 656 | 43 | 1416769 | 31/10/2014 11:58 |
| 1292 | 1 | 0 | 03/11/2014 09:12 |
| ... | ... | ... | ... |
Table name: session
| session_id | player_id | platform | install | ts | date_utc |
|---|---|---|---|---|---|
| 1 | 32 | ios | TRUE | 24/09/2014 21:59 | 24/09/2014 |
| 101 | 5307 | ios | TRUE | 14/11/2014 22:42 | 14/11/2014 |
| 107 | 5517 | ios | TRUE | 15/11/2014 11:45 | 15/11/2014 |
| ... | ... | ... | ... | ... | ... |
Table name: churn
| level | count_of_churned_players |
|---|---|
| 1 | 500 |
| 2 | 300 |
| 4 | 150 |
| 5 | 200 |
1. Create a view which selects average gold inventory per level (rank) for users who joined after 3/3/2014, reaching rank higher than 10, where average gold is below 1000.
CREATE VIEW avg_gold_per_level AS
SELECT
rank::INT AS rank,
AVG(gold) AS avg_gold
FROM player
WHERE TO_TIMESTAMP(joined_date, 'DD/MM/YYYY HH24:MI')::DATE > '2014-03-03'::DATE
AND rank::INT > 10
GROUP by rank::INT
HAVING AVG(gold) < 1000;
2. Select all session records for users reaching rank higher than 50.
WITH
users_higher_50 AS (
SELECT
player_id::INT AS player_id
FROM player
WHERE rank::INT > 50
)
SELECT
users.player_id,
sessions.session_id,
sessions.platform,
sessions.install::BOOLEAN AS install,
TO_TIMESTAMP(sessions.ts, 'DD/MM/YYYY HH24:MI') AS ts,
TO_DATE(sessions.date_utc, 'DD/MM/YYYY') AS date_utc
FROM users_higher_50 AS users
INNER JOIN session AS sessions
ON users.player_id = sessions.player_id::INT
ORDER BY users.player_id, sessions.session_id, ts DESC;
3. Select average session length for users reaching rank higher than 50.
WITH
users_higher_50 AS (
SELECT
player_id::INT AS player_id
FROM player
WHERE rank::INT > 50
),
sessions_cleaned AS (
SELECT
users.player_id,
sessions.session_id::INT AS session_id,
TO_TIMESTAMP(sessions.ts, 'DD/MM/YYYY HH24:MI') AS ts_cleaned
FROM users_higher_50 AS users
INNER JOIN session AS sessions
ON users.player_id = sessions.player_id::INT
),
sessions_agg AS (
SELECT
player_id,
session_id,
MIN(ts_cleaned) AS session_start_ts,
MAX(ts_cleaned) AS session_end_ts
FROM sessions_cleaned
GROUP BY player_id, session_id
),
session_length AS (
SELECT
player_id,
session_id,
EXTRACT(EPOCH FROM (session_end_ts - session_start_ts)) AS session_length_seconds
FROM sessions_agg
)
SELECT
AVG(session_length_seconds) AS avg_session_length_seconds,
AVG(session_length_seconds) / 60 AS avg_session_length_minutes
FROM session_length;
4. Select all users who don’t have any session record (they don’t appear in session table)
SELECT
player.player_id::INT AS player_id
FROM player
LEFT JOIN session
ON player.player_id::INT = session.player_id::INT
WHERE session.session_id IS NULL;
5. Select average time for player’s actions. A player’s action lasts until the next session record of a player id, having the same session id.
WITH
sessions_cleaned AS (
SELECT
player_id::INT AS player_id,
session_id::INT AS session_id,
TO_TIMESTAMP(ts, 'DD/MM/YYYY HH24:MI') AS ts_cleaned
FROM session
),
sessions_previous_ts AS (
SELECT
player_id,
session_id,
ts_cleaned AS action_ts,
LEAD(ts_cleaned, 1) OVER (PARTITION BY player_id, session_id ORDER BY ts_cleaned ASC) AS next_action_ts
FROM sessions_cleaned
),
session_time_between_actions AS (
SELECT
player_id,
session_id,
EXTRACT(EPOCH FROM (next_action_ts - action_ts)) AS seconds_between_actions
FROM sessions_previous_ts
WHERE next_action_ts IS NOT NULL
)
SELECT
player_id,
-- session_id -- The example is ambiguous as to whether this is a per-player or per-player-per-session metric, so I'm leaving this here but commented out. I would ask for clarification from stakeholders.
AVG(seconds_between_actions) AS avg_time_between_actions_seconds,
AVG(seconds_between_actions) / 60 AS avg_time_between_actions_minutes
FROM session_time_between_actions
GROUP BY player_id; --, session_id
6. Taking into consideration Churn Sheet, pivot the data to have the Level on rows instead of columns. Level 3 should also appear in the results with 0 value.
WITH
max_level AS (
SELECT
MAX(level::INT) AS max_level
FROM Churn
),
all_levels AS (
SELECT
generate_series(1, (SELECT max_level FROM max_level)) AS level
),
churn_complete AS (
-- Left join ensures level 3 exists with a count of 0
SELECT
all_levels.level,
COALESCE(Churn.count_of_churned_players, 0) AS count_of_churned_players
FROM all_levels
LEFT JOIN churn
ON all_levels.level = churn.level::INT
)
SELECT
SUM(CASE WHEN level = 1 THEN count_of_churned_players END) AS level_1,
SUM(CASE WHEN level = 2 THEN count_of_churned_players END) AS level_2,
SUM(CASE WHEN level = 3 THEN count_of_churned_players END) AS level_3,
SUM(CASE WHEN level = 4 THEN count_of_churned_players END) AS level_4,
SUM(CASE WHEN level = 5 THEN count_of_churned_players END) AS level_5
FROM churn_complete;
For dynamic situations, I would utilize dbt and jinja to do this:
{% set max_level_query %}
SELECT MAX(level::INT) FROM churn
{% endset %}
{% if execute %}
{% set max_level = run_query(max_level_query).columns[0][0] %}
{% else %}
{% set max_level = 5 %}
{% endif %}
WITH max_level AS (
SELECT MAX(level::INT) AS max_lvl FROM churn
),
all_levels AS (
SELECT generate_series(1, (SELECT max_lvl FROM max_level)) AS level
),
churn_complete AS (
SELECT
all_levels.level,
COALESCE(churn.count_of_churned_players, 0) AS count_of_churned_players
FROM all_levels
LEFT JOIN churn
ON all_levels.level = churn.level::INT
)
SELECT
{% for lvl in range(1, max_level + 1) %}
SUM(CASE WHEN level = {{ lvl }} THEN count_of_churned_players END) AS level_{{ lvl }}{% if not loop.last %},{% endif %}
{% endfor %}
FROM churn_complete;