barbies icon indicating copy to clipboard operation
barbies copied to clipboard

Trouble working with nested HKDs

Open solomon-b opened this issue 1 year ago • 2 comments

I apologize in advance for how long this has gotten. I've gone down a bit of a rabbit hole here..

My Goal

I am trying to use barbies for options parsing. I want to group my fields into sets that fail or succeed collectively and where some of those sets are optional.

For example:

data PostgresConfigF f = PostgresConfigF { pgHost :: f String, pgUser :: f String }
  deriving stock (Generic)
  deriving anyclass (FunctorB, ApplicativeB, TraversableB)

deriving instance (forall x. Show x => Show (f x)) => Show (PostgresConfigF f)

data SmtpConfigF f = SmtpConfigF { smtpHost :: f String, smtpPassword :: f String }
  deriving stock (Generic)
  deriving anyclass (FunctorB, ApplicativeB, TraversableB)

deriving instance (forall x. Show x => Show (f x)) => Show (SmtpConfigF f)

data ConfigF f = ConfigF
  { postgresConfig :: PostgresConfigF f,
    smtpConfig :: Maybe (SmtpConfigF f)
  }

deriving instance (forall x. Show x => Show (f x)) => Show (ConfigF f)

Here, postgresConfig is required and smtpConfig is optional.

In both cases all fields of the inner HKD need to be present for that field to succeed.

As written, ConfigF does not behave as I want.

For example:

config :: ConfigF Maybe
config =
  ConfigF
    { postgresConfig = PostgresConfigF (Just "localhost") (Just "solomon"),
      smtpConfig = Just $ SmtpConfigF Nothing (Just "solomon")
    }
ghci> bsequence' config
Nothing

My goal is to be able to evaluate config into this:

Just (ConfigF {postgresConfig = PostgresConfigF {pgHost = Identity "localhost", pgUser = Identity "solomon"}, smtpConfig = Nothing})

Or if any field of PostgresConfigF is missing it will evaluate the entire expression to Nothing.

Bifunctor Nesting

From the docs the bifunctor/nesting example looks like just what I need:

Dependants { getDependants = Just [] }  -- the user declared 0 dependants
Dependants { getDependants = Nothing }  -- the user didn't specify dependants yet

However, that turns out not to be the case as I will show now. For this section I'll use the Dependents' example from the docs with one modification. I am going to remove the list as it is not relevant to this issue.

data Person f = Person {name :: f String , age  :: f Int}
  deriving stock (Generic)
  deriving anyclass (FunctorB, ApplicativeB, TraversableB)

deriving instance (forall x. Show x => Show (f x)) => Show (Person f)

newtype Dependants' f' f = Dependants { getDependants :: f' (Person f) }
  deriving (Generic)

deriving instance (Show (f' (Person f))) => Show (Dependants' f' f)

instance Functor f' => FunctorB (Dependants' f')
instance FunctorT Dependants'
instance Applicative f' => ApplicativeB (Dependants' f')
instance Traversable f' => TraversableB (Dependants' f')
instance TraversableT Dependants'
dependants :: Dependants' Maybe Maybe
dependants = Dependants { getDependants = Just (Person (Just "foo") Nothing) }
ghci> bsequence' dependants 
Nothing

This is because when we besequence we are 'pulling out' the f functor from Person f to outside of Dependants'. Since we are missing a field of Person that results in the entire expression evaluating to Nothing.

Lets try tsequence:

ghci> tsequence' dependants 
Just (Dependants {getDependants = Identity (Person {name = Just "foo", age = Nothing})})

Now we are 'pulling out' the f' functor which was a Just value. The problem here is that we don't touch the f functor. This leaves the partial Person record uncollapsed.

Another thought I had was to compose fmap bsequence' . tsequence'. This would first evaluate f' which handles optionality on the outer HKD and then fmap bsequence would evaluate f which handles optionality in the inner HKD.

However the bsequence still short circuits through the outer HKD:

ghci> fmap bsequence' $ tsequence' dependants 
Just Nothing

I assume this is because the Applicative instance on Maybe doesn't know the different between my layers of Maybe when sequencing?

I've tried a few variations on this line of thinking but haven't gotten to a working solution. This post has already gotten pretty long so I'll leave it at this. I've been trying to work through this issue for a long time and have come up empty handed.

Am I missing an obvious or completely different solution?

solomon-b avatar Dec 16 '24 00:12 solomon-b

I may not have time to look into your question in detail, so I'll offer just a half-thought idea

I am trying to use barbies for options parsing. I want to group my fields into sets that fail or succeed collectively and where some of those sets are optional.

My gut feeling is that to try to get this working, you would need some kind of bifunctor for your config type, something like

data ConfigF group elem = ConfigF
 { postgresConfig :: PostgresConfigF elem,
    smtpConfig :: group (SmtpConfigF elem)
  }

And then given an operation like

allOrNothing :: TraversableB b => Identity (b Maybe) -> Maybe (b Identity)

you want something along the lines of (complete untested):

normalizeConfig :: ConfigF Identity Maybe -> Maybe (ConfigF Maybe Identity)
normalizeConfig (ConfigF pg smtp) = ConfigF <$> bsequence pg <*> Just (allOrNothing smtp)

I don't expect at this point you'll be able to write normalizeConfig using the bifunctor structure of ConfigF as allOrNothing is not a "natural" transformation, but maybe having to write just normalizeConfig manually is enough for your setting

jcpetruzza avatar Dec 17 '24 22:12 jcpetruzza

Thank you for the feedback. I'll have to think about this a bit.

solomon-b avatar Dec 19 '24 07:12 solomon-b