Search code examples
javajpajakarta-eedatabase-design

JPA design for saving a complex game state


Suppose I have a game with a map and units on it and I want to provide a save option on the server using JPA 2.2. Here are simplified classes to represent it:

@Entity
class GameState {

    @Id
    long gameId;

    Map<Point, Tile> map;

    Map<Long, Unit> units; // maps from the id to the unit
    Map<Long, Soldier> soldiers; // maps from the id to the soldier
}
@Entity
class Tile {

    @Id
    long entityId;

    @OneToOne(mappedBy = "tile")
    Unit unit; // null if no unit on this tile
}
@Entity
class Unit {

    @Id
    long entityId;
    
    @OneToOne
    Tile tile; // null if the unit is not on a tile

    @OneToMany(mappedBy = "unit", cascade = CascadeType.ALL, orphanRemoval = true)
    List<Soldier> soldiers;
}
@Entity
class Soldier {

    @id
    long entityId;
    
    @ManyToOne(fetch = FetchType.LAZY)
    Unit unit;        

    int health;
}
final class Point {

    final int x, y;
}

so each tile can have a unit on it, and each unit has some soldiers in it.

I have several issues:

  1. Since there can be many games, the entityId of Unit Soldier and Tile is not unique. There can be a soldier with id of 1 in each game. I'm not sure if i can/should use @Embedded or if I can use a complex id made of the entity id and the game id.

  2. What is the appropriate annotations for Point? It's immutable.

  3. What is the appropriate annotations for the maps? I thought something like

     @OneToMany(cascade = CascadeType.ALL)
     @JoinTable(name = "unit_mapping", 
       joinColumns = {@JoinColumn(name = "unit_id", referencedColumnName = "entityId")},
       inverseJoinColumns = {@JoinColumn(name = "unit_id", referencedColumnName = "entityId")})
     @MapKey(name = "id")
    

This is my attempt, but if there is a better approach I'd like to know. Previously I just serialized the whole state as a blob, but I want to be able to query finer details of the state.


Solution

  • What is the appropriate annotations for the maps?

    The simplest would be to use a @JoinColumn instead of a @JoinTable (and I believe it is the only feasible mapping if you're planning on making the Game id part of the primary key for Units or Soldiers):

    @OneToMany(cascadeType = ALL)
    @JoinColumn(name = "game_id")
    @MapKey(name = "entityId")
    private Map<Long, Unit> units; 
    

    The above mapping will create a game_id column inside the UNIT table. If you insist on using intermediary join tables instead, then the correct mapping would be sth like:

    @OneToMany(cascadeType = ALL)
    @JoinTable(name = "unit_mapping", 
        joinColumns = @JoinColumn(name = "game_id"),
        inverseJoinColumns = @JoinColumn(name = "unit_id")
    )
    @MapKey(name = "entityId")
    private Map<Long, Unit> units;
    

    What is the appropriate annotations for Point? It's immutable.

    Since Point does not have an identity, I'd suggest you make it an @Embeddable

    how do i make the complex ID using the game id with the entity id?

    First of all, If I were you, I'd really challenge myself to figure out how many Soldiers and Units there can be. A long is a really huuuuuge number, and as you're about to see, the mapping gets pretty complicated using the composite key.

    However, if you decide you'll need a composite key after all, you'll need to adjust the mapping, changing the unidirectional @OneToMany to a bidirectional one, with a shared key. The result will look sth like this:

    public class UnitKey implements Serializable {
    
        private long entityId;
        private long gameId;
    
        //getters, setters, equals, hashCode
    }
    
    @Entity
    @IdClass(UnitKey.class) // in principle, you could use an EmbeddedId instead, but IdClass is a little more convenient with PKs made partly of FKs IMHO
    public class Unit {
    
        @Id
        private long entityId;
    
        @JoinColumn(name = "game_id")
        @MapsId("gameId")
        private Game game;
    }
    
    @Entity
    public class Game {
    
        @OneToMany(mappedBy = "game", cascadeType = ALL)
        @MapKey(name = "entityId")
        private Map<Long, Unit> units;
    
    }
    
    

    Note that Unit is not the owning side of the association. This means that any changes to the association have to be done by adjusting the Unit.game property. In particular, to add a new Unit, to a Game, it is not enough to add a Unit to the Game.units map - you also need to manually set the Unit.game property to point to the Game in question.

    Note that I haven't tested the above mapping. Though it should work in principle, I've never tried combining a map on one side with a composite key on the other, so - no guarantees given.