diff --git a/hasura/triggers/tournament_stages.sql b/hasura/triggers/tournament_stages.sql index 7f64dab5..c2f024c5 100644 --- a/hasura/triggers/tournament_stages.sql +++ b/hasura/triggers/tournament_stages.sql @@ -83,6 +83,16 @@ DECLARE current_order INTEGER; next_stage_record RECORD; BEGIN + -- Resequencing after a delete only shifts "order"; the delete trigger + -- regenerates the brackets once for the whole tournament afterwards. + BEGIN + PERFORM 1 FROM pg_temp.resequencing_tournament_stages LIMIT 1; + RETURN NEW; + EXCEPTION + WHEN undefined_table THEN + NULL; + END; + -- Format-only tweaks (per-round best-of settings, default best-of) don't -- affect bracket structure — they are read at match materialization — so -- they must not trigger a bracket regeneration. @@ -203,6 +213,14 @@ DECLARE tournament_status text; stage_has_matches boolean; BEGIN + BEGIN + PERFORM 1 FROM pg_temp.resequencing_tournament_stages LIMIT 1; + RETURN NEW; + EXCEPTION + WHEN undefined_table THEN + NULL; + END; + -- Format-only tweaks (per-round best-of settings, default best-of) are -- allowed at any point: they only apply to matches that have not been -- created yet, so a live tournament/league can adjust its series format. @@ -284,6 +302,28 @@ CREATE OR REPLACE FUNCTION public.tad_tournament_stages() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN + -- Stage order must stay 1..N with no gaps: stages are linked by + -- "order" + 1, so a hole leaves the stages after it orphaned. + CREATE TEMP TABLE IF NOT EXISTS resequencing_tournament_stages (dummy int); + + BEGIN + UPDATE tournament_stages ts + SET "order" = resequenced.new_order + FROM ( + SELECT id, (ROW_NUMBER() OVER (ORDER BY "order" ASC, id ASC))::int AS new_order + FROM tournament_stages + WHERE tournament_id = OLD.tournament_id + ) resequenced + WHERE ts.id = resequenced.id + AND ts."order" IS DISTINCT FROM resequenced.new_order; + EXCEPTION + WHEN OTHERS THEN + DROP TABLE IF EXISTS pg_temp.resequencing_tournament_stages; + RAISE; + END; + + DROP TABLE IF EXISTS pg_temp.resequencing_tournament_stages; + PERFORM update_tournament_stages(OLD.tournament_id); PERFORM cleanup_orphaned_match_options(OLD.match_options_id); RETURN OLD; diff --git a/test/tournament-stages.spec.ts b/test/tournament-stages.spec.ts index c02bdd19..39024959 100644 --- a/test/tournament-stages.spec.ts +++ b/test/tournament-stages.spec.ts @@ -359,6 +359,52 @@ describe("tournament stages: Swiss and RoundRobin (SQL-driven)", () => { expect(Number(stage1.min_teams)).toBe(8); }); + const THREE_STAGES = [ + { type: "SingleElimination", order: 1, minTeams: 16, maxTeams: 16 }, + { type: "SingleElimination", order: 2, minTeams: 8, maxTeams: 8 }, + { type: "SingleElimination", order: 3, minTeams: 4, maxTeams: 4 }, + ]; + + it("deleting a stage closes the gap in the remaining stage order", async () => { + const t = await tfx.createTournament(THREE_STAGES); + + await postgres.query("DELETE FROM tournament_stages WHERE id = $1", [ + t.stageIds[0], + ]); + + const stages = await postgres.query>( + `SELECT id, "order" FROM tournament_stages WHERE tournament_id = $1 ORDER BY "order"`, + [t.id], + ); + expect(stages.map((stage) => Number(stage.order))).toEqual([1, 2]); + expect(stages.map((stage) => stage.id)).toEqual([ + t.stageIds[1], + t.stageIds[2], + ]); + }); + + it("deleting a middle stage keeps the surviving stages linked", async () => { + const t = await tfx.createTournament(THREE_STAGES); + + await postgres.query("DELETE FROM tournament_stages WHERE id = $1", [ + t.stageIds[1], + ]); + + const stages = await postgres.query>( + `SELECT "order" FROM tournament_stages WHERE tournament_id = $1 ORDER BY "order"`, + [t.id], + ); + expect(stages.map((stage) => Number(stage.order))).toEqual([1, 2]); + + // Stages link by "order" + 1, so the gap used to leave stage 3 orphaned. + const [linked] = await postgres.query>( + `SELECT COUNT(*) FROM tournament_brackets + WHERE tournament_stage_id = $1 AND parent_bracket_id IS NOT NULL`, + [t.stageIds[0]], + ); + expect(Number(linked.count)).toBeGreaterThan(0); + }); + it("a RoundRobin stage feeds its top teams into the elimination stage", async () => { const t = await tfx.launch( [