packages feed

dataframe-1.3.0.0: src/DataFrame/Typed/Generic.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}

{- |
Module      : DataFrame.Typed.Generic
License     : MIT

Generic-based opt-in for record-to-schema derivation. Mirrors the Template
Haskell splice in "DataFrame.Typed.TH" but builds the schema type from a
@GHC.Generics.Generic@ instance instead of @reify@.

Use it like this:

@
data Order = Order
  { orderId :: Int64
  , region  :: Text
  , amount  :: Double
  } deriving (Show, Eq, Generic)

type OrderSchema = SchemaOf Order

instance HasSchema Order OrderSchema where
  toColumns   = genericToColumns
  fromColumns = genericFromColumns
@

Field names are translated with the @CamelCase -> snake_case@ rule
(matching 'DataFrame.Typed.TH.camelToSnake'); use 'SchemaOfRaw' if you
want the schema to keep the record selector names verbatim — in that
case you cannot use 'genericToColumns' \/ 'genericFromColumns' and must
either hand-roll the instance or use the TH splice with a custom name
transform.
-}
module DataFrame.Typed.Generic (
    -- * Type-level schema derivation
    NameCase (..),
    SchemaOf,
    SchemaOfRaw,
    RepToSchema,
    CamelToSnake,

    -- * Value-level default methods
    genericToColumns,
    genericFromColumns,
    GHasColumns,
) where

import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import qualified Data.Text as T
import qualified Data.Vector as VB
import GHC.Generics (
    C,
    D,
    Generic (..),
    K1 (..),
    M1 (..),
    Meta (..),
    S,
    type (:*:) (..),
 )
import GHC.TypeLits (
    CharToNat,
    ConsSymbol,
    KnownSymbol,
    NatToChar,
    Symbol,
    UnconsSymbol,
    symbolVal,
    type (+),
 )

import Data.Type.Bool (If, type (&&))
import Data.Type.Ord (type (<=?))

import qualified DataFrame.Internal.Column as C
import qualified DataFrame.Internal.DataFrame as D
import DataFrame.Typed.Record (requireColumn)
import DataFrame.Typed.Schema (Append)
import DataFrame.Typed.TH (camelToSnake)
import DataFrame.Typed.Types (Column)

{- | Field-name policy applied to record selectors when computing
'RepToSchema'.

* 'SnakeCase' — translate @camelCaseField@ to @\"camel_case_field\"@.
* 'IdentityCase' — keep the selector name verbatim.
-}
data NameCase = SnakeCase | IdentityCase

{- | The schema type @[Column name ty, ...]@ derived from the 'Rep' of a
record type, with the given 'NameCase' applied to each field name.
-}
type family RepToSchema (nc :: NameCase) (r :: Type -> Type) :: [Type] where
    RepToSchema nc (M1 D _ f) = RepToSchema nc f
    RepToSchema nc (M1 C _ f) = RepToSchema nc f
    RepToSchema nc (a :*: b) = Append (RepToSchema nc a) (RepToSchema nc b)
    RepToSchema nc (M1 S ('MetaSel ('Just name) _ _ _) (K1 _ a)) =
        '[Column (TransformName nc name) a]

type family TransformName (nc :: NameCase) (name :: Symbol) :: Symbol where
    TransformName 'SnakeCase s = CamelToSnake s
    TransformName 'IdentityCase s = s

-- | Type-level camelCase -> snake_case. Matches 'camelToSnake' at the value level.
type family CamelToSnake (s :: Symbol) :: Symbol where
    CamelToSnake s = SnakeStart (UnconsSymbol s)

type family SnakeStart (mu :: Maybe (Char, Symbol)) :: Symbol where
    SnakeStart 'Nothing = ""
    SnakeStart ('Just '(c, r)) =
        ConsSymbol (ToLowerChar c) (SnakeRest (UnconsSymbol r))

type family SnakeRest (mu :: Maybe (Char, Symbol)) :: Symbol where
    SnakeRest 'Nothing = ""
    SnakeRest ('Just '(c, r)) =
        SnakeStep (IsUpperChar c) c (SnakeRest (UnconsSymbol r))

type family SnakeStep (up :: Bool) (c :: Char) (rest :: Symbol) :: Symbol where
    SnakeStep 'True c rest = ConsSymbol '_' (ConsSymbol (ToLowerChar c) rest)
    SnakeStep 'False c rest = ConsSymbol c rest

type family IsUpperChar (c :: Char) :: Bool where
    IsUpperChar c =
        (CharToNat 'A' <=? CharToNat c) && (CharToNat c <=? CharToNat 'Z')

type family ToLowerChar (c :: Char) :: Char where
    ToLowerChar c = If (IsUpperChar c) (NatToChar (CharToNat c + 32)) c

-- | Snake_case schema derived from @a@'s 'Generic' representation.
type SchemaOf a = RepToSchema 'SnakeCase (Rep a)

-- | Identity-cased schema derived from @a@'s 'Generic' representation.
type SchemaOfRaw a = RepToSchema 'IdentityCase (Rep a)

{- | Walks the 'Rep' tree of a record, producing or consuming a list of
named columns. Used by 'genericToColumns' \/ 'genericFromColumns'.
-}
class GHasColumns (r :: Type -> Type) where
    gToColumns :: [r p] -> [(T.Text, C.Column)]
    gFromColumns :: D.DataFrame -> Either T.Text [r p]

instance (GHasColumns f) => GHasColumns (M1 D meta f) where
    gToColumns rs = gToColumns (map unM1 rs)
    gFromColumns df = fmap (map M1) (gFromColumns df)

instance (GHasColumns f) => GHasColumns (M1 C meta f) where
    gToColumns rs = gToColumns (map unM1 rs)
    gFromColumns df = fmap (map M1) (gFromColumns df)

instance (GHasColumns a, GHasColumns b) => GHasColumns (a :*: b) where
    gToColumns rs =
        gToColumns (map (\(x :*: _) -> x) rs)
            ++ gToColumns (map (\(_ :*: y) -> y) rs)
    gFromColumns df = do
        as <- gFromColumns df
        bs <- gFromColumns df
        pure (zipWith (:*:) as bs)

instance
    (KnownSymbol name, C.Columnable a) =>
    GHasColumns
        ( M1
            S
            ('MetaSel ('Just name) su ss ds)
            (K1 i a)
        )
    where
    gToColumns rs =
        let colName = T.pack (camelToSnake (symbolVal (Proxy @name)))
            vals = map (unK1 . unM1) rs
         in [(colName, C.fromList vals)]
    gFromColumns df = do
        let colName = T.pack (camelToSnake (symbolVal (Proxy @name)))
        v <- requireColumn @a colName df
        pure (map (M1 . K1) (VB.toList v))

{- | Default implementation of 'DataFrame.Typed.Record.toColumns' for any
@Generic@ record. Field names are translated with @camelCase -> snake_case@.

@
instance HasSchema Order (SchemaOf Order) where
  toColumns   = genericToColumns
  fromColumns = genericFromColumns
@
-}
genericToColumns ::
    forall a. (Generic a, GHasColumns (Rep a)) => [a] -> [(T.Text, C.Column)]
genericToColumns = gToColumns . map from

{- | Default implementation of 'DataFrame.Typed.Record.fromColumns' for any
@Generic@ record.
-}
genericFromColumns ::
    forall a. (Generic a, GHasColumns (Rep a)) => D.DataFrame -> Either T.Text [a]
genericFromColumns df = fmap (map to) (gFromColumns df)