packages feed

htsn-import-0.2.2: src/TSN/XML/EarlyLine.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}

-- | Parse TSN XML for the DTD \"earlylineXML.dtd\". For that DTD,
--   each \<message\> element contains a bunch of \<date\>s, and those
--   \<date\>s contain a single \<game\>. In the database, we merge
--   the date info into the games, and key the games to the messages.
--
--   Real life is not so simple, however. There is another module,
--   "TSN.XML.MLBEarlyLine" that is something of a subclass of this
--   one. It contains early lines, but only for MLB games. The data
--   types and XML schema are /almost/ the same, but TSN like to make
--   things difficult.
--
--   A full list of the differences is given in that module. In this
--   one, we mention where data types have been twerked a little to
--   support the second document type.
--
module TSN.XML.EarlyLine (
  EarlyLine, -- Used in TSN.XML.MLBEarlyLine
  EarlyLineGame, -- Used in TSN.XML.MLBEarlyLine
  dtd,
  pickle_message,
  -- * Tests
  early_line_tests,
  -- * WARNING: these are private but exported to silence warnings
  EarlyLineConstructor(..),
  EarlyLineGameConstructor(..) )
where

-- System imports.
import Control.Monad ( join )
import Data.Time ( UTCTime(..) )
import Data.Tuple.Curry ( uncurryN )
import qualified Data.Vector.HFixed as H ( HVector, 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,
  xp4Tuple,
  xp6Tuple,
  xp7Tuple,
  xpAttr,
  xpElem,
  xpInt,
  xpList,
  xpOption,
  xpPair,
  xpText,
  xpWrap )

-- Local imports.
import Misc ( double_just )
import TSN.Codegen ( tsn_codegen_config )
import TSN.DbImport ( DbImport(..), ImportResult(..), run_dbmigrate )
import TSN.Picklers (
  xp_ambiguous_time,
  xp_attr_option,
  xp_early_line_date,
  xp_time_stamp )
import TSN.XmlImport ( XmlImport(..) )
import Xml (
  FromXml(..),
  ToDb(..),
  pickle_unpickle,
  unpickleable,
  unsafe_unpickle )


-- | The DTD to which this module corresponds. Used to invoke dbimport.
--
dtd :: String
dtd = "earlylineXML.dtd"

--
-- * DB/XML data types
--

-- * EarlyLine/Message

-- | Database representation of a 'Message'. It lacks the \<date\>
--   elements since they're really properties of the games that they
--   contain.
--
data EarlyLine =
  EarlyLine {
    db_xml_file_id :: Int,
    db_heading :: String,
    db_category :: String,
    db_sport :: String,
    db_title :: String,
    db_time_stamp :: UTCTime }
  deriving (Eq, Show)



-- | XML Representation of an 'EarlyLine'. It has the same
--   fields, but in addition contains the 'xml_dates'.
--
data Message =
  Message {
    xml_xml_file_id :: Int,
    xml_heading :: String,
    xml_category :: String,
    xml_sport :: String,
    xml_title :: String,
    xml_dates :: [EarlyLineDate],
    xml_time_stamp :: UTCTime }
  deriving (Eq, GHC.Generic, Show)

-- | For 'H.convert'.
--
instance H.HVector Message


instance ToDb Message where
  -- | The database analogue of a 'Message' is an 'EarlyLine'.
  --
  type Db Message = EarlyLine


-- | The 'FromXml' instance for 'Message' is required for the
--   'XmlImport' instance.
--
instance FromXml Message where
  -- | To convert a 'Message' to an 'EarlyLine', we just drop
  --   the 'xml_dates'.
  --
  from_xml Message{..} =
    EarlyLine {
      db_xml_file_id = xml_xml_file_id,
      db_heading = xml_heading,
      db_category = xml_category,
      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



-- * EarlyLineDate / EarlyLineGameWithNote

-- | This is a very sad data type. It exists so that we can
--   successfully unpickle/pickle the MLB_earlylineXML.dtd documents
--   and get back what we started with. In that document type, the
--   dates all have multiple \<game\>s associated with them (as
--   children). But the dates also have multiple \<note\>s as
--   children, and we're supposed to figure out which notes go with
--   which games based on the order that they appear in the XML
--   file. Yeah, right.
--
--   In any case, instead of expecting the games and notes in some
--   nice order, we use this data type to expect \"a game and maybe a
--   note\" multiple times. This will pair the notes with only one
--   game, rather than all of the games that TSN think it should go
--   with. But it allows us to pickle and unpickle correctly at least.
--
data EarlyLineGameWithNote =
  EarlyLineGameWithNote
    (Maybe String) -- date_note, unused
    EarlyLineGameXml -- date_game
  deriving (Eq, GHC.Generic, Show)

-- | Accessor for the game within a 'EarlyLineGameWithNote'. We define
--   this ourselves to avoid an unused field warning for date_note.
--
date_game :: EarlyLineGameWithNote -> EarlyLineGameXml
date_game (EarlyLineGameWithNote _ g) = g

-- | For 'H.convert'.
--
instance H.HVector EarlyLineGameWithNote



-- | XML representation of a \<date\>. It has a \"value\" attribute
--   containing the actual date string. As children it contains a
--   (non-optional) note, and a game. The note and date value are
--   properties of the game as far as I can tell.
--
data EarlyLineDate =
  EarlyLineDate {
    date_value :: UTCTime,
    date_games_with_notes :: [EarlyLineGameWithNote] }
  deriving (Eq, GHC.Generic, Show)

-- | For 'H.convert'.
--
instance H.HVector EarlyLineDate



-- * EarlyLineGame / EarlyLineGameXml

-- | Database representation of a \<game\> in earlylineXML.dtd and
--   MLB_earlylineXML.dtd. We've had to make a sacrifice here to
--   support both document types. Since it's not possible to pair the
--   \<note\>s with \<game\>s reliably in MLB_earlylineXML.dtd, we
--   have omitted the notes entirely. This is sad, but totally not our
--   fault.
--
--   In earlylineXML.dtd, each \<date\> and thus each \<note\> is
--   paired with exactly one \<game\>, so if we only cared about that
--   document type, we could have retained the notes.
--
--   In earlylinexml.DTD, the over/under is required, but in
--   MLB_earlylinexml.DTD it is not. So another compromise is to have
--   it optional here.
--
--   The 'db_game_time' should be the combined date/time using the
--   date value from the \<game\> element's containing
--   \<date\>. That's why EarlyLineGame isn't an instance of
--   'FromXmlFk': the foreign key isn't enough to construct one, we
--   also need the date.
--
data EarlyLineGame =
  EarlyLineGame {
    db_early_lines_id :: DefaultKey EarlyLine,
    db_game_time :: UTCTime, -- ^ Combined date/time
    db_away_team :: EarlyLineGameTeam,
    db_home_team :: EarlyLineGameTeam,
    db_over_under :: Maybe String }


-- | XML representation of a 'EarlyLineGame'. Comparatively, it lacks
--   only the foreign key to the parent message.
--
data EarlyLineGameXml =
  EarlyLineGameXml {
    xml_game_time :: Maybe UTCTime, -- ^ Only an ambiguous time string,
                                    --   e.g. \"8:30\". Can be empty.
    xml_away_team :: EarlyLineGameTeamXml,
    xml_home_team :: EarlyLineGameTeamXml,
    xml_over_under :: Maybe String }
  deriving (Eq, GHC.Generic, Show)


-- | For 'H.convert'.
--
instance H.HVector EarlyLineGameXml


-- * EarlyLineGameTeam / EarlyLineGameTeamXml

-- | Database representation of an EarlyLine team, used in both
--   earlylineXML.dtd and MLB_earlylineXML.dtd. It doubles as an
--   embedded type within the DB representation 'EarlyLineGame'.
--
--   The team name is /not/ optional. However, since we're overloading
--   the XML representation, we're constructing 'db_team_name' name
--   from two Maybes, 'xml_team_name_attr' and
--   'xml_team_name_text'. To ensure type safety (and avoid a runtime
--   crash), we allow the database field to be optional as well.
--
data EarlyLineGameTeam =
  EarlyLineGameTeam {
    db_rotation_number :: Maybe Int, -- ^ Usually there but sometimes empty.
    db_line :: Maybe String, -- ^ Can be blank, a Double, or \"off\".
    db_team_name :: Maybe String, -- ^ NOT optional, see the data type docs.
    db_pitcher :: Maybe String -- ^ Optional in MLB_earlylineXML.dtd,
                               --   always absent in earlylineXML.dtd.
    }


-- | This here is an abomination. What we've got is an XML
--   representation, not for either earlylineXML.dtd or
--   MLB_earlylineXML.dtd, but one that will work for /both/. Even
--   though they represent the teams totally differently! Argh!
--
--   The earlylineXML.dtd teams look like,
--
--   \<teamA rotation=\"709\" line=\"\">Miami\</teamA\>
--
--   While the MLB_earlylineXML.dtd teams look like,
--
--   <teamA rotation="901" name="LOS">
--   <pitcher>D.Haren</pitcher>
--   <line>-130</line>
--   </teamA>
--
--   So that's cool. This data type has placeholders that should allow
--   the name/line to appear either as an attribute or as a text
--   node. We'll sort it all out in the conversion to
--   EarlyLineGameTeam.
--
data EarlyLineGameTeamXml =
  EarlyLineGameTeamXml {
    xml_rotation_number :: Maybe Int,
    xml_line_attr :: Maybe String,
    xml_team_name_attr :: Maybe String,
    xml_team_name_text :: Maybe String,
    xml_pitcher :: Maybe String,
    xml_line_elem :: Maybe String }
  deriving (Eq, Show)



instance ToDb EarlyLineGameTeamXml where
  -- | The database analogue of a 'EarlyLineGameTeamXml' is an
  --   'EarlyLineGameTeam', although the DB type is merely embedded
  --   in another type.
  --
  type Db EarlyLineGameTeamXml = EarlyLineGameTeam


-- | The 'FromXml' instance for 'EarlyLineGameTeamXml' lets us convert
--   it to a 'EarlyLineGameTeam' easily.
--
instance FromXml EarlyLineGameTeamXml where
  -- | To convert a 'EarlyLineGameTeamXml' to an 'EarlyLineGameTeam',
  --   we figure how its fields were represented and choose the ones
  --   that are populated. For example if the \"line\" attribute was
  --   there, we'll use it, but if now, we'll use the \<line\>
  --   element.
  --
  from_xml EarlyLineGameTeamXml{..} =
    EarlyLineGameTeam {
      db_rotation_number = xml_rotation_number,
      db_line = merge xml_line_attr xml_line_elem,
      db_team_name = merge xml_team_name_attr xml_team_name_text,
      db_pitcher = xml_pitcher }
    where
      merge :: Maybe String -> Maybe String -> Maybe String
      merge Nothing y = y
      merge x Nothing = x
      merge _ _ = Nothing




-- | Convert an 'EarlyLineDate' into a list of 'EarlyLineGame's. Each
--   date has one or more games, and the fields that belong to the date
--   should really be in the game anyway. So the database
--   representation of a game has the combined fields of the XML
--   date/game.
--
--   This function gets the games out of a date, and then sticks the
--   date value inside the games. It also adds the foreign key
--   reference to the games' parent message, and returns the result.
--
--   This would convert a single date to a single game if we only
--   needed to support earlylineXML.dtd and not MLB_earlylineXML.dtd.
--
date_to_games :: (DefaultKey EarlyLine) -> EarlyLineDate -> [EarlyLineGame]
date_to_games fk date =
  map convert_game games_only
  where
    -- | Get the list of games out of a date (i.e. drop the notes).
    --
    games_only :: [EarlyLineGameXml]
    games_only = (map date_game (date_games_with_notes date))

    -- | Stick the date value into the given game. If our
    --   'EarlyLineGameXml' has an 'xml_game_time', then we combine it
    --   with the day portion of the supplied @date@. If not, then we
    --   just use @date as-is.
    --
    combine_date_time :: Maybe UTCTime -> UTCTime
    combine_date_time (Just t) =
      UTCTime (utctDay $ date_value date) (utctDayTime t)
    combine_date_time Nothing = date_value date

    -- | Convert an XML game to a database one.
    --
    convert_game :: EarlyLineGameXml -> EarlyLineGame
    convert_game EarlyLineGameXml{..} =
      EarlyLineGame {
        db_early_lines_id = fk,
        db_game_time = combine_date_time xml_game_time,
        db_away_team = from_xml xml_away_team,
        db_home_team = from_xml xml_home_team,
        db_over_under = xml_over_under }


--
-- * Database stuff
--

instance DbImport Message where
  dbmigrate _ =
    run_dbmigrate $ do
      migrate (undefined :: EarlyLine)
      migrate (undefined :: EarlyLineGame)

  dbimport m = do
    -- Insert the message and obtain its ID.
    msg_id <- insert_xml m

    -- Create a function that will turn a list of dates into a list of
    -- games by converting each date to its own list of games, and
    -- then concatenating all of the game lists together.
    let convert_dates_to_games = concatMap (date_to_games msg_id)

    -- Now use it to make dem games.
    let games = convert_dates_to_games (xml_dates m)

    -- And insert all of them
    mapM_ insert_ games

    return ImportSucceeded


mkPersist tsn_codegen_config [groundhog|

- entity: EarlyLine
  dbName: early_lines
  constructors:
    - name: EarlyLine
      uniques:
        - name: unique_early_lines
          type: constraint
          # Prevent multiple imports of the same message.
          fields: [db_xml_file_id]


- entity: EarlyLineGame
  dbName: early_lines_games
  constructors:
    - name: EarlyLineGame
      fields:
        - name: db_early_lines_id
          reference:
            onDelete: cascade
        - name: db_away_team
          embeddedType:
            - {name: rotation_number, dbName: away_team_rotation_number}
            - {name: line, dbName: away_team_line}
            - {name: team_name, dbName: away_team_name}
            - {name: pitcher, dbName: away_team_pitcher}
        - name: db_home_team
          embeddedType:
            - {name: rotation_number, dbName: home_team_rotation_number}
            - {name: line, dbName: home_team_line}
            - {name: team_name, dbName: home_team_name}
            - {name: pitcher, dbName: home_team_pitcher}

- embedded: EarlyLineGameTeam
  fields:
    - name: db_rotation_number
      dbName: rotation_number
    - name: db_line
      dbName: line
    - name: db_team_name
      dbName: team_name
    - name: db_pitcher
      dbName: pitcher
|]



--
-- * Pickling
--


-- | Pickler for the top-level 'Message'.
--
pickle_message :: PU Message
pickle_message =
  xpElem "message" $
    xpWrap (from_tuple, H.convert) $
    xp7Tuple (xpElem "XML_File_ID" xpInt)
             (xpElem "heading" xpText)
             (xpElem "category" xpText)
             (xpElem "sport" xpText)
             (xpElem "title" xpText)
             (xpList pickle_date)
             (xpElem "time_stamp" xp_time_stamp)
  where
    from_tuple = uncurryN Message



-- | Pickler for a '\<note\> followed by a \<game\>. We turn them into
--   a 'EarlyLineGameWithNote'.
--
pickle_game_with_note :: PU EarlyLineGameWithNote
pickle_game_with_note =
  xpWrap (from_tuple, H.convert) $
    xpPair (xpOption $ xpElem "note" xpText)
           pickle_game
  where
    from_tuple = uncurry EarlyLineGameWithNote


-- | Pickler for the \<date\> elements within each \<message\>.
--
pickle_date :: PU EarlyLineDate
pickle_date =
  xpElem "date" $
    xpWrap (from_tuple, H.convert) $
    xpPair (xpAttr "value" xp_early_line_date)
           (xpList pickle_game_with_note)
  where
    from_tuple = uncurry EarlyLineDate



-- | Pickler for the \<game\> elements within each \<date\>.
--
pickle_game :: PU EarlyLineGameXml
pickle_game =
  xpElem "game" $
    xpWrap (from_tuple, H.convert) $
    xp4Tuple (xpElem "time" (xpOption xp_ambiguous_time))
             pickle_away_team
             pickle_home_team
             (xpElem "over_under" (xpOption xpText))
  where
    from_tuple = uncurryN EarlyLineGameXml



-- | Pickle an away team (\<teamA\>) element within a \<game\>. Most
--   of the work (common with the home team pickler) is done by
--   'pickle_team'.
--
pickle_away_team :: PU EarlyLineGameTeamXml
pickle_away_team = xpElem "teamA" pickle_team


-- | Pickle a home team (\<teamH\>) element within a \<game\>. Most
--   of the work (common with theaway team pickler) is done by
--   'pickle_team'.
--
pickle_home_team :: PU EarlyLineGameTeamXml
pickle_home_team = xpElem "teamH" pickle_team


-- | Team pickling common to both 'pickle_away_team' and
--   'pickle_home_team'. Handles everything inside the \<teamA\> and
--   \<teamH\> elements. We try to parse the line/name as both an
--   attribute and an element in order to accomodate
--   MLB_earlylineXML.dtd.
--
--   The \"line\" and \"pitcher\" fields wind up being double-Maybes,
--   since they can be empty even if they exist.
--
pickle_team :: PU EarlyLineGameTeamXml
pickle_team =
  xpWrap (from_tuple, to_tuple') $
  xp6Tuple (xpAttr "rotation" xp_attr_option)
           (xpOption $ xpAttr "line" (xpOption xpText))
           (xpOption $ xpAttr "name" xpText)
           (xpOption xpText)
           (xpOption $ xpElem "pitcher" (xpOption xpText))
           (xpOption $ xpElem "line" (xpOption xpText))
  where
    from_tuple (u,v,w,x,y,z) =
      EarlyLineGameTeamXml u (join v) w x (join y) (join z)

    to_tuple' (EarlyLineGameTeamXml u v w x y z) =
      (u, double_just v, w, x, double_just y, double_just z)




--
-- * Tasty Tests
--

-- | A list of all tests for this module.
--
early_line_tests :: TestTree
early_line_tests =
  testGroup
    "EarlyLine tests"
    [ test_on_delete_cascade,
      test_pickle_of_unpickle_is_identity,
      test_unpickle_succeeds ]

-- | 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/earlylineXML.xml",

    check "pickle composed with unpickle is the identity (empty game time)"
          "test/xml/earlylineXML-empty-game-time.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/earlylineXML.xml",

    check "unpickling succeeds (empty game time)"
          "test/xml/earlylineXML-empty-game-time.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 early_lines deletes its children"
          "test/xml/earlylineXML.xml",

    check "deleting early_lines deletes its children (empty game time)"
          "test/xml/earlylineXML-empty-game-time.xml" ]
  where
    check desc path = testCase desc $ do
      results <- unsafe_unpickle path pickle_message
      let a = undefined :: EarlyLine
      let b = undefined :: EarlyLineGame

      actual <- withSqliteConn ":memory:" $ runDbConn $ do
                  runMigrationSilent $ do
                    migrate a
                    migrate b
                  _ <- dbimport results
                  deleteAll a
                  count_a <- countAll a
                  count_b <- countAll b
                  return $ sum [count_a, count_b]
      let expected = 0
      actual @?= expected