tomjaguarpaw / haskell-opaleye

Other
599 stars 115 forks source link

More type safety with same field type in a table #570

Closed stevemao closed 10 months ago

stevemao commented 10 months ago

I can put createdAt in place of updatedAt and this can cause a bug without compilation errors

Here is what I got to fix the problem:

newtype CreatedAt
  = CreatedAt { unCreatedAt :: UTCTime }
  deriving (PSG.FromField)

newtype PGCreatedAt
  = PGCreatedAt O.PGTimestamptz

pgUTCTime' :: Time.ISO8601 t => t -> O.Field a
pgUTCTime' = IPT.unsafePgFormatTime "timestamptz"

instance O.DefaultFromField PGCreatedAt CreatedAt where
  defaultFromField = O.fromPGSFromField

instance Default O.ToFields CreatedAt (O.Column PGCreatedAt) where
  def = O.toToFields (pgUTCTime' . unCreatedAt)

now' :: O.Field a
now' = O.Column $ HPQ.FunExpr "now" []

nowCreatedAt :: O.Field PGCreatedAt
nowCreatedAt = now'

This way PGCreatedAt can be only mapped from CreatedAt

Is this the correct way to do it? If so, I can submit a PR to add more polymorphic functions such as pgUTCTime' and now'

tomjaguarpaw commented 10 months ago

Actually, I'd suggest

newtype CreatedAt a
  = CreatedAt { unCreatedAt :: a }

and then use CreatedAt UTCTime and CreatedAt SqlTimestamptz. There's even a module Data.Profunctor.Product.Newtype to help with that.

Let me know if that helps or if you'd like more guidance.

stevemao commented 10 months ago

Thank, I would like you to write a full example this time.

Here's what I got

newtype CreatedAt a
  = CreatedAt { unCreatedAt :: a }
  deriving (ToJSON, PSG.FromField)

instance Newtype CreatedAt where
  constructor = CreatedAt
  field = unCreatedAt

type HCreatedAt
  = CreatedAt UTCTime

type PGCreatedAt
  = CreatedAt O.PGTimestamptz

now' :: O.Field a
now' = O.Column $ HPQ.FunExpr "now" []

nowCreatedAt :: O.Field PGCreatedAt
nowCreatedAt = now'

pgUTCTime' :: Time.ISO8601 t => t -> O.Field a
pgUTCTime' = IPT.unsafePgFormatTime "timestamptz"

instance O.DefaultFromField PGCreatedAt HCreatedAt where
  defaultFromField = O.fromPGSFromField

instance Default O.ToFields HCreatedAt (O.Column PGCreatedAt) where
  def = O.toToFields (pgUTCTime' . unCreatedAt)

And this is still very similar to my original code.

Thanks a lot!

tomjaguarpaw commented 10 months ago

Nice! But that's not quite what I would recommend. Instead I would recommend this:

{-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TemplateHaskell #-}

import Opaleye as O
import Data.Profunctor.Product
import Data.Profunctor.Product.Default
import Data.Profunctor.Product.TH
import Data.Aeson
import Data.Time

newtype CreatedAt a
  = CreatedAt { unCreatedAt :: a }
  deriving (ToJSON)

$(makeAdaptorAndInstance "pCreatedAt" ''CreatedAt)

type HCreatedAt
  = CreatedAt UTCTime

type SqlCreatedAt
  = CreatedAt (O.Field O.SqlTimestamptz)

nowCreatedAt :: SqlCreatedAt
nowCreatedAt = CreatedAt O.now

instance Default O.FromFields SqlCreatedAt HCreatedAt where
  def = pNewtype def

instance Default O.ToFields HCreatedAt SqlCreatedAt where
  def = pNewtype def
tomjaguarpaw commented 10 months ago

In other words, just treat CreatedAt like any other record type (except you get the Newtype instance, which makes things even simpler!)

stevemao commented 10 months ago

Thanks so much!

It's very clean and we don't need to change in the library

I had to update GHC to the latest version to make this work.

instance Default O.FromFields SqlCreatedAt HCreatedAt doesn't seem necessary and it's already defined in $(makeAdaptorAndInstance "pCreatedAt" ''CreatedAt)

When defining table fields, you just need to do

entityCreatedAt = pNewtype $ requiredTableField "created_at"
tomjaguarpaw commented 10 months ago

Ah yes, nice!

tomjaguarpaw commented 10 months ago

Pasting the complete simplest version, for posterity:

{-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TemplateHaskell #-}

import Opaleye as O
import Data.Profunctor.Product.TH
import Data.Aeson
import Data.Time

newtype CreatedAt a
  = CreatedAt { unCreatedAt :: a }
  deriving (ToJSON)

$(makeAdaptorAndInstance "pCreatedAt" ''CreatedAt)

type HCreatedAt
  = CreatedAt UTCTime

type SqlCreatedAt
  = CreatedAt (O.Field O.SqlTimestamptz)

nowCreatedAt :: SqlCreatedAt
nowCreatedAt = CreatedAt O.now