Skip to content

Commit 5cb4e89

Browse files
authored
feat: migrate leaderboard to PostgreSQL functions via Hasura (#95)
1 parent 90e9c47 commit 5cb4e89

13 files changed

Lines changed: 382 additions & 659 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
CREATE OR REPLACE FUNCTION public.get_leaderboard(
2+
_category TEXT,
3+
_window_days INT,
4+
_match_type TEXT DEFAULT NULL,
5+
_exclude_tournaments BOOLEAN DEFAULT FALSE
6+
)
7+
RETURNS SETOF public.leaderboard_entries
8+
LANGUAGE plpgsql STABLE
9+
AS $$
10+
BEGIN
11+
IF _category = 'elo' THEN
12+
RETURN QUERY SELECT * FROM _leaderboard_elo(_window_days, _match_type, _exclude_tournaments);
13+
14+
ELSIF _category = 'best_kdr' THEN
15+
RETURN QUERY SELECT * FROM _leaderboard_kdr(_window_days, _match_type, _exclude_tournaments);
16+
17+
ELSIF _category = 'best_win_rate' THEN
18+
RETURN QUERY SELECT * FROM _leaderboard_win_rate(_window_days, _match_type, _exclude_tournaments);
19+
20+
ELSIF _category = 'highest_hs_pct' THEN
21+
RETURN QUERY SELECT * FROM _leaderboard_hs_pct(_window_days, _match_type, _exclude_tournaments);
22+
23+
ELSE
24+
RAISE EXCEPTION 'Invalid category: %. Must be one of: elo, best_kdr, best_win_rate, highest_hs_pct', _category;
25+
END IF;
26+
END;
27+
$$;
28+
29+
-- ============================================================
30+
-- ELO leaderboard
31+
-- value = current ELO, secondary = ELO change, tertiary = win streak
32+
-- ============================================================
33+
CREATE OR REPLACE FUNCTION public._leaderboard_elo(
34+
_window_days INT,
35+
_match_type TEXT,
36+
_exclude_tournaments BOOLEAN
37+
)
38+
RETURNS SETOF public.leaderboard_entries
39+
LANGUAGE plpgsql STABLE
40+
AS $$
41+
BEGIN
42+
IF _exclude_tournaments THEN
43+
RETURN QUERY
44+
WITH last_elo_raw AS (
45+
SELECT DISTINCT ON (pe.steam_id)
46+
pe.steam_id,
47+
pe.current as raw_current
48+
FROM player_elo pe
49+
WHERE 1=1
50+
AND (_match_type IS NULL OR pe.type = _match_type)
51+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
52+
ORDER BY pe.steam_id, pe.created_at DESC
53+
),
54+
tournament_adj AS (
55+
SELECT pe.steam_id, SUM(pe.change) as tourney_total
56+
FROM player_elo pe
57+
WHERE 1=1
58+
AND (_match_type IS NULL OR pe.type = _match_type)
59+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
60+
AND EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pe.match_id)
61+
GROUP BY pe.steam_id
62+
),
63+
first_elo AS (
64+
SELECT DISTINCT ON (pe.steam_id)
65+
pe.steam_id,
66+
pe.current - pe.change as starting_elo
67+
FROM player_elo pe
68+
WHERE 1=1
69+
AND (_match_type IS NULL OR pe.type = _match_type)
70+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
71+
ORDER BY pe.steam_id, pe.created_at ASC
72+
),
73+
match_counts AS (
74+
SELECT pe.steam_id, COUNT(*)::int as matches_played
75+
FROM player_elo pe
76+
WHERE 1=1
77+
AND (_match_type IS NULL OR pe.type = _match_type)
78+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
79+
AND NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pe.match_id)
80+
GROUP BY pe.steam_id
81+
),
82+
win_streak AS (
83+
SELECT sub.steam_id,
84+
COALESCE(MIN(CASE WHEN sub.won = 0 THEN sub.rn END) - 1, MAX(sub.rn))::int as streak
85+
FROM (
86+
SELECT
87+
mlp.steam_id,
88+
CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won,
89+
ROW_NUMBER() OVER (PARTITION BY mlp.steam_id ORDER BY m.ended_at DESC) as rn
90+
FROM match_lineup_players mlp
91+
JOIN match_lineups ml ON ml.id = mlp.match_lineup_id
92+
JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id)
93+
JOIN match_options mo ON mo.id = m.match_options_id
94+
WHERE m.status = 'Finished'
95+
AND mlp.steam_id IS NOT NULL
96+
AND m.winning_lineup_id IS NOT NULL
97+
AND (_window_days = 0 OR m.ended_at >= NOW() - make_interval(days => _window_days))
98+
AND (_match_type IS NULL OR mo.type = _match_type)
99+
AND NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = m.id)
100+
) sub
101+
GROUP BY sub.steam_id
102+
)
103+
SELECT
104+
le.steam_id::text as player_steam_id,
105+
p.name as player_name,
106+
p.avatar_url as player_avatar_url,
107+
p.country as player_country,
108+
(le.raw_current - COALESCE(ta.tourney_total, 0))::float as value,
109+
((le.raw_current - COALESCE(ta.tourney_total, 0)) - fe.starting_elo)::float as secondary_value,
110+
COALESCE(ws.streak, 0)::float as tertiary_value,
111+
COALESCE(mc.matches_played, 0)::int as matches_played
112+
FROM last_elo_raw le
113+
LEFT JOIN tournament_adj ta ON ta.steam_id = le.steam_id
114+
JOIN first_elo fe ON fe.steam_id = le.steam_id
115+
LEFT JOIN match_counts mc ON mc.steam_id = le.steam_id
116+
LEFT JOIN win_streak ws ON ws.steam_id = le.steam_id
117+
JOIN players p ON p.steam_id = le.steam_id
118+
ORDER BY value DESC;
119+
120+
ELSE
121+
RETURN QUERY
122+
WITH last_elo AS (
123+
SELECT DISTINCT ON (pe.steam_id)
124+
pe.steam_id,
125+
pe.current as current_elo
126+
FROM player_elo pe
127+
WHERE 1=1
128+
AND (_match_type IS NULL OR pe.type = _match_type)
129+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
130+
ORDER BY pe.steam_id, pe.created_at DESC
131+
),
132+
first_elo AS (
133+
SELECT DISTINCT ON (pe.steam_id)
134+
pe.steam_id,
135+
pe.current - pe.change as starting_elo
136+
FROM player_elo pe
137+
WHERE 1=1
138+
AND (_match_type IS NULL OR pe.type = _match_type)
139+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
140+
ORDER BY pe.steam_id, pe.created_at ASC
141+
),
142+
match_counts AS (
143+
SELECT pe.steam_id, COUNT(*)::int as matches_played
144+
FROM player_elo pe
145+
WHERE 1=1
146+
AND (_match_type IS NULL OR pe.type = _match_type)
147+
AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days))
148+
GROUP BY pe.steam_id
149+
),
150+
win_streak AS (
151+
SELECT sub.steam_id,
152+
COALESCE(MIN(CASE WHEN sub.won = 0 THEN sub.rn END) - 1, MAX(sub.rn))::int as streak
153+
FROM (
154+
SELECT
155+
mlp.steam_id,
156+
CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won,
157+
ROW_NUMBER() OVER (PARTITION BY mlp.steam_id ORDER BY m.ended_at DESC) as rn
158+
FROM match_lineup_players mlp
159+
JOIN match_lineups ml ON ml.id = mlp.match_lineup_id
160+
JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id)
161+
JOIN match_options mo ON mo.id = m.match_options_id
162+
WHERE m.status = 'Finished'
163+
AND mlp.steam_id IS NOT NULL
164+
AND m.winning_lineup_id IS NOT NULL
165+
AND (_window_days = 0 OR m.ended_at >= NOW() - make_interval(days => _window_days))
166+
AND (_match_type IS NULL OR mo.type = _match_type)
167+
) sub
168+
GROUP BY sub.steam_id
169+
)
170+
SELECT
171+
le.steam_id::text as player_steam_id,
172+
p.name as player_name,
173+
p.avatar_url as player_avatar_url,
174+
p.country as player_country,
175+
le.current_elo::float as value,
176+
(le.current_elo - fe.starting_elo)::float as secondary_value,
177+
COALESCE(ws.streak, 0)::float as tertiary_value,
178+
mc.matches_played::int as matches_played
179+
FROM last_elo le
180+
JOIN first_elo fe ON fe.steam_id = le.steam_id
181+
JOIN match_counts mc ON mc.steam_id = le.steam_id
182+
LEFT JOIN win_streak ws ON ws.steam_id = le.steam_id
183+
JOIN players p ON p.steam_id = le.steam_id
184+
ORDER BY value DESC;
185+
END IF;
186+
END;
187+
$$;
188+
189+
-- ============================================================
190+
-- K/D Ratio leaderboard
191+
-- value = K/D ratio, secondary = kills, tertiary = deaths
192+
-- ============================================================
193+
CREATE OR REPLACE FUNCTION public._leaderboard_kdr(
194+
_window_days INT,
195+
_match_type TEXT,
196+
_exclude_tournaments BOOLEAN
197+
)
198+
RETURNS SETOF public.leaderboard_entries
199+
LANGUAGE plpgsql STABLE
200+
AS $$
201+
BEGIN
202+
RETURN QUERY
203+
WITH kills AS (
204+
SELECT
205+
pk.attacker_steam_id as steam_id,
206+
COUNT(*) as kill_count,
207+
COUNT(DISTINCT pk.match_id)::int as match_count
208+
FROM player_kills pk
209+
LEFT JOIN matches m ON (_match_type IS NOT NULL AND m.id = pk.match_id)
210+
LEFT JOIN match_options mo ON (_match_type IS NOT NULL AND mo.id = m.match_options_id)
211+
WHERE pk.attacker_steam_id IS NOT NULL
212+
AND pk.attacker_steam_id != pk.attacked_steam_id
213+
AND (_window_days = 0 OR pk.time >= NOW() - make_interval(days => _window_days))
214+
AND (_match_type IS NULL OR mo.type = _match_type)
215+
AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pk.match_id))
216+
GROUP BY pk.attacker_steam_id
217+
),
218+
deaths AS (
219+
SELECT
220+
dk.attacked_steam_id as steam_id,
221+
COUNT(*) as death_count
222+
FROM player_kills dk
223+
LEFT JOIN matches m2 ON (_match_type IS NOT NULL AND m2.id = dk.match_id)
224+
LEFT JOIN match_options mo2 ON (_match_type IS NOT NULL AND mo2.id = m2.match_options_id)
225+
WHERE 1=1
226+
AND (_window_days = 0 OR dk.time >= NOW() - make_interval(days => _window_days))
227+
AND (_match_type IS NULL OR mo2.type = _match_type)
228+
AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = dk.match_id))
229+
GROUP BY dk.attacked_steam_id
230+
)
231+
SELECT
232+
k.steam_id::text as player_steam_id,
233+
p.name as player_name,
234+
p.avatar_url as player_avatar_url,
235+
p.country as player_country,
236+
CASE WHEN COALESCE(d.death_count, 0) = 0
237+
THEN k.kill_count::float
238+
ELSE ROUND((k.kill_count::numeric / d.death_count::numeric), 2)::float
239+
END as value,
240+
k.kill_count::float as secondary_value,
241+
COALESCE(d.death_count, 0)::float as tertiary_value,
242+
k.match_count as matches_played
243+
FROM kills k
244+
LEFT JOIN deaths d ON d.steam_id = k.steam_id
245+
JOIN players p ON p.steam_id = k.steam_id
246+
WHERE k.match_count >= 5
247+
ORDER BY value DESC;
248+
END;
249+
$$;
250+
251+
-- ============================================================
252+
-- Win Rate leaderboard
253+
-- value = win%, secondary = wins, tertiary = losses
254+
-- ============================================================
255+
CREATE OR REPLACE FUNCTION public._leaderboard_win_rate(
256+
_window_days INT,
257+
_match_type TEXT,
258+
_exclude_tournaments BOOLEAN
259+
)
260+
RETURNS SETOF public.leaderboard_entries
261+
LANGUAGE plpgsql STABLE
262+
AS $$
263+
BEGIN
264+
RETURN QUERY
265+
WITH player_matches AS (
266+
SELECT
267+
mlp.steam_id,
268+
m.id as match_id,
269+
CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won
270+
FROM match_lineup_players mlp
271+
JOIN match_lineups ml ON ml.id = mlp.match_lineup_id
272+
JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id)
273+
JOIN match_options mo ON mo.id = m.match_options_id
274+
WHERE m.status = 'Finished'
275+
AND mlp.steam_id IS NOT NULL
276+
AND m.winning_lineup_id IS NOT NULL
277+
AND (_window_days = 0 OR m.ended_at >= NOW() - make_interval(days => _window_days))
278+
AND (_match_type IS NULL OR mo.type = _match_type)
279+
AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = m.id))
280+
)
281+
SELECT
282+
pm.steam_id::text as player_steam_id,
283+
p.name as player_name,
284+
p.avatar_url as player_avatar_url,
285+
p.country as player_country,
286+
ROUND((SUM(pm.won)::numeric / COUNT(*)::numeric) * 100, 2)::float as value,
287+
SUM(pm.won)::float as secondary_value,
288+
(COUNT(*) - SUM(pm.won))::float as tertiary_value,
289+
COUNT(*)::int as matches_played
290+
FROM player_matches pm
291+
JOIN players p ON p.steam_id = pm.steam_id
292+
GROUP BY pm.steam_id, p.name, p.avatar_url, p.country
293+
HAVING COUNT(*) >= 5
294+
ORDER BY value DESC;
295+
END;
296+
$$;
297+
298+
-- ============================================================
299+
-- Headshot % leaderboard
300+
-- value = HS%, secondary = total kills, tertiary = null
301+
-- ============================================================
302+
CREATE OR REPLACE FUNCTION public._leaderboard_hs_pct(
303+
_window_days INT,
304+
_match_type TEXT,
305+
_exclude_tournaments BOOLEAN
306+
)
307+
RETURNS SETOF public.leaderboard_entries
308+
LANGUAGE plpgsql STABLE
309+
AS $$
310+
BEGIN
311+
RETURN QUERY
312+
SELECT
313+
pk.attacker_steam_id::text as player_steam_id,
314+
p.name as player_name,
315+
p.avatar_url as player_avatar_url,
316+
p.country as player_country,
317+
ROUND((SUM(CASE WHEN pk.headshot THEN 1 ELSE 0 END)::numeric / COUNT(*)::numeric) * 100, 2)::float as value,
318+
COUNT(*)::float as secondary_value,
319+
NULL::float as tertiary_value,
320+
COUNT(DISTINCT pk.match_id)::int as matches_played
321+
FROM player_kills pk
322+
JOIN players p ON p.steam_id = pk.attacker_steam_id
323+
LEFT JOIN matches m ON (_match_type IS NOT NULL AND m.id = pk.match_id)
324+
LEFT JOIN match_options mo ON (_match_type IS NOT NULL AND mo.id = m.match_options_id)
325+
WHERE pk.attacker_steam_id IS NOT NULL
326+
AND pk.attacker_steam_id != pk.attacked_steam_id
327+
AND (_window_days = 0 OR pk.time >= NOW() - make_interval(days => _window_days))
328+
AND (_match_type IS NULL OR mo.type = _match_type)
329+
AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pk.match_id))
330+
GROUP BY pk.attacker_steam_id, p.name, p.avatar_url, p.country
331+
HAVING COUNT(*) >= 25
332+
ORDER BY value DESC;
333+
END;
334+
$$;

hasura/metadata/actions.graphql

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -334,36 +334,6 @@ type Mutation {
334334
): SuccessOutput
335335
}
336336

337-
type Query {
338-
getLeaderboard(
339-
category: String!
340-
window_days: Int!
341-
match_type: String
342-
limit: Int
343-
offset: Int
344-
exclude_tournaments: Boolean
345-
sort_by: String
346-
sort_dir: String
347-
): LeaderboardResponse!
348-
}
349-
350-
type LeaderboardResponse {
351-
entries: [LeaderboardEntry!]!
352-
total: Int!
353-
}
354-
355-
type LeaderboardEntry {
356-
rank: Int!
357-
player_steam_id: String!
358-
player_name: String!
359-
player_avatar_url: String
360-
player_country: String
361-
value: Float!
362-
secondary_value: Float
363-
tertiary_value: Float
364-
matches_played: Int
365-
}
366-
367337
input SampleInput {
368338
username: String!
369339
password: String!

hasura/metadata/actions.yaml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -443,14 +443,6 @@ actions:
443443
permissions:
444444
- role: administrator
445445
comment: Write content to file on game server
446-
- name: getLeaderboard
447-
definition:
448-
kind: ""
449-
handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}'
450-
forward_client_headers: true
451-
permissions:
452-
- role: guest
453-
comment: Get leaderboard rankings by category and time window
454446
custom_types:
455447
enums: []
456448
input_objects:
@@ -504,6 +496,4 @@ custom_types:
504496
- name: StorageStats
505497
- name: StorageSummary
506498
- name: TableSizeInfo
507-
- name: LeaderboardResponse
508-
- name: LeaderboardEntry
509499
scalars: []

hasura/metadata/databases/databases.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212
retries: 1
1313
use_prepared_statements: true
1414
tables: "!include default/tables/tables.yaml"
15+
functions: "!include default/functions/functions.yaml"

0 commit comments

Comments
 (0)