packages feed

warlock-0.1.0.0: test/Warlock/TypeConversionSpec.hs

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Warlock.TypeConversionSpec (spec) where

import Test.Hspec
import qualified Witch as W
import Warlock
import Data.Text (Text)

--------------------------------------------------------------------------------
-- Higher-Kinded From Type Classes (similar to aeson's ToJSON1/FromJSON1)
--------------------------------------------------------------------------------
--
-- This demonstrates an elegant solution to the problem of type conversions
-- in parameterized types. Instead of manually defining:
--
--   instance From (Maybe String) (Maybe Text)
--   instance From [String] [Text]
--   instance From (Either String b) (Either Text b)
--   ... and so on for every combination ...
--
-- We define two type classes that capture the *structure* of the conversion:
--
-- * From1: For single-parameter type constructors (Functors)
-- * From2: For two-parameter type constructors (Bifunctors)
--
-- Then we provide ONE generic instance that says:
--   "If you have a From1 instance for the container type,
--    and a From instance for the element type,
--    you automatically get a From instance for the container of elements"
--
-- This is the same pattern used by aeson (ToJSON1/FromJSON1),
-- hashable (Hashable1), and other libraries for dealing with
-- parameterized types in a compositional way.
--
--------------------------------------------------------------------------------

-- | Type class for converting between type constructors with one parameter.
-- This allows automatic derivation of From instances for parameterized types.
--
-- Example: if you have From String Text, and From1 Maybe,
-- you automatically get From (Maybe String) (Maybe Text)
class From1 f g | f -> g, g -> f where
  -- | Lift a conversion function into the type constructor
  liftFrom :: (a -> b) -> f a -> g b

-- | Type class for converting between type constructors with two parameters.
class From2 f g | f -> g, g -> f where
  -- | Lift two conversion functions into the type constructor
  liftFrom2 :: (a -> c) -> (b -> d) -> f a b -> g c d

-- Instances for common Functors (From1)
instance From1 Maybe Maybe where
  liftFrom = fmap

instance From1 [] [] where
  liftFrom = map

-- Instances for Bifunctors (From2)
instance From2 Either Either where
  liftFrom2 f _ (Left a)  = Left (f a)
  liftFrom2 _ g (Right b) = Right (g b)

instance From2 (,) (,) where
  liftFrom2 f g (a, b) = (f a, g b)

-- Generic instances that use From1 to derive From instances automatically
-- These are overlappable because more specific instances can override them
instance {-# OVERLAPPABLE #-} (From1 f g, W.From a b) => W.From (f a) (g b) where
  from = liftFrom W.from

-- For Either, we typically want to convert the Left type but keep Right unchanged
-- This is more specific than the From1 instance, so it takes precedence
instance {-# OVERLAPS #-} (From2 f g, W.From a c) => W.From (f a b) (g c b) where
  from = liftFrom2 W.from id

-- We'll add specific instances for record lists after we define the types

-- Now, thanks to From1, we automatically get these instances without defining them:
-- * From (Maybe String) (Maybe Text)
-- * From [String] [Text]
-- * From (Either String b) (Either Text b)
-- * And any other Functor/Bifunctor combinations!

--------------------------------------------------------------------------------
-- String <-> Text Conversions
--------------------------------------------------------------------------------

-- Simple record with String -> Text conversion
data PersonString = PersonString
  { personName :: String
  , personAge :: Int
  } deriving (Show, Eq)

data PersonText = PersonText
  { personName :: Text
  , personAge :: Int
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''PersonString ''PersonText
deriveAutomap (ByName defaultConfig) ''PersonText ''PersonString

-- Nested Maybe with type conversion
data MaybeStringRecord = MaybeStringRecord
  { nickname :: Maybe String
  , score :: Int
  } deriving (Show, Eq)

data MaybeTextRecord = MaybeTextRecord
  { nickname :: Maybe Text
  , score :: Int
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''MaybeStringRecord ''MaybeTextRecord

-- List with type conversion
data ListStringRecord = ListStringRecord
  { tags :: [String]
  , count :: Int
  } deriving (Show, Eq)

data ListTextRecord = ListTextRecord
  { tags :: [Text]
  , count :: Int
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''ListStringRecord ''ListTextRecord

-- Either with type conversion
data EitherStringRecord = EitherStringRecord
  { result :: Either String Int
  } deriving (Show, Eq)

data EitherTextRecord = EitherTextRecord
  { result :: Either Text Int
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''EitherStringRecord ''EitherTextRecord

-- Multiple fields with different conversions
data MixedSource = MixedSource
  { label :: String
  , description :: String
  , value :: Int
  } deriving (Show, Eq)

data MixedDest = MixedDest
  { label :: Text
  , description :: Text
  , value :: Int
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''MixedSource ''MixedDest

--------------------------------------------------------------------------------
-- Integer Type Conversions
--------------------------------------------------------------------------------

-- Note: For numeric type conversions, you would need to add From instances
-- witch library doesn't provide all numeric conversions by default
-- Here we'll just demonstrate with Natural/Integer which witch does support

-- Example: you can add your own instances like:
-- instance W.From Int Int32 where from = fromIntegral
-- instance W.From Word8 Word16 where from = fromIntegral

--------------------------------------------------------------------------------
-- Nested Type Conversions
--------------------------------------------------------------------------------

-- Record containing another record that needs type conversion
data InnerString = InnerString
  { innerText :: String
  } deriving (Show, Eq)

data InnerText = InnerText
  { innerText :: Text
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''InnerString ''InnerText

data OuterString = OuterString
  { outerLabel :: String
  , inner :: InnerString
  } deriving (Show, Eq)

data OuterText = OuterText
  { outerLabel :: Text
  , inner :: InnerText
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''OuterString ''OuterText

-- List of records with type conversion
data ItemString = ItemString
  { itemName :: String
  } deriving (Show, Eq)

data ItemText = ItemText
  { itemName :: Text
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''ItemString ''ItemText

data CartString = CartString
  { items :: [ItemString]
  } deriving (Show, Eq)

data CartText = CartText
  { items :: [ItemText]
  } deriving (Show, Eq)

deriveAutomap (ByName defaultConfig) ''CartString ''CartText

-- The derive calls above automatically generate From instances that use witch's
-- From instances to convert nested types. For lists of records, we need a helper:
instance W.From [ItemString] [ItemText] where
  from = map W.from

--------------------------------------------------------------------------------
-- Tests
--------------------------------------------------------------------------------

spec :: Spec
spec = do
  describe "String <-> Text conversions" $ do
    it "converts String to Text in simple record" $ do
      let person = PersonString "Alice" 30
      let converted = W.from person :: PersonText
      case converted of
        PersonText name age -> do
          name `shouldBe` "Alice"
          age `shouldBe` 30

    it "converts Text to String in simple record" $ do
      let person = PersonText "Bob" 25
      let converted = W.from person :: PersonString
      case converted of
        PersonString name age -> do
          name `shouldBe` "Bob"
          age `shouldBe` 25

    it "converts Maybe String to Maybe Text" $ do
      let record = MaybeStringRecord (Just "nickname") 100
      let converted = W.from record :: MaybeTextRecord
      case converted of
        MaybeTextRecord nick _ -> nick `shouldBe` Just "nickname"

    it "handles Nothing in Maybe conversions" $ do
      let record = MaybeStringRecord Nothing 100
      let converted = W.from record :: MaybeTextRecord
      case converted of
        MaybeTextRecord nick _ -> nick `shouldBe` Nothing

    it "converts [String] to [Text]" $ do
      let record = ListStringRecord ["tag1", "tag2", "tag3"] 3
      let converted = W.from record :: ListTextRecord
      case converted of
        ListTextRecord t c -> do
          t `shouldBe` ["tag1", "tag2", "tag3"]
          c `shouldBe` 3

    it "converts empty list" $ do
      let record = ListStringRecord [] 0
      let converted = W.from record :: ListTextRecord
      case converted of
        ListTextRecord t _ -> t `shouldBe` []

    it "converts Either String to Either Text (Left case)" $ do
      let record = EitherStringRecord (Left "error")
      let converted = W.from record :: EitherTextRecord
      case converted of
        EitherTextRecord r -> r `shouldBe` Left "error"

    it "converts Either String to Either Text (Right case)" $ do
      let record = EitherStringRecord (Right 42)
      let converted = W.from record :: EitherTextRecord
      case converted of
        EitherTextRecord r -> r `shouldBe` Right 42

    it "converts multiple String fields to Text" $ do
      let record = MixedSource "Label" "Description" 999
      let converted = W.from record :: MixedDest
      case converted of
        MixedDest l d v -> do
          l `shouldBe` "Label"
          d `shouldBe` "Description"
          v `shouldBe` 999

  -- Numeric conversions would require additional From instances
  -- which witch doesn't provide by default

  describe "Nested type conversions" $ do
    it "converts nested records with type conversions" $ do
      let innerRec = InnerString "inner text"
      let outerRec = OuterString "outer label" innerRec
      let converted = W.from outerRec :: OuterText
      case converted of
        OuterText lbl innerConverted -> do
          lbl `shouldBe` "outer label"
          case innerConverted of
            InnerText innerTxt -> innerTxt `shouldBe` "inner text"

    it "converts list of records with type conversions" $ do
      let itemList = [ItemString "item1", ItemString "item2"]
      let cart = CartString itemList
      let converted = W.from cart :: CartText
      case converted of
        CartText convertedItems -> do
          length convertedItems `shouldBe` 2
          case convertedItems of
            (ItemText name1 : ItemText name2 : _) -> do
              name1 `shouldBe` "item1"
              name2 `shouldBe` "item2"
            _ -> expectationFailure "Expected at least 2 items"

  describe "From1/From2 automatic derivation" $ do
    it "automatically derives Maybe conversions via From1" $ do
      -- No manual instance needed! From1 automatically provides this
      let maybeStr = Just "hello" :: Maybe String
      let maybeText = W.from maybeStr :: Maybe Text
      maybeText `shouldBe` Just "hello"

    it "automatically derives list conversions via From1" $ do
      -- No manual instance needed! From1 automatically provides this
      let strList = ["one", "two", "three"] :: [String]
      let textList = W.from strList :: [Text]
      textList `shouldBe` ["one", "two", "three"]

    it "automatically derives Either conversions via From2" $ do
      -- No manual instance needed! From2 automatically provides this
      let eitherStr = Left "error" :: Either String Int
      let eitherText = W.from eitherStr :: Either Text Int
      eitherText `shouldBe` Left "error"

    it "works with nested Maybe [String]" $ do
      -- Composition of From1 instances!
      let nested = Just ["a", "b", "c"] :: Maybe [String]
      let converted = W.from nested :: Maybe [Text]
      converted `shouldBe` Just ["a", "b", "c"]

    it "works with Either (Maybe String) Int" $ do
      -- Composition of From2 and From1!
      let complex = Left (Just "error") :: Either (Maybe String) Int
      let converted = W.from complex :: Either (Maybe Text) Int
      converted `shouldBe` Left (Just "error")