1 {-# LANGUAGE DeriveDataTypeable #-}
2 {-# LANGUAGE FlexibleInstances #-}
4 {-# LANGUAGE QuasiQuotes #-}
5 {-# LANGUAGE RecordWildCards #-}
6 {-# LANGUAGE TemplateHaskell #-}
7 {-# LANGUAGE TypeFamilies #-}
9 -- | Parse TSN XML for the DTD \"scoresxml.dtd\". Each document
10 -- contains a single \<game\> and some \<location\>s.
12 module TSN.XML.Scores (
17 -- * WARNING: these are private but exported to silence warnings
18 Score_ScoreLocationConstructor(..),
20 ScoreGameConstructor(..),
21 ScoreGameTeamConstructor(..),
22 ScoreLocationConstructor(..),
23 ScoreGame_ScoreGameTeamConstructor(..) )
27 import Data.Data ( Data )
28 import Data.Time ( UTCTime )
29 import Data.Tuple.Curry ( uncurryN )
30 import Data.Typeable ( Typeable )
31 import Database.Groundhog (
38 silentMigrationLogger )
39 import Database.Groundhog.Core ( DefaultKey )
40 import Database.Groundhog.Generic ( runDbConn )
41 import Database.Groundhog.Sqlite ( withSqliteConn )
42 import Database.Groundhog.TH (
46 import Test.Tasty ( TestTree, testGroup )
47 import Test.Tasty.HUnit ( (@?=), testCase )
48 import Text.XML.HXT.Core (
66 import TSN.DbImport ( DbImport(..), ImportResult(..), run_dbmigrate )
67 import TSN.Picklers ( xp_time_stamp )
68 import TSN.XmlImport ( XmlImport(..), XmlImportFk(..) )
78 -- | The DTD to which this module corresponds. Used to invoke dbimport.
91 -- | Database representation of a 'Message'. It lacks the
92 -- 'xml_locations' and 'xml_game' which are related via foreign keys
97 db_xml_file_id :: Int,
100 db_schedule_id :: Int,
101 db_tsnupdate :: Maybe Bool,
102 db_category :: String,
104 db_season_type :: String,
105 db_time_stamp :: UTCTime }
108 -- | XML representation of the top level \<message\> element (i.e. a
113 xml_xml_file_id :: Int,
114 xml_heading :: String,
116 xml_schedule_id :: Int,
117 xml_tsnupdate :: Maybe Bool,
118 xml_category :: String,
120 xml_locations :: [ScoreLocation],
121 xml_season_type :: String,
122 xml_game :: ScoreGameXml,
123 xml_time_stamp :: UTCTime }
126 instance ToDb Message where
127 -- | The database representation of a 'Message' is a 'Score'.
128 type Db Message = Score
130 instance FromXml Message where
131 from_xml Message{..} =
133 db_xml_file_id = xml_xml_file_id,
134 db_heading = xml_heading,
135 db_game_id = xml_game_id,
136 db_schedule_id = xml_schedule_id,
137 db_tsnupdate = xml_tsnupdate,
138 db_category = xml_category,
139 db_sport = xml_sport,
140 db_season_type = xml_season_type,
141 db_time_stamp = xml_time_stamp }
144 -- | This lets us insert the XML representation 'Message' directly.
146 instance XmlImport Message
149 -- * ScoreGame / ScoreGameXml
151 -- | This is an embedded field within 'SportsGame'. Each \<status\>
152 -- element has two attributes, a numeral and a type. It also
153 -- contains some text. Rather than put these in their own table, we
154 -- include them in the parent 'SportsGame'.
156 data ScoreGameStatus =
158 db_status_numeral :: Int,
159 db_status_type :: String, -- ^ These are probably only one-character long,
160 -- but they all take the same amount of space
162 db_status_text :: String }
163 deriving (Data, Eq, Show, Typeable)
166 -- | Database representation of a game.
170 db_scores_id :: DefaultKey Score,
173 db_time_r :: Maybe String, -- ^ Time remaining, the format is uncertain.
174 db_status :: ScoreGameStatus,
175 db_notes :: Maybe String }
178 -- | XML representation of a \<game\> element (i.e. a 'ScoreGame').
182 xml_vteam :: ScoreGameVTeam,
183 xml_hteam :: ScoreGameHTeam,
186 xml_time_r :: Maybe String, -- ^ Time remaining, the format is uncertain.
187 xml_status :: ScoreGameStatus,
188 xml_notes :: Maybe String }
191 -- | Pseudo-accessor to get the 'ScoreGameTeam' out of 'xml_vteam'.
192 vteam :: ScoreGameXml -> ScoreGameTeam
193 vteam g = let (ScoreGameVTeam t) = xml_vteam g in t
195 -- | Pseudo-accessor to get the 'ScoreGameTeam' out of 'xml_hteam'.
196 hteam :: ScoreGameXml -> ScoreGameTeam
197 hteam g = let (ScoreGameHTeam t) = xml_hteam g in t
199 instance ToDb ScoreGameXml where
200 -- | The database representation of a 'ScoreGameXml' is a
203 type Db ScoreGameXml = ScoreGame
205 instance FromXmlFk ScoreGameXml where
206 -- | Each 'ScoreGameXml' is contained in (i.e. has a foreign key to)
209 type Parent ScoreGameXml = Score
211 from_xml_fk fk ScoreGameXml{..} =
214 db_vscore = xml_vscore,
215 db_hscore = xml_hscore,
216 db_time_r = xml_time_r,
217 db_status = xml_status,
218 db_notes = xml_notes }
220 -- | This lets us import the database representation 'ScoreGameXml'
223 instance XmlImportFk ScoreGameXml
228 -- | A team that appears in a 'ScoreGame'. This is meant to represent
229 -- both home and away teams.
234 team_name :: String }
237 -- | A wrapper around 'ScoreGameTeam' that lets us distinguish between
238 -- home and away teams. See also 'ScoreGameHTeam'.
240 newtype ScoreGameVTeam =
241 ScoreGameVTeam ScoreGameTeam
245 -- | A wrapper around 'ScoreGameTeam' that lets us distinguish between
246 -- home and away teams. See also 'ScoreGameVTeam'.
248 newtype ScoreGameHTeam =
249 ScoreGameHTeam ScoreGameTeam
253 -- * ScoreGame_ScoreGameTeam
255 -- | Join a 'ScoreGame' with its home/away teams. Database-only. We
256 -- use a join table because the teams are kept unique.
258 data ScoreGame_ScoreGameTeam =
259 ScoreGame_ScoreGameTeam
260 (DefaultKey ScoreGame) -- ^ game id
261 (DefaultKey ScoreGameTeam) -- ^ vteam id
262 (DefaultKey ScoreGameTeam) -- ^ hteam id
267 -- | Database and XML representation of a \<location\>. This is almost
268 -- identical to 'TSN.XML.NewsLocation', but the city/state have not
269 -- appeared optional here so far.
279 -- * Score_ScoreLocation
281 -- | Join each 'Score' with its 'ScoreLocation's. Database-only. We
282 -- use a join table because the locations are kept unique.
284 data Score_ScoreLocation =
287 (DefaultKey ScoreLocation)
291 instance DbImport Message where
294 migrate (undefined :: Score)
295 migrate (undefined :: ScoreGame)
296 migrate (undefined :: ScoreGameTeam)
297 migrate (undefined :: ScoreGame_ScoreGameTeam)
298 migrate (undefined :: ScoreLocation)
299 migrate (undefined :: Score_ScoreLocation)
302 -- Insert the message and get its ID.
303 msg_id <- insert_xml m
305 -- Insert all of the locations contained within this message and
306 -- collect their IDs in a list.
307 location_ids <- mapM insert (xml_locations m)
309 -- Now use that list to construct 'Score_ScoreLocation' objects,
311 mapM_ (insert_ . Score_ScoreLocation msg_id) location_ids
313 -- Insert the game and its hteam/vteam, noting the IDs.
314 game_id <- insert_xml_fk msg_id (xml_game m)
315 vteam_id <- insert (vteam $ xml_game m)
316 hteam_id <- insert (hteam $ xml_game m)
318 -- Finally add a 'ScoreGame_ScoreGameTeam' mapping the
319 -- aforementioned game to its hteam/vteam.
320 insert_ $ ScoreGame_ScoreGameTeam game_id vteam_id hteam_id
322 return ImportSucceeded
325 -- These types don't have special XML representations or field name
326 -- collisions so we use the defaultCodegenConfig and give their
327 -- fields nice simple names.
328 mkPersist defaultCodegenConfig [groundhog|
329 - entity: ScoreGameTeam
330 dbName: scores_games_teams
332 - name: ScoreGameTeam
334 - name: unique_scores_games_team
338 - entity: ScoreLocation
339 dbName: scores_locations
341 - name: ScoreLocation
343 - name: unique_scores_location
345 fields: [city, state, country]
351 -- These types have fields with e.g. db_ and xml_ prefixes, so we
352 -- use our own codegen to peel those off before naming the columns.
353 mkPersist tsn_codegen_config [groundhog|
359 - name: unique_scores
361 # Prevent multiple imports of the same message.
362 fields: [db_xml_file_id]
364 - embedded: ScoreGameStatus
366 - name: db_status_numeral
367 dbName: status_numeral
368 - name: db_status_type
370 - name: db_status_text
383 - { name: status_numeral, dbName: status_numeral }
384 - { name: status_type, dbName: status_type }
385 - { name: status_text, dbName: status_text }
387 - entity: ScoreGame_ScoreGameTeam
388 dbName: scores_games__scores_games_teams
390 - name: ScoreGame_ScoreGameTeam
392 - name: scoreGame_ScoreGameTeam0 # Default created by mkNormalFieldName
393 dbName: scores_games_id
396 - name: scoreGame_ScoreGameTeam1 # Default created by mkNormalFieldName
397 dbName: scores_games_teams_vteam_id
400 - name: scoreGame_ScoreGameTeam2 # Default created by mkNormalFieldName
401 dbName: scores_games_teams_hteam_id
405 - entity: Score_ScoreLocation
406 dbName: scores__scores_locations
408 - name: Score_ScoreLocation
410 - name: score_ScoreLocation0 # Default created by mkNormalFieldName
414 - name: score_ScoreLocation1 # Default created by mkNormalFieldName
415 dbName: scores_locations_id
425 -- | Convert a 'Message' to/from \<message\>.
427 pickle_message :: PU Message
430 xpWrap (from_tuple, to_tuple) $
431 xp11Tuple (xpElem "XML_File_ID" xpInt)
432 (xpElem "heading" xpText)
433 (xpElem "game_id" xpInt)
434 (xpElem "schedule_id" xpInt)
435 (xpOption $ xpElem "tsnupdate" xpPrim)
436 (xpElem "category" xpText)
437 (xpElem "sport" xpText)
438 (xpList pickle_location)
439 (xpElem "seasontype" xpText)
441 (xpElem "time_stamp" xp_time_stamp)
443 from_tuple = uncurryN Message
444 to_tuple m = (xml_xml_file_id m,
458 -- | Convert a 'ScoreLocation' to/from \<location\>.
460 pickle_location :: PU ScoreLocation
463 xpWrap (from_tuple, to_tuple) $
464 xpTriple (xpElem "city" xpText)
465 (xpElem "state" xpText)
466 (xpElem "country" xpText)
469 uncurryN ScoreLocation
470 to_tuple l = (city l, state l, country l)
473 -- | Convert a 'ScoreGameStatus' to/from \<status\>.
475 pickle_status :: PU ScoreGameStatus
478 xpWrap (from_tuple, to_tuple) $
479 xpTriple (xpAttr "numeral" xpInt)
480 (xpAttr "type" xpText)
483 from_tuple = uncurryN ScoreGameStatus
484 to_tuple ScoreGameStatus{..} = (db_status_numeral,
489 -- | Convert a 'ScoreGameXml' to/from \<game\>.
491 pickle_game :: PU ScoreGameXml
494 xpWrap (from_tuple, to_tuple) $
495 xp7Tuple pickle_vteam
497 (xpElem "vscore" xpInt)
498 (xpElem "hscore" xpInt)
499 (xpOption $ xpElem "time_r" xpText)
501 (xpOption $ xpElem "notes" xpText)
503 from_tuple = uncurryN ScoreGameXml
504 to_tuple ScoreGameXml{..} = (xml_vteam,
513 -- | Convert a 'ScoreGameVTeam' to/from \<vteam\>.
515 pickle_vteam :: PU ScoreGameVTeam
518 xpWrap (from_tuple, to_tuple) $
519 xpPair (xpAttr "id" xpText)
522 from_tuple = ScoreGameVTeam . uncurry ScoreGameTeam
523 to_tuple (ScoreGameVTeam ScoreGameTeam{..}) = (team_id, team_name)
526 -- | Convert a 'ScoreGameVTeam' to/from \<hteam\>. Identical to
527 -- 'pickle_vteam' modulo the \"h\" and \"v\".
529 pickle_hteam :: PU ScoreGameHTeam
532 xpWrap (from_tuple, to_tuple) $
533 xpPair (xpAttr "id" xpText)
536 from_tuple = ScoreGameHTeam . uncurry ScoreGameTeam
537 to_tuple (ScoreGameHTeam ScoreGameTeam{..}) = (team_id, team_name)
545 -- | A list of all tests for this module.
547 scores_tests :: TestTree
551 [ test_on_delete_cascade,
552 test_pickle_of_unpickle_is_identity,
553 test_unpickle_succeeds ]
556 -- | If we unpickle something and then pickle it, we should wind up
557 -- with the same thing we started with. WARNING: success of this
558 -- test does not mean that unpickling succeeded.
560 test_pickle_of_unpickle_is_identity :: TestTree
561 test_pickle_of_unpickle_is_identity = testGroup "pickle-unpickle tests"
562 [ check "pickle composed with unpickle is the identity"
563 "test/xml/scoresxml.xml",
565 check "pickle composed with unpickle is the identity (no locations)"
566 "test/xml/scoresxml-no-locations.xml" ]
568 check desc path = testCase desc $ do
569 (expected, actual) <- pickle_unpickle pickle_message path
573 -- | Make sure we can actually unpickle these things.
575 test_unpickle_succeeds :: TestTree
576 test_unpickle_succeeds = testGroup "unpickle tests"
577 [ check "unpickling succeeds"
578 "test/xml/scoresxml.xml",
580 check "unpickling succeeds (no locations)"
581 "test/xml/scoresxml-no-locations.xml" ]
583 check desc path = testCase desc $ do
584 actual <- unpickleable path pickle_message
589 -- | Make sure everything gets deleted when we delete the top-level
592 test_on_delete_cascade :: TestTree
593 test_on_delete_cascade = testGroup "cascading delete tests"
594 [ check "unpickling succeeds"
595 "test/xml/scoresxml.xml"
596 4, -- 2 teams, 2 locations
598 check "unpickling succeeds (no locations)"
599 "test/xml/scoresxml-no-locations.xml"
600 2 -- 2 teams, 0 locations
603 check desc path expected = testCase desc $ do
604 score <- unsafe_unpickle path pickle_message
605 let a = undefined :: Score
606 let b = undefined :: ScoreGame
607 let c = undefined :: ScoreGameTeam
608 let d = undefined :: ScoreGame_ScoreGameTeam
609 let e = undefined :: ScoreLocation
610 let f = undefined :: Score_ScoreLocation
611 actual <- withSqliteConn ":memory:" $ runDbConn $ do
612 runMigration silentMigrationLogger $ do
620 -- No idea how 'delete' works, so do this instead.
622 count_a <- countAll a
623 count_b <- countAll b
624 count_c <- countAll c
625 count_d <- countAll d
626 count_e <- countAll e
627 count_f <- countAll f
628 return $ sum [count_a, count_b, count_c,
629 count_d, count_e, count_f ]