dataframe-1.3.0.0: src/DataFrame/Typed/TH.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskellQuotes #-}
{-# LANGUAGE TypeApplications #-}
module DataFrame.Typed.TH (
-- * Schema inference
deriveSchema,
deriveSchemaFromCsvFile,
deriveSchemaFromCsvFileWith,
-- * ADT-based schema derivation
deriveSchemaFromType,
deriveSchemaFromTypeWith,
SchemaOptions (..),
defaultSchemaOptions,
camelToSnake,
-- * Re-export for TH splices
TypedDataFrame,
Column,
) where
import Control.Monad (when)
import Control.Monad.IO.Class
import Data.Char (isUpper, toLower)
import qualified Data.List as L
import qualified Data.Map as M
import qualified Data.Text as T
import qualified Data.Vector as VB
import Language.Haskell.TH
import qualified DataFrame.IO.CSV as D
import qualified DataFrame.Internal.Column as C
import qualified DataFrame.Internal.DataFrame as D
import DataFrame.Typed.Record (
HasSchema,
Schema,
fromColumns,
requireColumn,
toColumns,
)
import DataFrame.Typed.Types (Column, TypedDataFrame)
{- | Generate a type synonym for a schema based on an existing 'DataFrame'.
@
-}
{- $(deriveSchema \"IrisSchema\" irisDF)
-- Generates: type IrisSchema = '[Column \"sepal_length\" Double, ...]
@
-}
deriveSchema :: String -> D.DataFrame -> DecsQ
deriveSchema typeName df = do
let cols = getSchemaInfo df
let names = map fst cols
case findDuplicate names of
Just dup -> fail $ "Duplicate column name in DataFrame: " ++ T.unpack dup
Nothing -> pure ()
colTypes <- mapM mkColumnType cols
let schemaType = foldr (\t acc -> PromotedConsT `AppT` t `AppT` acc) PromotedNilT colTypes
let synName = mkName typeName
pure [TySynD synName [] schemaType]
deriveSchemaFromCsvFile :: String -> String -> DecsQ
deriveSchemaFromCsvFile = deriveSchemaFromCsvFileWith D.defaultReadOptions
deriveSchemaFromCsvFileWith :: D.ReadOptions -> String -> String -> DecsQ
deriveSchemaFromCsvFileWith opts typeName path = do
df <- liftIO (D.readSeparated opts path)
deriveSchema typeName df
getSchemaInfo :: D.DataFrame -> [(T.Text, String)]
getSchemaInfo df =
let orderedNames =
map fst $
L.sortBy (\(_, a) (_, b) -> compare a b) $
M.toList (D.columnIndices df)
in map (\name -> (name, getColumnTypeStr name df)) orderedNames
getColumnTypeStr :: T.Text -> D.DataFrame -> String
getColumnTypeStr name df = case D.getColumn name df of
Just col -> C.columnTypeString col
Nothing -> error $ "Column not found: " ++ T.unpack name
mkColumnType :: (T.Text, String) -> Q Type
mkColumnType (name, tyStr) = do
ty <- parseTypeString tyStr
let nameLit = LitT (StrTyLit (T.unpack name))
pure $ ConT ''Column `AppT` nameLit `AppT` ty
parseTypeString :: String -> Q Type
parseTypeString "Int" = pure $ ConT ''Int
parseTypeString "Double" = pure $ ConT ''Double
parseTypeString "Float" = pure $ ConT ''Float
parseTypeString "Bool" = pure $ ConT ''Bool
parseTypeString "Char" = pure $ ConT ''Char
parseTypeString "String" = pure $ ConT ''String
parseTypeString "Text" = pure $ ConT ''T.Text
parseTypeString "Integer" = pure $ ConT ''Integer
parseTypeString s
| "Maybe " `L.isPrefixOf` s = do
inner <- parseTypeString (L.drop 6 s)
pure $ ConT ''Maybe `AppT` inner
parseTypeString s = fail $ "Unsupported column type in schema inference: " ++ s
findDuplicate :: (Eq a) => [a] -> Maybe a
findDuplicate [] = Nothing
findDuplicate (x : xs)
| x `elem` xs = Just x
| otherwise = findDuplicate xs
-- | Options controlling 'deriveSchemaFromTypeWith'.
data SchemaOptions = SchemaOptions
{ nameTransform :: String -> String
-- ^ Map each record selector name to a column name. Default: 'camelToSnake'.
, schemaTypeName :: Maybe String
-- ^ Override the generated type synonym name. Default: @\<TypeName\>Schema@.
, generateInstance :: Bool
{- ^ When @True@ (default), also generate a 'HasSchema' instance so the
record can be turned into a 'DataFrame' via 'fromRecords' / 'toRecords'.
-}
}
defaultSchemaOptions :: SchemaOptions
defaultSchemaOptions =
SchemaOptions
{ nameTransform = camelToSnake
, schemaTypeName = Nothing
, generateInstance = True
}
{- | Convert a camelCase identifier to snake_case.
>>> camelToSnake "orderId"
"order_id"
>>> camelToSnake "amountUS"
"amount_u_s"
>>> camelToSnake "region"
"region"
-}
camelToSnake :: String -> String
camelToSnake [] = []
camelToSnake (c : cs) = toLower c : go cs
where
go [] = []
go (x : xs)
| isUpper x = '_' : toLower x : go xs
| otherwise = x : go xs
{- | Derive a schema type synonym and a 'HasSchema' instance from a single-record
ADT, using 'defaultSchemaOptions'.
@
data Order = Order
{ orderId :: Int64
, region :: Text
, amount :: Double
} deriving (Show, Eq)
\$(deriveSchemaFromType ''Order)
-- expands to:
-- type OrderSchema =
-- '[Column "order_id" Int64, Column "region" Text, Column "amount" Double]
--
-- instance HasSchema Order OrderSchema where
-- toColumns rs = ...
-- fromColumns df = ...
@
-}
deriveSchemaFromType :: Name -> DecsQ
deriveSchemaFromType = deriveSchemaFromTypeWith defaultSchemaOptions
-- | Like 'deriveSchemaFromType' but accepts custom 'SchemaOptions'.
deriveSchemaFromTypeWith :: SchemaOptions -> Name -> DecsQ
deriveSchemaFromTypeWith opts tyName = do
info <- reify tyName
(conName, vbts) <- extractRecord tyName info
when (Prelude.null vbts) $
fail $
"deriveSchemaFromType: record "
++ show tyName
++ " has no fields"
let fields =
[ (nameTransform opts (nameBase fName), fName, ty)
| (fName, _bang, ty) <- vbts
]
colNames = [c | (c, _, _) <- fields]
case findDuplicate colNames of
Just dup ->
fail $
"deriveSchemaFromType: duplicate transformed column name "
++ show dup
++ " (consider customizing nameTransform via deriveSchemaFromTypeWith)"
Nothing -> pure ()
let synName = case schemaTypeName opts of
Just s -> mkName s
Nothing -> mkName (nameBase tyName ++ "Schema")
let columnTypes =
[ ConT ''Column `AppT` LitT (StrTyLit colName) `AppT` ty
| (colName, _, ty) <- fields
]
schemaType =
foldr
(\t acc -> PromotedConsT `AppT` t `AppT` acc)
PromotedNilT
columnTypes
synDec = TySynD synName [] schemaType
if generateInstance opts
then do
inst <- mkHasSchemaInstance tyName schemaType conName fields
pure [synDec, inst]
else pure [synDec]
extractRecord :: Name -> Info -> Q (Name, [VarBangType])
extractRecord _ (TyConI dec) = case dec of
DataD _ _ _ _ [RecC conName fs] _ -> pure (conName, fs)
NewtypeD _ _ _ _ (RecC conName fs) _ -> pure (conName, fs)
DataD _ name _ _ _ _ ->
fail $
"deriveSchemaFromType: "
++ show name
++ " must have exactly one record constructor"
NewtypeD _ name _ _ _ _ ->
fail $
"deriveSchemaFromType: "
++ show name
++ " newtype must use record syntax"
other ->
fail $
"deriveSchemaFromType: unsupported declaration: " ++ show other
extractRecord tyName _ =
fail $
"deriveSchemaFromType: " ++ show tyName ++ " is not a data/newtype declaration"
mkHasSchemaInstance ::
Name -> Type -> Name -> [(String, Name, Type)] -> Q Dec
mkHasSchemaInstance tyName schemaType conName fields = do
toClause <- mkToColumnsClause fields
fromClause <- mkFromColumnsClause conName fields
let instType = ConT ''HasSchema `AppT` ConT tyName
schemaInst =
TySynInstD
(TySynEqn Nothing (ConT ''Schema `AppT` ConT tyName) schemaType)
pure $
InstanceD
Nothing
[]
instType
[ schemaInst
, FunD 'toColumns [toClause]
, FunD 'fromColumns [fromClause]
]
mkToColumnsClause :: [(String, Name, Type)] -> Q Clause
mkToColumnsClause fields = do
rs <- newName "rs"
let mkPair (colName, fieldFn, _ty) =
let nameE = AppE (VarE 'T.pack) (LitE (StringL colName))
colE =
AppE
(VarE 'C.fromList)
( AppE
(AppE (VarE 'map) (VarE fieldFn))
(VarE rs)
)
in TupE [Just nameE, Just colE]
listExp = ListE (map mkPair fields)
pure $ Clause [VarP rs] (NormalB listExp) []
mkFromColumnsClause :: Name -> [(String, Name, Type)] -> Q Clause
mkFromColumnsClause conName fields = do
df <- newName "df"
iN <- newName "i"
nN <- newName "n"
vNames <- mapM (\k -> newName ("v" ++ show (k :: Int))) [0 .. length fields - 1]
let mkBind v (colName, _, ty) =
let nameE = AppE (VarE 'T.pack) (LitE (StringL colName))
callE =
AppE
( AppE
(AppTypeE (VarE 'requireColumn) ty)
nameE
)
(VarE df)
in BindS (VarP v) callE
binds = zipWith mkBind vNames fields
firstV = case vNames of
(v0 : _) -> v0
[] -> error "mkFromColumnsClause: empty fields (should have failed earlier)"
lengthBind =
LetS
[ ValD
(VarP nN)
(NormalB (AppE (VarE 'VB.length) (VarE firstV)))
[]
]
indexE v =
AppE (AppE (VarE 'VB.unsafeIndex) (VarE v)) (VarE iN)
elemE =
foldl
(\acc v -> AppE acc (indexE v))
(ConE conName)
vNames
nMinus1E =
InfixE
(Just (VarE nN))
(VarE '(-))
(Just (LitE (IntegerL 1)))
rangeE = ArithSeqE (FromToR (LitE (IntegerL 0)) nMinus1E)
compExp = CompE [BindS (VarP iN) rangeE, NoBindS elemE]
rightE = AppE (ConE 'Right) compExp
body = DoE Nothing (binds ++ [lengthBind, NoBindS rightE])
pure $ Clause [VarP df] (NormalB body) []