Home > front end >  How do I model a record's fields as data?
How do I model a record's fields as data?

Time:10-07

Let's say I have a Person record with some fields:

data Person = Person
  { name :: String
  , age :: Int
  , id :: Int
  }

and I want to be able to search a list of Persons by a given field:

findByName :: String -> [Person] -> Maybe Person
findByName s = find (\p -> name p == s)

Now let's say I want to be able to model and store these searches/queries as data, for instance for logging purposes, or to batch execute them, or whatever.

How would I go about representing a search over a given field (or set of fields) as data?

My intuition says to model it as a map of fields to string values (Map (RecordField) (Maybe String)), but I can't do that, because record fields are functions.

Is there a better way to do this than, say, the following?

data PersonField = Name | Age | Int

type Search = Map PersonField (Maybe String)

This could technically work but it decouples PersonField from Person in an ugly way.

CodePudding user response:

If you don't need to serialize these query objects to disk, then your "field" type is Person -> a. A record accessor is just a function from Person to some type a. Or if you end up outgrowing basic accessors and need to work with a lot of nested data, you can look into lenses.

However, it sounds like you want to be able to write these queries to disk. In that case, you can't easily serialize functions (or lenses, for that matter). I don't know of a way built-in to Haskell to do all of that automatically and still have it be serializable. So my recommendation would be to roll your own datatypes.

data PersonField = Name | Age | Id

or, even better, you can use GADTs to keep type safety.

data PersonField a where
  Name :: PersonField String
  Age :: PersonField Int
  Id :: PersonField Int

getField :: PersonField a -> Person -> a
getField Name = name
getField Age = age
getField id = id

Then you have total control over this concrete type and can write your own serialization logic for it. I think Map PersonField (Maybe String) is a good start, and you can refine the Maybe String part if you end up doing more complex queries (like "contains" or "case insensitive comparison", for instance).

CodePudding user response:

I want to be able to model and store these searches/queries as data

Let's assume we want to store them as JSON. We could define a type like

data Predicate record = Predicate {
    runPredicate :: record -> Bool ,
    storePredicate :: Value
}

Where storePredicate would return a JSON representation of the "reference value" inside the predicate. For example, the value 77 for "age equals 77".

For each record, we would like to have a collection like this:

type FieldName = String
type FieldPredicates record = [(FieldName, Value -> Maybe (Predicate record))]

That is: for each field, we can supply a JSON value encoding the "reference value" of the predicate and, if it parses successfully, we get a Predicate. Otherwise we get Nothing. This would allows us to serialize and deserialize predicates.

We could define FieldPredicates manually for each record, but is there a more automated way? We could try generating field equality predicates using a typeclass. But first, the extensions and imports dance:

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneKindSignatures #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE BlockArguments #-}
import Data.Functor ( (<&>) )
import Data.Kind ( Type, Constraint )
import Data.Proxy
import GHC.Records ( HasField(..) )
import GHC.TypeLits ( KnownSymbol, Symbol, symbolVal )
import Data.Aeson ( FromJSON(parseJSON), Value, ToJSON(toJSON) )
import Data.Aeson.Types (parseMaybe)
import Data.List ( lookup )

Now we define the helper typeclass:

type HasEqFieldPredicates :: [Symbol] -> Type -> Constraint
class HasEqFieldPredicates fieldNames record where
  eqFieldPredicates :: FieldPredicates record

instance HasEqFieldPredicates '[] record where
  eqFieldPredicates = []

instance
  ( KnownSymbol fieldName, 
    HasField fieldName record v,
    Eq v,
    FromJSON v,
    ToJSON v,
    HasEqFieldPredicates fieldNames record
  ) =>
  HasEqFieldPredicates (fieldName ': fieldNames) record
  where
  eqFieldPredicates =
    let current =
          ( symbolVal (Proxy @fieldName), 
          \j ->
              parseMaybe (parseJSON @v) j <&> \v ->
                  Predicate (\record -> getField @fieldName record == v) (toJSON v))
    in current : eqFieldPredicates @fieldNames @record

An example with Person:

personEqPredicates :: [(FieldName, Value -> Maybe (Predicate Person))]
personEqPredicates = eqFieldPredicates @["name", "age", "id"] @Person

personAgeEquals :: Value -> Maybe (Predicate Person)
personAgeEquals = let Just x = Data.List.lookup "age" personEqPredicates in x

Putting it to work:

ghci> let Just p = personAgeEquals (toJSON (77::Int)) in runPredicate p Person { name = "John", age = 78, id = 3 }
False
ghci> let Just p = personAgeEquals (toJSON (78::Int)) in runPredicate p Person { name = "John", age = 78, id = 3 }
True
  • Related