How to Design Graph-Based Data Models for Player-Game Relationships: A SQL Implementation Guide

Model player-game interactions as a property graph using separate vertices and edges tables, enabling flexible traversal queries for opponent analysis, career tracking, and co-play statistics without schema migrations.

Graph-based data modeling transforms complex player-game interactions into traversable relationships, capturing many-to-many connections that traditional relational schemas struggle to express. The DataExpert-io/data-engineer-handbook repository demonstrates this approach using PostgreSQL ENUM types and composite primary keys to build extensible graph structures for sports analytics. By treating players, teams, and games as vertices and their interactions as typed edges, you enable powerful queries such as tracing career progression across seasons or computing head-to-head statistics.

Core Schema Architecture

Defining Vertex and Edge Types

The foundation of the graph model resides in intermediate-bootcamp/materials/1-dimensional-data-modeling/lecture-lab/graph_ddls.sql, which establishes strict type safety through PostgreSQL ENUMs. The vertex_type ENUM categorizes entities as player, team, or game, while the edge_type ENUM classifies relationships including plays_in, plays_against, shares_team, and plays_on.

CREATE TYPE vertex_type AS ENUM('player', 'team', 'game');

CREATE TYPE edge_type AS ENUM (
    'plays_against',
    'shares_team',
    'plays_in',
    'plays_on'
);

Creating the Vertices and Edges Tables

The schema separates static entity attributes from dynamic relationships. The vertices table stores unique entities with a composite primary key on (identifier, type) and a JSON properties column for flexible attributes like player names or team logos. The edges table implements the graph structure using subject-object terminology, where each relationship references both endpoint identifiers and their respective types.

CREATE TABLE vertices (
    identifier TEXT,
    type vertex_type,
    properties JSON,
    PRIMARY KEY (identifier, type)
);

CREATE TABLE edges (
    subject_identifier TEXT,
    subject_type vertex_type,
    object_identifier TEXT,
    object_type vertex_type,
    edge_type edge_type,
    properties JSON,
    PRIMARY KEY (subject_identifier, subject_type,
                 object_identifier, object_type,
                 edge_type)
);

Populating Player-Game Relationships

Inserting Player-Game Edges

The intermediate-bootcamp/materials/1-dimensional-data-modeling/lecture-lab/player_game_edges.sql script materializes participation records as plays_in edges. It first deduplicates source data using row_number() OVER (PARTITION BY player_id, game_id) to ensure idempotent inserts, then stores game-specific metrics—such as start position, points scored, and team affiliation—within the JSON properties column.

INSERT INTO edges
WITH deduped AS (
    SELECT *, row_number() OVER (PARTITION BY player_id, game_id) AS row_num
    FROM game_details
)
SELECT
    player_id            AS subject_identifier,
    'player'::vertex_type AS subject_type,
    game_id              AS object_identifier,
    'game'::vertex_type   AS object_type,
    'plays_in'::edge_type AS edge_type,
    json_build_object(
        'start_position', start_position,
        'pts',            pts,
        'team_id',        team_id,
        'team_abbreviation', team_abbreviation
    ) AS properties
FROM deduped
WHERE row_num = 1;

Deriving Player-Player Connections

Once player-game edges exist, the intermediate-bootcamp/materials/1-dimensional-data-modeling/lecture-lab/player_player_edges.sql script computes implicit relationships through self-joins. By joining the deduplicated game details on game_id while enforcing player_name <> player_name and f1.player_id > f2.player_id to eliminate mirror duplicates, the query generates both plays_against and shares_team edges. A CASE statement distinguishes relationship types based on team affiliation, aggregating statistics such as games played and cumulative points across matchups.

WITH deduped AS (
    SELECT *, row_number() OVER (PARTITION BY player_id, game_id) AS row_num
    FROM game_details
), filtered AS (
    SELECT * FROM deduped WHERE row_num = 1
), aggregated AS (
    SELECT
        f1.player_id   AS player_a,
        f1.player_name AS name_a,
        f2.player_id   AS player_b,
        f2.player_name AS name_b,
        CASE
            WHEN f1.team_abbreviation = f2.team_abbreviation
                THEN 'shares_team'::edge_type
            ELSE 'plays_against'::edge_type
        END AS edge_type,
        COUNT(*) AS num_games,
        SUM(f1.pts) AS left_points,
        SUM(f2.pts) AS right_points
    FROM filtered f1
    JOIN filtered f2
      ON f1.game_id = f2.game_id
     AND f1.player_name <> f2.player_name
    WHERE f1.player_id > f2.player_id
    GROUP BY
        f1.player_id, f1.player_name,
        f2.player_id, f2.player_name,
        CASE
            WHEN f1.team_abbreviation = f2.team_abbreviation
                THEN 'shares_team'::edge_type
            ELSE 'plays_against'::edge_type
        END
)
INSERT INTO edges
SELECT
    player_a      AS subject_identifier,
    'player'::vertex_type AS subject_type,
    player_b      AS object_identifier,
    'player'::vertex_type AS object_type,
    edge_type,
    json_build_object(
        'num_games',    num_games,
        'left_points',  left_points,
        'right_points', right_points
    ) AS properties
FROM aggregated;

Querying the Graph Model

With the graph materialized, analytical questions become straightforward traversals. To identify all opponents a specific player has faced, filter the edges table for edge_type = 'plays_against' and join to vertices for human-readable names. Advanced use cases leverage recursive CTEs to trace multi-hop relationships, such as finding all teammates-of-teammates or mapping six-degrees-of-separation paths between players.

SELECT e.object_identifier AS opponent_id,
       v.properties->>'name' AS opponent_name,
       e.properties->>'num_games' AS games_played
FROM edges e
JOIN vertices v
  ON v.identifier = e.object_identifier
 AND v.type = 'player'
WHERE e.subject_identifier = '123'
  AND e.edge_type = 'plays_against';

Summary

  • Separate static entities from dynamic relationships using distinct vertices and edges tables to maintain schema flexibility when adding new entity types like tournaments or coaches.
  • Enforce data integrity through PostgreSQL ENUM types for vertex and edge classifications, preventing invalid relationship categories at the database level.
  • Store variable attributes in JSON properties to capture evolving metrics (points, positions, team affiliations) without requiring ALTER TABLE operations.
  • Derive implicit edges via self-joins on game participation data, using row_number() deduplication and inequality joins to prevent duplicate relationships.
  • Optimize traversals using composite primary keys on (subject_identifier, subject_type, object_identifier, object_type, edge_type) for fast graph lookups.

Frequently Asked Questions

Why use a graph model instead of traditional relational tables for player-game data?

Traditional relational schemas require complex many-to-many join tables that become rigid when adding new relationship types. The graph model stores all relationships uniformly in the edges table, allowing you to introduce new interaction types (such as wins_against or coaches) by simply inserting rows or extending the edge_type ENUM, without restructuring existing tables or rewriting application logic.

How do you prevent duplicate edges when multiple players participate in the same game?

The insertion scripts in player_game_edges.sql and player_player_edges.sql use row_number() OVER (PARTITION BY player_id, game_id) to deduplicate source records before insertion. For player-player relationships, the additional predicate f1.player_id > f2.player_id ensures only one directional edge exists between any pair, eliminating mirror duplicates while preserving undirected relationship semantics.

Can this schema handle additional entity types like tournaments or coaches?

Yes, the schema is designed for extensibility. You can add tournament or coach values to the vertex_type ENUM and create corresponding entries in the vertices table. New relationship types such as wins_against or coaches require only additions to the edge_type ENUM, making the model adaptable to evolving analytical requirements without breaking existing queries.

What performance considerations apply when querying large graph tables?

The composite primary key on (subject_identifier, subject_type, object_identifier, object_type, edge_type) provides intrinsic indexing for exact-match lookups on specific relationship types. For traversal-heavy workloads, create additional B-tree indexes on object_identifier and object_type to accelerate reverse-direction queries (finding all subjects pointing to a specific object), and consider GIN indexes on the JSON properties column if filtering frequently on nested attributes like team abbreviations or season years.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →