htsn-import-0.2.2: src/TSN/XML/Weather.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
-- | Parse TSN XML for the DTD \"weatherxml.dtd\". Each document
-- contains a bunch of forecasts, which each contain zero or more
-- leagues, which in turn (each) contain a bunch of listings.
--
module TSN.XML.Weather (
dtd,
is_type1,
pickle_message,
teams_are_normal,
-- * Tests
weather_tests,
-- * WARNING: these are private but exported to silence warnings
WeatherConstructor(..),
WeatherDetailedWeatherListingItemConstructor(..),
WeatherForecastConstructor(..),
WeatherForecastListingConstructor(..) )
where
-- System imports.
import Control.Monad ( forM_ )
import Data.Time ( UTCTime )
import Data.Tuple.Curry ( uncurryN )
import qualified Data.Vector.HFixed as H ( HVector, cons, convert )
import Database.Groundhog (
countAll,
deleteAll,
insert_,
migrate )
import Database.Groundhog.Core ( DefaultKey )
import Database.Groundhog.Generic ( runDbConn, runMigrationSilent )
import Database.Groundhog.Sqlite ( withSqliteConn )
import Database.Groundhog.TH (
groundhog,
mkPersist )
import qualified GHC.Generics as GHC ( Generic )
import Test.Tasty ( TestTree, testGroup )
import Test.Tasty.HUnit ( (@?=), testCase )
import Text.XML.HXT.Core (
PU,
XmlTree,
(/>),
(>>>),
addNav,
descendantAxis,
filterAxis,
followingSiblingAxis,
hasName,
remNav,
runLA,
xp8Tuple,
xp9Tuple,
xpAttr,
xpElem,
xpInt,
xpList,
xpOption,
xpPair,
xpText,
xpTriple,
xpWrap )
-- Local imports.
import TSN.Codegen (
tsn_codegen_config )
import TSN.DbImport ( DbImport(..), ImportResult(..), run_dbmigrate )
import TSN.Picklers ( xp_datetime, xp_gamedate, xp_time_stamp )
import TSN.XmlImport ( XmlImport(..), XmlImportFk(..) )
import Xml (
Child(..),
FromXml(..),
FromXmlFk(..),
ToDb(..),
pickle_unpickle,
unpickleable,
unsafe_read_invalid_document,
unsafe_unpickle )
-- | The DTD to which this module corresponds. Used to invoke dbimport.
--
dtd :: String
dtd = "weatherxml.dtd"
--
-- DB/XML Data types
--
-- * WeatherForecastListing/WeatherForecastListingXml
-- | XML representation of a weather forecast listing.
--
data WeatherForecastListingXml =
WeatherForecastListingXml {
xml_teams :: String,
xml_weather :: Maybe String }
deriving (Eq, GHC.Generic, Show)
-- | For 'H.convert'.
--
instance H.HVector WeatherForecastListingXml
-- | Database representation of a weather forecast listing. The
-- 'db_league_name' field should come from the containing \<league\>
-- element which is not stored in the database.
--
data WeatherForecastListing =
WeatherForecastListing {
db_weather_forecasts_id :: DefaultKey WeatherForecast,
db_league_name :: Maybe String,
db_teams :: String,
db_weather :: Maybe String }
-- | We don't make 'WeatherForecastListingXml' an instance of
-- 'FromXmlFk' because it needs some additional information, namely
-- the league name from its containing \<league\> element.
--
-- When supplied with a forecast id and a league name, this will
-- turn an XML listing into a database one.
--
from_xml_fk_league :: DefaultKey WeatherForecast
-> (Maybe String)
-> WeatherForecastListingXml
-> WeatherForecastListing
from_xml_fk_league fk ln WeatherForecastListingXml{..} =
WeatherForecastListing {
db_weather_forecasts_id = fk,
db_league_name = ln,
db_teams = xml_teams,
db_weather = xml_weather }
-- * WeatherLeague
-- | XML representation of a league, as they appear in the weather
-- documents. There is no associated database representation because
-- the league element really adds no information besides its own
-- (usually empty) name. The leagues contain listings, so we
-- associate the league name with each listing instead.
--
data WeatherLeague =
WeatherLeague {
league_name :: Maybe String,
listings :: [WeatherForecastListingXml] }
deriving (Eq, Show)
-- * WeatherForecast/WeatherForecastXml
-- | Database representation of a weather forecast.
--
data WeatherForecast =
WeatherForecast {
db_weather_id :: DefaultKey Weather,
db_game_date :: UTCTime }
-- | XML representation of a weather forecast.
--
data WeatherForecastXml =
WeatherForecastXml {
xml_game_date :: UTCTime,
xml_leagues :: [WeatherLeague] }
deriving (Eq, GHC.Generic, Show)
-- | For 'H.convert'.
--
instance H.HVector WeatherForecastXml
instance ToDb WeatherForecastXml where
-- | The database representation of a 'WeatherForecastXml' is a
-- 'WeatherForecast'.
--
type Db WeatherForecastXml = WeatherForecast
instance Child WeatherForecastXml where
-- | The database type containing a 'WeatherForecastXml' is
-- 'Weather'.
type Parent WeatherForecastXml = Weather
instance FromXmlFk WeatherForecastXml where
-- | To convert a 'WeatherForecastXml' into a 'WeatherForecast', we
-- add the foreign key to the containing 'Weather', and copy the
-- game date.
--
-- This is so short it's pointless to do it generically.
--
from_xml_fk fk WeatherForecastXml{..} =
WeatherForecast {
db_weather_id = fk,
db_game_date = xml_game_date }
-- | This allows us to call 'insert_xml' on an 'WeatherForecastXml'
-- without first converting it to the database representation.
--
instance XmlImportFk WeatherForecastXml
-- * WeatherDetailedWeatherXml
-- | XML Representation of a \<Detailed_Weather\>, which just contains
-- a bunch iof \<DW_Listing\>s. There is no associated database type
-- since these don't really contain any information.
--
data WeatherDetailedWeatherXml =
WeatherDetailedWeatherXml {
xml_detailed_listings :: [WeatherDetailedWeatherListingXml] }
deriving (Eq, Show)
-- * WeatherDetailedWeatherXml
-- | XML Representation of a \<DW_Listing\>. The sport and sport code
-- come as attributes, but then these just contain a bunch of
-- \<Item\>s. There is no associated database type since these don't
-- contain much information. The sport we already know from the
-- \<message\>, while the sport code is ignored since it's already
-- present in each \<Item\>s.
--
data WeatherDetailedWeatherListingXml =
WeatherDetailedWeatherListingXml
String -- xml_dtl_listing_sport, unused
String -- xml_dtl_listing_sport_code, unused
[WeatherDetailedWeatherListingItemXml] -- xml_items
deriving (Eq, GHC.Generic, Show)
-- | Accessor for the third field of WeatherDetailedWeatherListingXml.
-- We don't use field names to avoid the unused field warnings that
-- we'd otherwise get for the first two fields.
--
xml_items :: WeatherDetailedWeatherListingXml
-> [WeatherDetailedWeatherListingItemXml]
xml_items (WeatherDetailedWeatherListingXml _ _ items) = items
-- | For 'H.convert'.
--
instance H.HVector WeatherDetailedWeatherListingXml
-- * WeatherDetailedWeatherListingItem / WeatherDetailedWeatherListingItemXml
-- | Database representation of a detailed weather item. The away/home
-- teams don't use the representation in "TSN.Team" because all
-- we're given is a name, and a team id is required for "TSN.Team".
--
-- We also drop the sport name, because it's given in the parent
-- 'Weather'.
--
-- The leading underscores prevent unused field warnings.
--
data WeatherDetailedWeatherListingItem =
WeatherDetailedWeatherListingItem {
_db_dtl_weather_id :: DefaultKey Weather, -- ^ Avoid name collision by
-- using \"dtl\" prefix.
_db_sport_code :: String,
_db_game_id :: Int,
_db_dtl_game_date :: UTCTime, -- ^ Avoid name clash with \"dtl\" prefix
_db_away_team :: String,
_db_home_team :: String,
_db_weather_type :: Int,
_db_description :: Maybe String,
_db_temp_adjust :: Maybe String,
_db_temperature :: Int }
deriving ( GHC.Generic )
-- | For 'H.cons' and 'H.convert'.
--
instance H.HVector WeatherDetailedWeatherListingItem
-- | XML representation of a detailed weather item. Same as the
-- database representation, only without the foreign key and the
-- sport name that comes from the containing listing.
--
-- The leading underscores prevent unused field warnings.
--
data WeatherDetailedWeatherListingItemXml =
WeatherDetailedWeatherListingItemXml {
_xml_sport_code :: String,
_xml_game_id :: Int,
_xml_dtl_game_date :: UTCTime,
_xml_away_team :: String,
_xml_home_team :: String,
_xml_weather_type :: Int,
_xml_description :: Maybe String,
_xml_temp_adjust :: Maybe String,
_xml_temperature :: Int }
deriving (Eq, GHC.Generic, Show)
-- | For 'H.convert'.
--
instance H.HVector WeatherDetailedWeatherListingItemXml
instance ToDb WeatherDetailedWeatherListingItemXml where
-- | Our database analogue is a 'WeatherDetailedWeatherListingItem'.
type Db WeatherDetailedWeatherListingItemXml =
WeatherDetailedWeatherListingItem
instance Child WeatherDetailedWeatherListingItemXml where
-- | We skip two levels of containers and say that the items belong
-- to the top-level 'Weather'.
type Parent WeatherDetailedWeatherListingItemXml = Weather
instance FromXmlFk WeatherDetailedWeatherListingItemXml where
-- | To convert from the XML to database representation, we simply
-- add the foreign key (to Weather) and copy the rest of the fields.
from_xml_fk = H.cons
-- | This allows us to insert the XML representation directly without
-- having to do the manual XML -\> DB conversion.
--
instance XmlImportFk WeatherDetailedWeatherListingItemXml
-- * Weather/Message
-- | The database representation of a weather message. We don't
-- contain the forecasts or the detailed weather since those are
-- foreigned-keyed to us.
--
data Weather =
Weather {
db_xml_file_id :: Int,
db_sport :: String,
db_title :: String,
db_time_stamp :: UTCTime }
-- | The XML representation of a weather message.
--
data Message =
Message {
xml_xml_file_id :: Int,
xml_heading :: String,
xml_category :: String,
xml_sport :: String,
xml_title :: String,
xml_forecasts :: [WeatherForecastXml],
xml_detailed_weather :: Maybe WeatherDetailedWeatherXml,
xml_time_stamp :: UTCTime }
deriving (Eq, GHC.Generic, Show)
-- | For 'H.convert'.
--
instance H.HVector Message
instance ToDb Message where
-- | The database representation of 'Message' is 'Weather'.
--
type Db Message = Weather
instance FromXml Message where
-- | To get a 'Weather' from a 'Message', we drop a bunch of
-- unwanted fields.
--
from_xml Message{..} =
Weather {
db_xml_file_id = xml_xml_file_id,
db_sport = xml_sport,
db_title = xml_title,
db_time_stamp = xml_time_stamp }
-- | This allows us to insert the XML representation 'Message'
-- directly.
--
instance XmlImport Message
--
-- * Database stuff
--
mkPersist tsn_codegen_config [groundhog|
- entity: Weather
constructors:
- name: Weather
uniques:
- name: unique_weather
type: constraint
# Prevent multiple imports of the same message.
fields: [db_xml_file_id]
- entity: WeatherForecast
dbName: weather_forecasts
constructors:
- name: WeatherForecast
fields:
- name: db_weather_id
reference:
onDelete: cascade
- entity: WeatherForecastListing
dbName: weather_forecast_listings
constructors:
- name: WeatherForecastListing
fields:
- name: db_weather_forecasts_id
reference:
onDelete: cascade
# We rename the two fields that needed a "dtl" prefix to avoid a name
# clash.
- entity: WeatherDetailedWeatherListingItem
dbName: weather_detailed_items
constructors:
- name: WeatherDetailedWeatherListingItem
fields:
- name: _db_dtl_weather_id
dbName: weather_id
reference:
onDelete: cascade
- name: _db_dtl_game_date
dbName: game_date
|]
-- | There are two different types of documents that claim to be
-- \"weatherxml.dtd\". The first, more common type has listings
-- within forecasts. The second type has forecasts within
-- listings. Clearly we can't parse both of these using the same
-- parser!
--
-- For now we're simply punting on the issue and refusing to parse
-- the second type. This will check the given @xmltree@ to see if
-- there are any forecasts contained within listings. If there are,
-- then it's the second type that we don't know what to do with.
--
is_type1 :: XmlTree -> Bool
is_type1 xmltree =
case elements of
[] -> True
_ -> False
where
parse :: XmlTree -> [XmlTree]
parse = runLA $ hasName "/"
/> hasName "message"
/> hasName "listing"
/> hasName "forecast"
elements = parse xmltree
-- | Some weatherxml documents even have the Home/Away teams in the
-- wrong order. We can't parse that! This next bit of voodoo detects
-- whether or not there are any \<HomeTeam\> elements that are
-- directly followed by sibling \<AwayTeam\> elements. This is the
-- opposite of the usual order.
--
teams_are_normal :: XmlTree -> Bool
teams_are_normal xmltree =
case elements of
[] -> True
_ -> False
where
parse :: XmlTree -> [XmlTree]
parse = runLA $ hasName "/"
/> hasName "message"
/> hasName "Detailed_Weather"
/> hasName "DW_Listing"
/> hasName "Item"
>>> addNav
>>> descendantAxis
>>> filterAxis (hasName "HomeTeam")
>>> followingSiblingAxis
>>> remNav
>>> hasName "AwayTeam"
elements = parse xmltree
instance DbImport Message where
dbmigrate _ =
run_dbmigrate $ do
migrate (undefined :: Weather)
migrate (undefined :: WeatherForecast)
migrate (undefined :: WeatherForecastListing)
migrate (undefined :: WeatherDetailedWeatherListingItem)
dbimport m = do
-- First we insert the top-level weather record.
weather_id <- insert_xml m
-- Next insert all of the forecasts, one at a time.
forM_ (xml_forecasts m) $ \forecast -> do
forecast_id <- insert_xml_fk weather_id forecast
-- With the forecast id in hand, loop through this forecast's
-- leagues...
forM_ (xml_leagues forecast) $ \league -> do
-- Construct the function that converts an XML listing to a
-- database one.
let todb = from_xml_fk_league forecast_id (league_name league)
-- Now use it to convert all of the XML listings.
let db_listings = map todb (listings league)
-- And finally, insert those DB listings.
mapM_ insert_ db_listings
-- Now we do the detailed weather items.
case (xml_detailed_weather m) of
Nothing -> return ()
Just dw -> do
let detailed_listings = xml_detailed_listings dw
let items = concatMap xml_items detailed_listings
mapM_ (insert_xml_fk_ weather_id) items
return ImportSucceeded
--
-- * Pickling
--
-- | Pickler to convert a 'WeatherForecastListingXml' to/from XML.
--
pickle_listing :: PU WeatherForecastListingXml
pickle_listing =
xpElem "listing" $
xpWrap (from_pair, H.convert) $
xpPair
(xpElem "teams" xpText)
(xpElem "weather" (xpOption xpText))
where
from_pair = uncurry WeatherForecastListingXml
-- | Pickler to convert a 'WeatherLeague' to/from XML.
--
pickle_league :: PU WeatherLeague
pickle_league =
xpElem "league" $
xpWrap (from_pair, to_pair) $
xpPair
(xpAttr "name" $ xpOption xpText)
(xpList pickle_listing)
where
from_pair = uncurry WeatherLeague
to_pair WeatherLeague{..} = (league_name, listings)
-- | Pickler to convert a 'WeatherForecastXml' to/from XML.
--
pickle_forecast :: PU WeatherForecastXml
pickle_forecast =
xpElem "forecast" $
xpWrap (from_pair, to_pair) $
xpPair
(xpAttr "gamedate" xp_gamedate)
(xpList pickle_league)
where
from_pair = uncurry WeatherForecastXml
to_pair WeatherForecastXml{..} = (xml_game_date,
xml_leagues)
-- | (Un)pickle a 'WeatherDetailedWeatherListingItemXml'.
--
pickle_item :: PU WeatherDetailedWeatherListingItemXml
pickle_item =
xpElem "Item" $
xpWrap (from_tuple, H.convert) $
xp9Tuple (xpElem "Sportcode" xpText)
(xpElem "GameID" xpInt)
(xpElem "Gamedate" xp_datetime)
(xpElem "AwayTeam" xpText)
(xpElem "HomeTeam" xpText)
(xpElem "WeatherType" xpInt)
(xpElem "Description" (xpOption xpText))
(xpElem "TempAdjust" (xpOption xpText))
(xpElem "Temperature" xpInt)
where
from_tuple = uncurryN WeatherDetailedWeatherListingItemXml
-- | (Un)pickle a 'WeatherDetailedWeatherListingXml'.
--
pickle_dw_listing :: PU WeatherDetailedWeatherListingXml
pickle_dw_listing =
xpElem "DW_Listing" $
xpWrap (from_tuple, H.convert) $
xpTriple (xpAttr "SportCode" xpText)
(xpAttr "Sport" xpText)
(xpList pickle_item)
where
from_tuple = uncurryN WeatherDetailedWeatherListingXml
-- | (Un)pickle a 'WeatherDetailedWeatherXml'
--
pickle_detailed_weather :: PU WeatherDetailedWeatherXml
pickle_detailed_weather =
xpElem "Detailed_Weather" $
xpWrap (WeatherDetailedWeatherXml, xml_detailed_listings)
(xpList pickle_dw_listing)
-- | Pickler to convert a 'Message' to/from XML.
--
pickle_message :: PU Message
pickle_message =
xpElem "message" $
xpWrap (from_tuple, H.convert) $
xp8Tuple
(xpElem "XML_File_ID" xpInt)
(xpElem "heading" xpText)
(xpElem "category" xpText)
(xpElem "sport" xpText)
(xpElem "title" xpText)
(xpList pickle_forecast)
(xpOption pickle_detailed_weather)
(xpElem "time_stamp" xp_time_stamp)
where
from_tuple = uncurryN Message
--
-- * Tasty tests
--
weather_tests :: TestTree
weather_tests =
testGroup
"Weather tests"
[ test_on_delete_cascade,
test_pickle_of_unpickle_is_identity,
test_unpickle_succeeds,
test_types_detected_correctly,
test_normal_teams_detected_correctly ]
-- | If we unpickle something and then pickle it, we should wind up
-- with the same thing we started with. WARNING: success of this
-- test does not mean that unpickling succeeded.
--
test_pickle_of_unpickle_is_identity :: TestTree
test_pickle_of_unpickle_is_identity = testGroup "pickle-unpickle tests"
[ check "pickle composed with unpickle is the identity"
"test/xml/weatherxml.xml",
check "pickle composed with unpickle is the identity (detailed)"
"test/xml/weatherxml-detailed.xml" ]
where
check desc path = testCase desc $ do
(expected, actual) <- pickle_unpickle pickle_message path
actual @?= expected
-- | Make sure we can actually unpickle these things.
--
test_unpickle_succeeds :: TestTree
test_unpickle_succeeds = testGroup "unpickle tests"
[ check "unpickling succeeds"
"test/xml/weatherxml.xml",
check "unpickling succeeds (detailed)"
"test/xml/weatherxml-detailed.xml",
check "unpickling succeeds (empty weather)"
"test/xml/weatherxml-empty-weather.xml"]
where
check desc path = testCase desc $ do
actual <- unpickleable path pickle_message
let expected = True
actual @?= expected
-- | Make sure everything gets deleted when we delete the top-level
-- record.
--
test_on_delete_cascade :: TestTree
test_on_delete_cascade = testGroup "cascading delete tests"
[ check "deleting weather deletes its children"
"test/xml/weatherxml.xml",
check "deleting weather deletes its children (detailed)"
"test/xml/weatherxml-detailed.xml",
check "deleting weather deletes its children (empty weather)"
"test/xml/weatherxml-empty-weather.xml"]
where
check desc path = testCase desc $ do
weather <- unsafe_unpickle path pickle_message
let a = undefined :: Weather
let b = undefined :: WeatherForecast
let c = undefined :: WeatherForecastListing
let d = undefined :: WeatherDetailedWeatherListingItem
actual <- withSqliteConn ":memory:" $ runDbConn $ do
runMigrationSilent $ do
migrate a
migrate b
migrate c
migrate d
_ <- dbimport weather
deleteAll a
count_a <- countAll a
count_b <- countAll b
count_c <- countAll c
count_d <- countAll d
return $ count_a + count_b + count_c + count_d
let expected = 0
actual @?= expected
-- | We want to make sure type1 documents are detected as type1, and
-- type2 documents detected as type2..
--
test_types_detected_correctly :: TestTree
test_types_detected_correctly =
testGroup "weatherxml types detected correctly"
[ check "test/xml/weatherxml.xml"
"first type detected correctly"
True,
check "test/xml/weatherxml-detailed.xml"
"first type detected correctly (detailed)"
True,
check "test/xml/weatherxml-empty-weather.xml"
"first type detected correctly (empty weather)"
True,
check "test/xml/weatherxml-type2.xml"
"second type detected correctly"
False ]
where
check path desc expected = testCase desc $ do
xmltree <- unsafe_read_invalid_document path
let actual = is_type1 xmltree
actual @?= expected
-- | We want to make sure normal teams are detected as normal, and the
-- backwards ones are flagged as backwards.
--
test_normal_teams_detected_correctly :: TestTree
test_normal_teams_detected_correctly =
testGroup "team order is detected correctly" [
check "normal teams are detected correctly"
"test/xml/weatherxml.xml"
True,
check "backwards teams are detected correctly"
"test/xml/weatherxml-backwards-teams.xml"
False ]
where
check desc path expected = testCase desc $ do
xmltree <- unsafe_read_invalid_document path
let actual = teams_are_normal xmltree
actual @?= expected