bitemyapp / esqueleto

New home of Esqueleto, please file issues so we can get things caught up!
BSD 3-Clause "New" or "Revised" License
370 stars 107 forks source link

`deriveEsqueletoRecord` does not handle polymorphic records #383

Open parsonsmatt opened 6 months ago

parsonsmatt commented 6 months ago

Consider the type:

data Record key = Record { key :: key, column :: Int }

We can't write deriveEsqueletoRecord here because there's a type error. We can't provide concrete things because deriveEsqueletoRecord takes a Name and not a Type, so we can't write deriveEsqueletoRecord @(Record Foo).

Ideally we can support polymorphic records.

curranosaurus commented 5 months ago

I hand-derived some instances that ended up working out for this case. See for example the following minimal example.

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FlexibleContexts #-}

module Lib where

import Control.Monad.Trans.State.Strict (evalStateT)
import Data.Bifunctor (first)
import Data.String (fromString)
import Database.Esqueleto.Experimental
import Database.Esqueleto.Record
import Database.Esqueleto.Internal.Internal
import Data.Proxy

data Record key = Record { key :: key, val :: Int }

data SqlRecord key = SqlRecord { key :: SqlExpr (Value key), val :: SqlExpr (Value Int) }

data SqlMaybeRecord key = SqlMaybeRecord { key :: SqlExpr (Value (Maybe key)), val :: SqlExpr (Value (Maybe Int)) }

instance PersistField (Key rec) => SqlSelect (SqlRecord (Key rec)) (Record (Key rec)) where
  sqlSelectCols identInfo SqlRecord { key, val } =
    sqlSelectCols identInfo (key :& val)
  sqlSelectColCount _ = sqlSelectColCount
    ( Proxy
        @( SqlExpr (Value (Key rec))
                :& SqlExpr (Value Int)
         )
    )
  sqlSelectProcessRow columns =
    first
      (fromString "Failed to parse Record: " <>)
      (evalStateT process columns)
    where
      process = do
        Value key <- takeColumns @(SqlExpr (Value (Key rec)))
        Value val <- takeColumns @(SqlExpr (Value Int))
        pure Record { key, val }

instance ToAliasReference (SqlRecord (Key rec)) where
  toAliasReference ident SqlRecord { key, val } =
    SqlRecord <$> toAliasReference ident key <*> toAliasReference ident val

instance ToAlias (SqlRecord (Key rec)) where
  toAlias SqlRecord { key, val } =
    SqlRecord <$> toAlias key <*> toAlias val

instance ToMaybe (SqlRecord (Key rec)) where
  type ToMaybeT (SqlRecord (Key rec)) = SqlMaybeRecord (Key rec)
  toMaybe SqlRecord { key, val } =
    SqlMaybeRecord
      { key = just key
      , val = just val
      }

I'm not actually certain if the Key rec thing is necessary, I added it in because it doesn't constrain my usecase and I figured it might make some of these instances more deducible.

parsonsmatt commented 5 months ago

Hmm. With a totally polymorphic field like key :: key, it's impossible to know how it will be instantiated, and therefore, what code to use. The three cases are SqlExpr (Value a), SqlExpr (Entity rec), and (for other esqueleto records) key (since the SqlExpr is baked into the record itself).

I think you'd need the type parameter to not affect the conversion logic. Or, to delegate more fully. Consider:

data R k = R { key :: k }

data SqlR k = SqlR { key :: k }

data SqlMaybeR k = SqlMaybeR { key :: k }

Then I think our instance becomes:

instance (SqlSelect sqlK valueK) => SqlSelect (SqlR sqlK) (R valueK)

Which should work in more generality.

I think a much easier alternative is if you can further constrain the type of key. Consider:

data Record a = Record { key :: Key a }

Now, we know that Key a is always going to be wrapped in SqlExpr (Value (Key a)), so we can work with it more easily.

That may also simplify the rest of your code, as well.