| 1 |
|
|---|
| 2 | -- ddl
|
|---|
| 3 |
|
|---|
| 4 | CREATE TABLE matches
|
|---|
| 5 | (
|
|---|
| 6 | id BIGINT,
|
|---|
| 7 | game_id BIGINT NOT NULL DEFAULT 0 REFERENCES games(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 8 | game_mode_id BIGINT NOT NULL DEFAULT 0 REFERENCES game_modes(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 9 | tournament_id BIGINT NOT NULL DEFAULT 0 REFERENCES tournaments(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 10 | server_id BIGINT NOT NULL DEFAULT 0 REFERENCES servers(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 11 | status_id BIGINT NOT NULL DEFAULT 1 REFERENCES match_statuses(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 12 | started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Part of PK, must be NOT NULL
|
|---|
| 13 | ended_at TIMESTAMP,
|
|---|
| 14 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 15 | CHECK (ended_at IS NULL OR started_at IS NULL OR ended_at >= started_at),
|
|---|
| 16 | PRIMARY KEY (id, started_at)
|
|---|
| 17 | ) PARTITION BY RANGE (started_at);
|
|---|
| 18 |
|
|---|
| 19 | CREATE TABLE match_teams
|
|---|
| 20 | (
|
|---|
| 21 | match_id BIGINT NOT NULL DEFAULT 0,
|
|---|
| 22 | match_started_at TIMESTAMP NOT NULL, -- Added to satisfy FK constraint
|
|---|
| 23 | team_id BIGINT NOT NULL DEFAULT 0 REFERENCES teams(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 24 | side_label VARCHAR(20),
|
|---|
| 25 | PRIMARY KEY (match_id, team_id),
|
|---|
| 26 | FOREIGN KEY (match_id, match_started_at) REFERENCES matches(id, started_at) ON DELETE CASCADE ON UPDATE CASCADE
|
|---|
| 27 | );
|
|---|
| 28 |
|
|---|
| 29 | CREATE TABLE match_participants
|
|---|
| 30 | (
|
|---|
| 31 | id BIGINT PRIMARY KEY,
|
|---|
| 32 | match_id BIGINT NOT NULL DEFAULT 0,
|
|---|
| 33 | match_started_at TIMESTAMP NOT NULL, -- Added to satisfy FK constraint
|
|---|
| 34 | user_id BIGINT NOT NULL DEFAULT 0 REFERENCES users(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 35 | team_id BIGINT NOT NULL DEFAULT 0 REFERENCES teams(id) ON DELETE SET DEFAULT ON UPDATE CASCADE,
|
|---|
| 36 | result_id BIGINT DEFAULT NULL REFERENCES participant_results(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
|---|
| 37 | score INT CHECK (score >= 0),
|
|---|
| 38 | elo_change INT NOT NULL DEFAULT 0,
|
|---|
| 39 | joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 40 | UNIQUE (match_id, user_id),
|
|---|
| 41 | FOREIGN KEY (match_id, team_id) REFERENCES match_teams(match_id, team_id),
|
|---|
| 42 | FOREIGN KEY (match_id, match_started_at) REFERENCES matches(id, started_at) ON DELETE CASCADE ON UPDATE CASCADE
|
|---|
| 43 | );
|
|---|
| 44 |
|
|---|
| 45 |
|
|---|
| 46 | -- views
|
|---|
| 47 |
|
|---|
| 48 | CREATE OR REPLACE VIEW public.leaderboard
|
|---|
| 49 | AS SELECT u.id AS user_id,
|
|---|
| 50 | u.username,
|
|---|
| 51 | p.rank_points,
|
|---|
| 52 | l.country,
|
|---|
| 53 | p.avatar_url,
|
|---|
| 54 | count(DISTINCT mp.id) AS matches_played,
|
|---|
| 55 | count(DISTINCT mp.id) FILTER (WHERE pr.name = 'win') AS wins,
|
|---|
| 56 | CASE
|
|---|
| 57 | WHEN count(DISTINCT mp.id) = 0 THEN 0::numeric
|
|---|
| 58 | ELSE round(count(DISTINCT mp.id) FILTER (WHERE pr.name = 'win')::numeric * 100.0 / count(DISTINCT mp.id)::numeric, 2)
|
|---|
| 59 | END AS win_rate
|
|---|
| 60 | FROM users u
|
|---|
| 61 | JOIN profiles p ON p.user_id = u.id
|
|---|
| 62 | JOIN locations l ON l.id = u.location_id
|
|---|
| 63 | LEFT JOIN match_participants mp ON mp.user_id = u.id
|
|---|
| 64 | LEFT JOIN participant_results pr ON pr.id = mp.result_id
|
|---|
| 65 | GROUP BY u.id, u.username, p.rank_points, l.country, p.avatar_url
|
|---|
| 66 | ORDER BY p.rank_points DESC;
|
|---|
| 67 |
|
|---|
| 68 | CREATE OR REPLACE VIEW public.match_history
|
|---|
| 69 | AS SELECT m.id AS match_id,
|
|---|
| 70 | g.title AS game,
|
|---|
| 71 | gm.mode_name AS game_mode,
|
|---|
| 72 | t.name AS tournament_name,
|
|---|
| 73 | m.started_at,
|
|---|
| 74 | m.ended_at,
|
|---|
| 75 | EXTRACT(epoch FROM m.ended_at - m.started_at) / 60::numeric AS duration_minutes,
|
|---|
| 76 | l.latitude AS server_lat,
|
|---|
| 77 | l.longitude AS server_lon
|
|---|
| 78 | FROM matches m
|
|---|
| 79 | JOIN games g ON g.id = m.game_id
|
|---|
| 80 | JOIN game_modes gm ON gm.id = m.game_mode_id
|
|---|
| 81 | LEFT JOIN tournaments t ON t.id = m.tournament_id
|
|---|
| 82 | JOIN servers s ON s.id = m.server_id
|
|---|
| 83 | JOIN locations l ON l.id = s.location_id
|
|---|
| 84 | WHERE m.ended_at IS NOT NULL;
|
|---|
| 85 |
|
|---|
| 86 | CREATE OR REPLACE VIEW public.player_match_stats
|
|---|
| 87 | AS SELECT mp.id AS user_match_id,
|
|---|
| 88 | u.id AS user_id,
|
|---|
| 89 | u.username,
|
|---|
| 90 | m.id AS match_id,
|
|---|
| 91 | g.title AS game,
|
|---|
| 92 | pr.name AS result,
|
|---|
| 93 | mp.score,
|
|---|
| 94 | mp.elo_change,
|
|---|
| 95 | mp.joined_at,
|
|---|
| 96 | jsonb_object_agg(st.name, ps.value) FILTER (WHERE st.id IS NOT NULL) AS stats
|
|---|
| 97 | FROM match_participants mp
|
|---|
| 98 | JOIN users u ON u.id = mp.user_id
|
|---|
| 99 | JOIN matches m ON m.id = mp.match_id AND m.started_at = mp.match_started_at
|
|---|
| 100 | JOIN games g ON g.id = m.game_id
|
|---|
| 101 | LEFT JOIN participant_results pr ON pr.id = mp.result_id
|
|---|
| 102 | LEFT JOIN participant_stats ps ON ps.match_participant_id = mp.id
|
|---|
| 103 | LEFT JOIN stat_types st ON st.id = ps.stat_type_id
|
|---|
| 104 | GROUP BY mp.id, u.id, u.username, m.id, g.title, pr.name, mp.score, mp.elo_change, mp.joined_at;
|
|---|
| 105 |
|
|---|
| 106 |
|
|---|
| 107 |
|
|---|
| 108 | -- fn, proc, trigg
|
|---|
| 109 |
|
|---|
| 110 | CREATE OR REPLACE PROCEDURE public.create_match(
|
|---|
| 111 | IN p_match_id bigint,
|
|---|
| 112 | IN p_game_id bigint,
|
|---|
| 113 | IN p_game_mode_id bigint,
|
|---|
| 114 | IN p_tournament_id bigint,
|
|---|
| 115 | IN p_server_id bigint,
|
|---|
| 116 | IN p_participants jsonb,
|
|---|
| 117 | OUT p_started_at timestamp -- Added OUT parameter to pass back the chosen partition time
|
|---|
| 118 | )
|
|---|
| 119 | LANGUAGE plpgsql
|
|---|
| 120 | AS $procedure$
|
|---|
| 121 | DECLARE
|
|---|
| 122 | v_status_id BIGINT;
|
|---|
| 123 | BEGIN
|
|---|
| 124 | IF NOT EXISTS (SELECT 1 FROM servers WHERE id = p_server_id AND is_active = TRUE) THEN
|
|---|
| 125 | RAISE EXCEPTION 'Server % is not active or does not exist.', p_server_id;
|
|---|
| 126 | END IF;
|
|---|
| 127 |
|
|---|
| 128 | IF NOT EXISTS (SELECT 1 FROM tournaments WHERE id = p_tournament_id AND game_id = p_game_id) THEN
|
|---|
| 129 | RAISE EXCEPTION 'Tournament % is not connected to game %.', p_tournament_id, p_game_id;
|
|---|
| 130 | END IF;
|
|---|
| 131 |
|
|---|
| 132 | SELECT id INTO v_status_id FROM match_statuses WHERE name = 'in_progress';
|
|---|
| 133 | p_started_at := CURRENT_TIMESTAMP;
|
|---|
| 134 |
|
|---|
| 135 | INSERT INTO matches (id, game_id, game_mode_id, tournament_id, server_id, status_id, started_at)
|
|---|
| 136 | VALUES (p_match_id, p_game_id, p_game_mode_id, p_tournament_id, p_server_id, v_status_id, p_started_at);
|
|---|
| 137 |
|
|---|
| 138 | RAISE NOTICE 'The match % is created in partition window %.', p_match_id, p_started_at;
|
|---|
| 139 | END;
|
|---|
| 140 | $procedure$
|
|---|
| 141 | ;
|
|---|
| 142 |
|
|---|
| 143 | CREATE OR REPLACE PROCEDURE public.add_match_participants(IN p_match_id bigint, IN p_user_ids bigint[], IN p_team_ids bigint[])
|
|---|
| 144 | LANGUAGE plpgsql
|
|---|
| 145 | AS $procedure$
|
|---|
| 146 | DECLARE
|
|---|
| 147 | v_ongoing_id BIGINT;
|
|---|
| 148 | v_started_at TIMESTAMP;
|
|---|
| 149 | BEGIN
|
|---|
| 150 | SELECT id INTO v_ongoing_id FROM match_statuses WHERE name = 'in_progress';
|
|---|
| 151 |
|
|---|
| 152 | -- Look up the partition timestamp key along with status validation
|
|---|
| 153 | SELECT started_at INTO v_started_at
|
|---|
| 154 | FROM matches
|
|---|
| 155 | WHERE id = p_match_id AND status_id = v_ongoing_id;
|
|---|
| 156 |
|
|---|
| 157 | IF v_started_at IS NULL THEN
|
|---|
| 158 | RAISE EXCEPTION 'Match % is not active, does not exist, or status mismatch.', p_match_id;
|
|---|
| 159 | END IF;
|
|---|
| 160 |
|
|---|
| 161 | IF cardinality(p_user_ids) != cardinality(p_team_ids) THEN
|
|---|
| 162 | RAISE EXCEPTION 'The number of user IDs must match the number of team IDs.';
|
|---|
| 163 | END IF;
|
|---|
| 164 |
|
|---|
| 165 | -- Include composite foreign key tracking column: match_started_at
|
|---|
| 166 | INSERT INTO match_teams (match_id, match_started_at, team_id, side_label)
|
|---|
| 167 | SELECT DISTINCT p_match_id, v_started_at, t_id, 'Team_' || t_id
|
|---|
| 168 | FROM unnest(p_team_ids) AS t_id
|
|---|
| 169 | ON CONFLICT DO NOTHING;
|
|---|
| 170 |
|
|---|
| 171 | -- Include composite foreign key tracking column: match_started_at
|
|---|
| 172 | INSERT INTO match_participants (id, match_id, match_started_at, user_id, team_id)
|
|---|
| 173 | SELECT
|
|---|
| 174 | coalesce((select max(id) from match_participants), 0) + row_number() OVER (),
|
|---|
| 175 | p_match_id,
|
|---|
| 176 | v_started_at,
|
|---|
| 177 | arr.u_id,
|
|---|
| 178 | arr.t_id
|
|---|
| 179 | FROM unnest(p_user_ids, p_team_ids) AS arr(u_id, t_id);
|
|---|
| 180 |
|
|---|
| 181 | RAISE NOTICE 'Participants successfully added to match %.', p_match_id;
|
|---|
| 182 | END;
|
|---|
| 183 | $procedure$
|
|---|
| 184 | ;
|
|---|
| 185 |
|
|---|
| 186 | CREATE OR REPLACE FUNCTION public.register_match_result(r_match_id bigint, r_participant_ids bigint[], r_results text[], r_scores integer[], r_elo_changes integer[])
|
|---|
| 187 | RETURNS text
|
|---|
| 188 | LANGUAGE plpgsql
|
|---|
| 189 | AS $function$
|
|---|
| 190 | DECLARE
|
|---|
| 191 | r_status_name text;
|
|---|
| 192 | v_started_at TIMESTAMP;
|
|---|
| 193 | v_comp_id BIGINT;
|
|---|
| 194 | BEGIN
|
|---|
| 195 | IF cardinality(r_participant_ids) != cardinality(r_results) OR
|
|---|
| 196 | cardinality(r_participant_ids) != cardinality(r_scores) OR
|
|---|
| 197 | cardinality(r_participant_ids) != cardinality(r_elo_changes) THEN
|
|---|
| 198 | RETURN 'Error: Input arrays must have matching dimensions.';
|
|---|
| 199 | END IF;
|
|---|
| 200 |
|
|---|
| 201 | -- Gather started_at for highly precise partition isolation
|
|---|
| 202 | SELECT ms.name, m.started_at INTO r_status_name, v_started_at
|
|---|
| 203 | FROM matches m
|
|---|
| 204 | JOIN match_statuses ms ON ms.id = m.status_id
|
|---|
| 205 | WHERE m.id = r_match_id;
|
|---|
| 206 |
|
|---|
| 207 | IF v_started_at IS NULL THEN
|
|---|
| 208 | RETURN 'Error: Match does not exist.';
|
|---|
| 209 | END IF;
|
|---|
| 210 |
|
|---|
| 211 | IF r_status_name NOT IN ('in_progress', 'scheduled') THEN
|
|---|
| 212 | RETURN 'Error: The match is over or canceled.';
|
|---|
| 213 | END IF;
|
|---|
| 214 |
|
|---|
| 215 | -- Primary updates utilize child match indices
|
|---|
| 216 | UPDATE match_participants AS mp
|
|---|
| 217 | SET result_id = (SELECT id FROM participant_results WHERE name = arr.res),
|
|---|
| 218 | score = arr.scr,
|
|---|
| 219 | elo_change = arr.elo
|
|---|
| 220 | FROM unnest(r_participant_ids, r_results, r_scores, r_elo_changes) AS arr(part_id, res, scr, elo)
|
|---|
| 221 | WHERE mp.id = arr.part_id
|
|---|
| 222 | AND mp.match_id = r_match_id;
|
|---|
| 223 |
|
|---|
| 224 | SELECT id INTO v_comp_id FROM match_statuses WHERE name = 'completed';
|
|---|
| 225 |
|
|---|
| 226 | -- Included started_at = v_started_at in the WHERE clause for partition pruning
|
|---|
| 227 | UPDATE matches
|
|---|
| 228 | SET status_id = v_comp_id,
|
|---|
| 229 | ended_at = CURRENT_TIMESTAMP
|
|---|
| 230 | WHERE id = r_match_id
|
|---|
| 231 | AND started_at = v_started_at;
|
|---|
| 232 |
|
|---|
| 233 | RETURN 'OK: Match is over.';
|
|---|
| 234 | END;
|
|---|
| 235 | $function$
|
|---|
| 236 | ;
|
|---|
| 237 |
|
|---|
| 238 | CREATE OR REPLACE FUNCTION public.update_rank()
|
|---|
| 239 | RETURNS trigger
|
|---|
| 240 | LANGUAGE plpgsql
|
|---|
| 241 | AS $function$
|
|---|
| 242 | BEGIN
|
|---|
| 243 | IF OLD.result_id IS NULL AND NEW.result_id IS NOT NULL THEN
|
|---|
| 244 | UPDATE profiles
|
|---|
| 245 | SET rank_points = GREATEST(0, rank_points + NEW.elo_change)
|
|---|
| 246 | WHERE user_id = NEW.user_id;
|
|---|
| 247 | END IF;
|
|---|
| 248 | RETURN NEW;
|
|---|
| 249 | END;
|
|---|
| 250 | $function$
|
|---|
| 251 | ;
|
|---|
| 252 |
|
|---|
| 253 | CREATE OR REPLACE TRIGGER update_rank_after_match
|
|---|
| 254 | AFTER UPDATE OF result_id ON match_participants
|
|---|
| 255 | FOR EACH ROW
|
|---|
| 256 | EXECUTE FUNCTION public.update_rank(); |
|---|