packages feed

htsn-import-0.0.4: src/TSN/XML/Weather.hs

{-# 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 one league, and
--   that league contains a bunch of listings.
--
module TSN.XML.Weather (
  dtd,
  pickle_message,
  -- * Tests
  weather_tests,
  -- * WARNING: these are private but exported to silence warnings
  WeatherConstructor(..),
  WeatherForecastConstructor(..),
  WeatherForecastListingConstructor(..) )
where

-- System imports.
import Control.Monad ( forM_ )
import Data.Time ( UTCTime )
import Data.Tuple.Curry ( uncurryN )
import Database.Groundhog (
  countAll,
  executeRaw,
  migrate,
  runMigration,
  silentMigrationLogger )
import Database.Groundhog.Core ( DefaultKey )
import Database.Groundhog.Generic ( runDbConn )
import Database.Groundhog.Sqlite ( withSqliteConn )
import Database.Groundhog.TH (
  groundhog,
  mkPersist )
import Test.Tasty ( TestTree, testGroup )
import Test.Tasty.HUnit ( (@?=), testCase )
import Text.XML.HXT.Core (
  PU,
  xp7Tuple,
  xpAttr,
  xpElem,
  xpInt,
  xpList,
  xpOption,
  xpPair,
  xpText,
  xpWrap )

-- Local imports.
import TSN.Codegen (
  tsn_codegen_config )
import TSN.DbImport ( DbImport(..), ImportResult(..), run_dbmigrate )
import TSN.Picklers ( xp_gamedate, xp_time_stamp )
import TSN.XmlImport ( XmlImport(..), XmlImportFk(..) )
import Xml (
  FromXml(..),
  FromXmlFk(..),
  ToDb(..),
  pickle_unpickle,
  unpickleable,
  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 :: String }
  deriving (Eq, Show)

-- | Database representation of a weather forecast listing.
--
data WeatherForecastListing =
  WeatherForecastListing {
    db_weather_forecasts_id :: DefaultKey WeatherForecast,
    db_teams :: String,
    db_weather :: String }


-- | The database analogue of a 'WeatherForecastListingXml' is
--   'WeatherForecastListing'.
--
instance ToDb WeatherForecastListingXml where
  type Db WeatherForecastListingXml = WeatherForecastListing

-- | This is needed to define the 'XmlImportFk' instance for
--   'WeatherForecastListing'.
--
instance FromXmlFk WeatherForecastListingXml where
  -- | Each 'WeatherForecastListingXml' is contained in a
  --   'WeatherForecast'.
  --
  type Parent WeatherForecastListingXml = WeatherForecast

  from_xml_fk fk WeatherForecastListingXml{..} =
    WeatherForecastListing {
      db_weather_forecasts_id = fk,
      db_teams = xml_teams,
      db_weather = xml_weather }

-- | This allows us to insert the XML representation
--   'WeatherForecastListingXml' directly.
--
instance XmlImportFk WeatherForecastListingXml


-- * 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. Since there's exactly one league per
--   forecast, we just store the league_name in the database
--   representation of a forecast.
--
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,
    db_league_name :: Maybe String }

-- | XML representation of a weather forecast. It would have been
--   cleaner to omit the 'WeatherLeague' middleman, but having it
--   simplifies parsing.
--
data WeatherForecastXml =
  WeatherForecastXml {
    xml_game_date :: UTCTime,
    xml_league :: WeatherLeague }
  deriving (Eq, Show)

instance ToDb WeatherForecastXml where
  -- | The database representation of a 'WeatherForecastXml' is a
  --   'WeatherForecast'.
  --
  type Db WeatherForecastXml = WeatherForecast

instance FromXmlFk WeatherForecastXml where
  type Parent WeatherForecastXml = Weather

  -- | To convert a 'WeatherForecastXml' into a 'WeatherForecast', we
  --   replace the 'WeatherLeague' with its name.
  --
  from_xml_fk fk WeatherForecastXml{..} =
    WeatherForecast {
      db_weather_id  = fk,
      db_game_date   = xml_game_date,
      db_league_name = (league_name xml_league) }


-- | This allows us to call 'insert_xml' on an 'WeatherForecastXml'
--   without first converting it to the database representation.
--
instance XmlImportFk WeatherForecastXml


-- * Weather/Message

-- | The database representation of a weather message.
--
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_time_stamp :: UTCTime }
  deriving (Eq, Show)

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

|]


instance DbImport Message where
  dbmigrate _ =
    run_dbmigrate $ do
      migrate (undefined :: Weather)
      migrate (undefined :: WeatherForecast)
      migrate (undefined :: WeatherForecastListing)

  dbimport m = do
    -- The weather database schema has a nice linear structure. 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

      -- Insert all of this forecast's listings.
      mapM_ (insert_xml_fk_ forecast_id) (listings $ xml_league forecast)

    return ImportSucceeded


---
--- Pickling
---

-- | Pickler to convert a 'WeatherForecastListingXml' to/from XML.
--
pickle_listing :: PU WeatherForecastListingXml
pickle_listing =
  xpElem "listing" $
    xpWrap (from_pair, to_pair) $
      xpPair
        (xpElem "teams" xpText)
        (xpElem "weather" xpText)
  where
    from_pair = uncurry WeatherForecastListingXml
    to_pair WeatherForecastListingXml{..} = (xml_teams, xml_weather)


-- | 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)
        pickle_league
  where
    from_pair = uncurry WeatherForecastXml
    to_pair WeatherForecastXml{..} = (xml_game_date,
                                      xml_league)



-- | Pickler to convert a 'Message' to/from XML.
--
pickle_message :: PU Message
pickle_message =
  xpElem "message" $
    xpWrap (from_tuple, to_tuple) $
      xp7Tuple
        (xpElem "XML_File_ID" xpInt)
        (xpElem "heading" xpText)
        (xpElem "category" xpText)
        (xpElem "sport" xpText)
        (xpElem "title" xpText)
        (xpList pickle_forecast)
        (xpElem "time_stamp" xp_time_stamp)
  where
    from_tuple = uncurryN Message
    to_tuple Message{..} = (xml_xml_file_id,
                            xml_heading,
                            xml_category,
                            xml_sport,
                            xml_title,
                            xml_forecasts,
                            xml_time_stamp)

--
-- Tasty tests
--

weather_tests :: TestTree
weather_tests =
  testGroup
    "Weather 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 =
  testCase "pickle composed with unpickle is the identity" $ do
    let path = "test/xml/weatherxml.xml"
    (expected, actual) <- pickle_unpickle pickle_message path
    actual @?= expected


-- | Make sure we can actually unpickle these things.
--
test_unpickle_succeeds :: TestTree
test_unpickle_succeeds =
  testCase "unpickling succeeds" $ do
    let path = "test/xml/weatherxml.xml"
    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 =
  testCase "deleting weather deletes its children" $ do
    let path = "test/xml/weatherxml.xml"
    weather <- unsafe_unpickle path pickle_message
    let a = undefined :: Weather
    let b = undefined :: WeatherForecast
    let c = undefined :: WeatherForecastListing
    actual <- withSqliteConn ":memory:" $ runDbConn $ do
                runMigration silentMigrationLogger $ do
                  migrate a
                  migrate b
                  migrate c
                _ <- dbimport weather
                -- No idea how 'delete' works, so do this instead.
                executeRaw False "DELETE FROM weather;" []
                count_a <- countAll a
                count_b <- countAll b
                count_c <- countAll c
                return $ count_a + count_b + count_c
    let expected = 0
    actual @?= expected