I can’t remember such a problem. Yes, converting between JSON <-> DTO <-> Domain is boilerplate, but the Domain <-> DB Store is often related to re-arranging data shape to place it into relational tables efficiently. And I’ve done it for each store by hand.
Perhaps, if an app is barely a CRUD-app, this conversion becomes boilerplate too, I don’t know. It was not the case for the game. Also, a typical template for store implementations has born before introducing Jzon. So, I used bs-json
to decode results from PgSQL.
Hopefully an uncensored copy/paste example of a random store implementation can give you some ideas Sorry, it’s still in ReasonML, but nevertheless:
// RoomStore.rei
open SimpleTypes;
module Room = LobbyDomain.Room;
module RoomTable = LobbyDomain.RoomTable;
type t;
let make: KnexConnection.knex => t;
let readRoomTable: (t, roomId) => Promise.Option.t(RoomTable.t);
let findGlobalVacantRoomTable: t => Promise.Option.t(RoomTable.t);
let findWaitingRoomsJoinedByPlayer:
(t, playerId) => Promise.t(array(Room.t));
let create: (t, Room.t) => Promise.t(unit);
let addPlayerToRoom: (t, playerId, roomId) => Promise.t(unit);
let removePlayerFromRoom: (t, playerId, roomId) => Promise.t(unit);
let markAsPlaying: (t, roomId) => Promise.t(unit);
// RoomStore.re
open Knex.Params.Infix; /* Brings (??) and (?|) */
open SimpleTypes;
module JD = Json.Decode;
module Room = LobbyDomain.Room;
module RoomTable = LobbyDomain.RoomTable;
type t = KnexConnection.knex;
let decodeRoom = row => {
let roomId = RoomId(row |> JD.field("id", JD.string));
let privacy =
row |> JD.field("global", JD.bool) ? Room.Global : Room.Friends;
let numberOfPlayers = row |> JD.field("number_of_players", JD.int);
let address =
switch (privacy) {
| Global => Room.Address.GlobalRoom({numberOfPlayers, roomId})
| Friends => Room.Address.FriendsRoom({numberOfPlayers, roomId})
};
Room.make(
~address,
~buyin=
(row |> JD.field("buyin", JD.string))->Int.fromString->Option.getExn,
~tax=(row |> JD.field("tax", JD.string))->Int.fromString->Option.getExn,
(),
)
->Result.getExn;
};
let readRoom = (knex, roomId) =>
Knex.Select.(
make(knex)
|> from("rooms")
|> column("id")
|> column("global")
|> column("number_of_players")
|> column("buyin")
|> column("tax")
|> where("waiting = TRUE")
|> whereParam("id = ?", ??roomId)
|> execute
)
|> Promise.map(Array.get(_, 0))
|> Promise.map(Option.map(_, decodeRoom));
let readRoomPlayerIds = (knex, roomId) =>
Knex.Select.(
make(knex)
|> from("rooms_players")
|> column("player_id")
|> whereParam("room_id = ?", ??roomId)
|> orderBy("created_at", Ascending)
|> execute
)
|> Promise.map(
Array.map(_, row => PlayerId(row |> JD.field("player_id", JD.int))),
);
let readRoomTable = (knex, roomId) => {
let%Promise (maybeRoom, playerIds) =
Promise.all2((readRoom(knex, roomId), readRoomPlayerIds(knex, roomId)));
maybeRoom
->Option.map(room => RoomTable.make(room, playerIds))
->Promise.resolve;
};
let findGlobalVacantRoomTable = knex => {
let%Promise maybeRoomId =
KnexX.Select.(
make(knex)
|> from("rooms")
|> column("id")
|> where("waiting = TRUE")
|> where("global = TRUE")
|> execute
)
|> Promise.map(rows => {
rows
->Array.get(0)
->Option.map(row => RoomId(row |> JD.field("id", JD.string)))
});
switch (maybeRoomId) {
| Some(roomId) => readRoomTable(knex, roomId)
| None => None->Promise.resolve
};
};
let findWaitingRoomsJoinedByPlayer = (knex, playerId) =>
Knex.Select.(
make(knex)
|> from("rooms")
|> innerJoin("rooms_players", "rooms.id", "=", "rooms_players.room_id")
|> column("rooms.id")
|> column("rooms.global")
|> column("rooms.number_of_players")
|> column("rooms.buyin")
|> column("rooms.tax")
|> where("rooms.waiting = TRUE")
|> whereParam("rooms_players.player_id = ?", ??playerId)
|> execute
)
|> Promise.map(Array.map(_, decodeRoom));
let create = (knex, room) => {
// For now the prize amounts are extraneous but it might be changed in future
// if some challenges with guaranted prize over the buyin would be introduced
let prizeFor = place =>
room->Room.prizeAmounts->Array.get(place - 1)->Option.getWithDefault(0);
Knex.Insert.(
make(knex)
|> into("rooms")
|> set("id", room->Room.id)
|> set("global", room->Room.privacy == Global)
|> set("number_of_players", room->Room.numberOfPlayers)
|> set("buyin", room->Room.buyin)
|> set("tax", room->Room.tax)
|> set("prize_for_1st", prizeFor(1))
|> set("prize_for_2nd", prizeFor(2))
|> set("prize_for_3rd", prizeFor(3))
|> set("prize_for_4th", prizeFor(4))
|> execute
)
|> Promise.erase;
};
let addPlayerToRoom = (knex, playerId, roomId) =>
Knex.Insert.(
make(knex)
|> into("rooms_players")
|> set("player_id", playerId)
|> set("room_id", roomId)
|> set("seat", 0) /* TODO: remove this column, it is extraneous */
|> execute
)
|> Promise.erase;
let removePlayerFromRoom = (knex, playerId, roomId) =>
Knex.Delete.
/* TODO: do not delete, but set `left_at` for analytics */
(
make("rooms_players", knex)
|> whereParam("player_id = ?", ??playerId)
|> whereParam("room_id = ?", ??roomId)
|> execute
)
|> Promise.erase;
let markAsPlaying = (knex, roomId) =>
Knex.Update.(
make("rooms", knex)
|> set("waiting", false)
|> whereParam("id = ?", ??roomId)
|> execute
)
|> Promise.erase;
let make = knex => knex;