Step 2: Examine the data model and implementation details
2.1: Basic data model
This example application highlights the following DynamoDB data model concepts:
-
Table – In DynamoDB, a table is a collection of items (that is, records), and each item is a collection of name-value pairs called attributes.
In this Tic-Tac-Toe example, the application stores all game data in a table,
Games
. The application creates one item in the table per game and stores all game data as attributes. A tic-tac-toe game can have up to nine moves. Because DynamoDB tables do not have a schema in cases where only the primary key is the required attribute, the application can store varying number of attributes per game item.The
Games
table has a simple primary key made of one attribute,GameId
, of string type. The application assigns a unique ID to each game. For more information on DynamoDB primary keys, see Primary key.When a user initiates a tic-tac-toe game by inviting another user to play, the application creates a new item in the
Games
table with attributes storing game metadata, such as the following:-
HostId
, the user who initiated the game. -
Opponent
, the user who was invited to play. -
The user whose turn it is to play. The user who initiated the game plays first.
-
The user who uses the O symbol on the board. The user who initiates the games uses the O symbol.
In addition, the application creates a
StatusDate
concatenated attribute, marking the initial game state asPENDING
. The following screenshot shows an example item as it appears in the DynamoDB console:As the game progresses, the application adds one attribute to the table for each game move. The attribute name is the board position, for example
TopLeft
orBottomRight
. For example, a move might have aTopLeft
attribute with the valueO
, aTopRight
attribute with the valueO
, and aBottomRight
attribute with the valueX
. The attribute value is eitherO
orX
, depending on which user made the move. For example, consider the following board. -
-
Concatenated value attributes – The
StatusDate
attribute illustrates a concatenated value attribute. In this approach, instead of creating separate attributes to store game status (PENDING
,IN_PROGRESS
, andFINISHED
) and date (when the last move was made), you combine them as single attribute, for exampleIN_PROGRESS_2014-04-30 10:20:32
.The application then uses the
StatusDate
attribute in creating secondary indexes by specifyingStatusDate
as a sort key for the index. The benefit of using theStatusDate
concatenated value attribute is further illustrated in the indexes discussed next. -
Global secondary indexes – You can use the table's primary key,
GameId
, to efficiently query the table to find a game item. To query the table on attributes other than the primary key attributes, DynamoDB supports the creation of secondary indexes. In this example application, you build the following two secondary indexes:-
HostId-StatusDate-index. This index has
HostId
as a partition key andStatusDate
as a sort key. You can use this index to query onHostId
, for example to find games hosted by a particular user. -
OpponentId-StatusDate-index. This index has
OpponentId
as a partition key andStatusDate
as a sort key. You can use this index to query onOpponent
, for example to find games where a particular user is the opponent.
These indexes are called global secondary indexes because the partition key in these indexes is not the same the partition key (
GameId
), used in the primary key of the table.Note that both the indexes specify
StatusDate
as a sort key. Doing this enables the following:-
You can query using the
BEGINS_WITH
comparison operator. For example, you can find all games with theIN_PROGRESS
attribute hosted by a particular user. In this case, theBEGINS_WITH
operator checks for theStatusDate
value that begins withIN_PROGRESS
. -
DynamoDB stores the items in the index in sorted order, by sort key value. So if all status prefixes are the same (for example,
IN_PROGRESS
), the ISO format used for the date part will have items sorted from oldest to the newest. This approach enables certain queries to be performed efficiently, for example the following:-
Retrieve up to 10 of the most recent
IN_PROGRESS
games hosted by the user who is logged in. For this query, you specify theHostId-StatusDate-index
index. -
Retrieve up to 10 of the most recent
IN_PROGRESS
games where the user logged in is the opponent. For this query, you specify theOpponentId-StatusDate-index
index.
-
-
For more information about secondary indexes, see Improving data access with secondary indexes in DynamoDB.
2.2: Application in action (code walkthrough)
This application has two main pages:
-
Home page – This page provides the user a simple login, a CREATE button to create a new tic-tac-toe game, a list of games in progress, game history, and any active pending game invitations.
The home page is not refreshed automatically; you must refresh the page to refresh the lists.
-
Game page – This page shows the tic-tac-toe grid where users play.
The application updates the game page automatically every second. The JavaScript in your browser calls the Python web server every second to query the Games table whether the game items in the table have changed. If they have, JavaScript triggers a page refresh so that the user sees the updated board.
Let us see in detail how the application works.
Home page
After the user logs in, the application displays the following three lists of information.
-
Invitations – This list shows up to the 10 most recent invitations from others that are pending acceptance by the user who is logged in. In the preceding screenshot, user1 has invitations from user5 and user2 pending.
-
Games in-progress – This list shows up to the 10 most recent games that are in progress. These are games that the user is actively playing, which have the status
IN_PROGRESS
. In the screenshot, user1 is actively playing a tic-tac-toe game with user3 and user4. -
Recent history – This list shows up to the 10 most recent games that the user finished, which have the status
FINISHED
. In game shown in the screenshot, user1 has previously played with user2. For each completed game, the list shows the game result.
In the code, the index
function (in application.py
)
makes the following three calls to retrieve game status information:
inviteGames = controller.getGameInvites(session["username"]) inProgressGames = controller.getGamesWithStatus(session["username"], "IN_PROGRESS") finishedGames = controller.getGamesWithStatus(session["username"], "FINISHED")
Each of these calls returns a list of items from DynamoDB that are wrapped by the
Game
objects. It is easy to extract data from these objects in
the view. The index function passes these object lists to the view to render the
HTML.
return render_template("index.html", user=session["username"], invites=inviteGames, inprogress=inProgressGames, finished=finishedGames)
The Tic-Tac-Toe application defines the Game
class primarily to
store game data retrieved from DynamoDB. These functions return lists of
Game
objects that enable you to isolate the rest of the
application from code related to Amazon DynamoDB items. Thus, these functions help you
decouple your application code from the details of the data store layer.
The architectural pattern described here is also referred as the
model-view-controller (MVC) UI pattern. In this case, the Game
object instances (representing data) are the model, and the HTML page is the
view. The controller is divided into two files. The application.py
file has the controller logic for the Flask framework, and the business logic is
isolated in the gameController.py
file. That is, the application
stores everything that has to do with DynamoDB SDK in its own separate file in the
dynamodb
folder.
Let us review the three functions and how they query the Games table using global secondary indexes to retrieve relevant data.
Using getGameInvites to get the list of pending game invitations
The getGameInvites
function retrieves the list of the 10 most
recent pending invitations. These games have been created by users, but the
opponents have not accepted the game invitations. For these games, the
status remains PENDING
until the opponent accepts the invite.
If the opponent declines the invite, the application removes the
corresponding item from the table.
The function specifies the query as follows:
-
It specifies the
OpponentId-StatusDate-index
index to use with the following index key values and comparison operators:-
The partition key is
OpponentId
and takes the index key
.user ID
-
The sort key is
StatusDate
and takes the comparison operator and index key valuebeginswith="PENDING_"
.
You use the
OpponentId-StatusDate-index
index to retrieve games to which the logged-in user is invited—that is, where the logged-in user is the opponent. -
-
The query limits the result to 10 items.
gameInvitesIndex = self.cm.getGamesTable().query( Opponent__eq=user, StatusDate__beginswith="PENDING_", index="OpponentId-StatusDate-index", limit=10)
In the index, for each OpponentId
(the partition key) DynamoDB
keeps items sorted by StatusDate
(the sort key). Therefore, the
games that the query returns will be the 10 most recent games.
Using getGamesWithStatus to get the list of games with a specific status
After an opponent accepts a game invitation, the game status changes to
IN_PROGRESS
. After the game completes, the status changes
to FINISHED
.
Queries to find games that are either in progress or finished are the same
except for the different status value. Therefore, the application defines
the getGamesWithStatus
function, which takes the status value
as a parameter.
inProgressGames = controller.getGamesWithStatus(session["username"], "IN_PROGRESS") finishedGames = controller.getGamesWithStatus(session["username"], "FINISHED")
The following section discusses in-progress games, but the same description also applies to finished games.
A list of in-progress games for a given user includes both the following:
-
In-progress games hosted by the user
-
In-progress games where the user is the opponent
The getGamesWithStatus
function runs the following two
queries, each time using the appropriate secondary index.
-
The function queries the
Games
table using theHostId-StatusDate-index
index. For the index, the query specifies primary key values—both the partition key (HostId
) and sort key (StatusDate
) values, along with comparison operators.hostGamesInProgress = self.cm.getGamesTable ().query(HostId__eq=user, StatusDate__beginswith=status, index="HostId-StatusDate-index", limit=10)
Note the Python syntax for comparison operators:
-
HostId__eq=user
specifies the equality comparison operator. -
StatusDate__beginswith=status
specifies theBEGINS_WITH
comparison operator.
-
-
The function queries the
Games
table using theOpponentId-StatusDate-index
index.oppGamesInProgress = self.cm.getGamesTable().query(Opponent__eq=user, StatusDate__beginswith=status, index="OpponentId-StatusDate-index", limit=10)
-
The function then combines the two lists, sorts, and for the first 0 to 10 items creates a list of the
Game
objects and returns the list to the calling function (that is, the index).games = self.mergeQueries(hostGamesInProgress, oppGamesInProgress) return games
Game page
The game page is where the user plays tic-tac-toe games. It shows the game grid along with game-relevant information. The following screenshot shows an example game in progress:
The application displays the game page in the following situations:
-
The user creates a game inviting another user to play.
In this case, the page shows the user as host and the game status as
PENDING
, waiting for the opponent to accept. -
The user accepts one of the pending invitations on the home page.
In this case, the page shows the user as the opponent and game status as
IN_PROGRESS
.
A user selection on the board generates a form POST
request to
the application. That is, Flask calls the selectSquare
function (in
application.py
) with the HTML form data. This function, in
turn, calls the updateBoardAndTurn
function (in
gameController.py
) to update the game item as follows:
-
It adds a new attribute specific to the move.
-
It updates the
Turn
attribute value to the user whose turn is next.
controller.updateBoardAndTurn(item, value, session["username"])
The function returns true if the item update was successful; otherwise, it
returns false. Note the following about the updateBoardAndTurn
function:
-
The function calls the
update_item
function of the SDK for Python to make a finite set of updates to an existing item. The function maps to theUpdateItem
operation in DynamoDB. For more information, see UpdateItem.Note
The difference between the
UpdateItem
andPutItem
operations is thatPutItem
replaces the entire item. For more information, see PutItem.
For the update_item
call, the code identifies the
following:
-
The primary key of the
Games
table (that is,ItemId
).key = { "GameId" : { "S" : gameId } }
-
The new attribute to add, specific to the current user move, and its value (for example,
TopLeft="X"
).attributeUpdates = { position : { "Action" : "PUT", "Value" : { "S" : representation } } }
-
Conditions that must be true for the update to take place:
-
The game must be in progress. That is, the
StatusDate
attribute value must begin withIN_PROGRESS
. -
The current turn must be a valid user turn as specified by the
Turn
attribute. -
The square that the user chose must be available. That is, the attribute corresponding to the square must not exist.
expectations = {"StatusDate" : {"AttributeValueList": [{"S" : "IN_PROGRESS_"}], "ComparisonOperator": "BEGINS_WITH"}, "Turn" : {"Value" : {"S" : current_player}}, position : {"Exists" : False}}
-
Now the function calls update_item
to update the item.
self.cm.db.update_item("Games", key=key, attribute_updates=attributeUpdates, expected=expectations)
After the function returns, the selectSquare
function calls
redirect, as shown in the following example.
redirect("/game="+gameId)
This call causes the browser to refresh. As part of this refresh, the application checks to see if the game has ended in a win or draw. If it has, the application updates the game item accordingly.