diff --git a/changelog.d/20250428_030850_shane.obrien_prepared.md b/changelog.d/20250428_030850_shane.obrien_prepared.md new file mode 100644 index 00000000..ebe79450 --- /dev/null +++ b/changelog.d/20250428_030850_shane.obrien_prepared.md @@ -0,0 +1,21 @@ +### Removed + +- Removed support for `network-ip`. We still support `iproute`. + +### Added + +- Add support for prepared statements. To use prepared statements, simply use `prepare run` instead of `run` with a function that passes the parameters to your statement. + +- Added new `Encoder` type with three members: `binary`, which is the Hasql binary encoder, `text` which encodes a type in PostgreSQL's text format (needed for nested arrays) and `quote`, which is the does the thing that the function we previously called `encode` does (i.e., `a -> Opaleye.PrimExpr`). + +### Changed + +- Several changes to `TypeInformation`: + + * Changed the `encode` field of `TypeInformation` to be `Encoder a` instead of `a -> Opaleye.PrimExpr`. + + * Moved the `delimiter` field of `Decoder` into the top level of `TypeInformation`, as it's not "decoding" specific, it's also used when "encoding". + + * Renamed the `parser` field of `Decoder` to `text`, to mirror the `text` field of the new `Encoder` type. + + All of this will break any downstream code that uses a completely custom `DBType` implementation, but anything that uses `ReadShow`, `Enum`, `Composite`, `JSONBEncoded` or `parseTypeInformation` will continue working as before (which should cover all common cases). diff --git a/rel8.cabal b/rel8.cabal index 41d7df94..06163517 100644 --- a/rel8.cabal +++ b/rel8.cabal @@ -1,6 +1,6 @@ cabal-version: 2.0 name: rel8 -version: 1.6.0.0 +version: 1.7.0.0 synopsis: Hey! Hey! Can u rel8? license: BSD3 license-file: LICENSE @@ -21,20 +21,15 @@ library build-depends: aeson , attoparsec - , attoparsec-aeson , base ^>= 4.16 || ^>= 4.17 || ^>= 4.18 || ^>= 4.19 || ^>= 4.20 , base16 >= 1.0 , base-compat ^>= 0.11 || ^>= 0.12 || ^>= 0.13 || ^>= 0.14 , bifunctors - , binary-parser ^>= 0.5 - , data-dword ^>= 0.3 , bytestring , case-insensitive , comonad , contravariant - , data-textual - , hasql >= 1.6.1.2 && < 1.10 - , network-ip ^>= 0.3 + , hasql >= 1.8 && < 1.10 , iproute ^>= 1.7 , opaleye ^>= 0.10.2.1 , pretty @@ -60,6 +55,10 @@ library -Wno-monomorphism-restriction -Wno-missing-local-signatures -Wno-missing-kind-signatures + -Wno-missing-role-annotations + -Wno-missing-deriving-strategies + -Wno-term-variable-capture + hs-source-dirs: src exposed-modules: @@ -175,6 +174,7 @@ library Rel8.Statement.Delete Rel8.Statement.Insert Rel8.Statement.OnConflict + Rel8.Statement.Prepared Rel8.Statement.Returning Rel8.Statement.Rows Rel8.Statement.Run @@ -215,16 +215,21 @@ library Rel8.Type Rel8.Type.Array + Rel8.Type.Builder.ByteString + Rel8.Type.Builder.Fold + Rel8.Type.Builder.Time Rel8.Type.Composite Rel8.Type.Decimal Rel8.Type.Decoder Rel8.Type.Eq + Rel8.Type.Encoder Rel8.Type.Enum Rel8.Type.Information Rel8.Type.JSONEncoded Rel8.Type.JSONBEncoded Rel8.Type.Monoid Rel8.Type.Name + Rel8.Type.Nullable Rel8.Type.Num Rel8.Type.Ord Rel8.Type.Parser @@ -247,12 +252,10 @@ test-suite tests , bytestring , case-insensitive , containers - , data-dword , hasql , hasql-transaction , hedgehog ^>= 1.0 || ^>= 1.1 || ^>= 1.2 || ^>= 1.3 || ^>= 1.4 || ^>= 1.5 , mmorph - , network-ip , iproute , rel8 , scientific @@ -277,3 +280,4 @@ test-suite tests -Wno-deprecations -Wno-monomorphism-restriction -Wno-missing-local-signatures -Wno-implicit-prelude -Wno-missing-kind-signatures + -Wno-missing-role-annotations diff --git a/src/Rel8.hs b/src/Rel8.hs index 16a05ec8..a81cd3ac 100644 --- a/src/Rel8.hs +++ b/src/Rel8.hs @@ -26,6 +26,9 @@ module Rel8 -- *** @Decoder@ , Decoder(..) + -- *** @Encoder@ + , Encoder(..) + -- ** The @DBType@ hierarchy , DBSemigroup(..) , DBMonoid(..) @@ -355,6 +358,7 @@ module Rel8 , run1 , runMaybe , runVector + , prepared -- ** @SELECT@ , select @@ -383,6 +387,7 @@ module Rel8 -- ** @WITH@ , Statement , showStatement + , showPreparedStatement -- ** @CREATE VIEW@ , createView @@ -460,6 +465,7 @@ import Rel8.Statement import Rel8.Statement.Delete import Rel8.Statement.Insert import Rel8.Statement.OnConflict +import Rel8.Statement.Prepared import Rel8.Statement.Returning import Rel8.Statement.Run import Rel8.Statement.Select @@ -493,6 +499,7 @@ import Rel8.Type import Rel8.Type.Composite import Rel8.Type.Decoder import Rel8.Type.Eq +import Rel8.Type.Encoder import Rel8.Type.Enum import Rel8.Type.Information import Rel8.Type.JSONBEncoded diff --git a/src/Rel8/Expr/Aggregate.hs b/src/Rel8/Expr/Aggregate.hs index b1ede727..78a12c82 100644 --- a/src/Rel8/Expr/Aggregate.hs +++ b/src/Rel8/Expr/Aggregate.hs @@ -80,7 +80,7 @@ import Rel8.Schema.Null ( Sql, Unnullify ) import Rel8.Table.Opaleye (fromOrder, unpackspec) import Rel8.Table.Order (ascTable) import Rel8.Type ( DBType, typeInformation ) -import Rel8.Type.Array (arrayTypeName, encodeArrayElement) +import Rel8.Type.Array (arrayTypeName, quoteArrayElement) import Rel8.Type.Eq ( DBEq ) import Rel8.Type.Information (TypeInformation) import Rel8.Type.Num (DBFractional, DBNum) @@ -451,7 +451,7 @@ slistAggExpr :: () => TypeInformation (Unnullify a) -> Aggregator' fold (Expr a) (Expr [a]) slistAggExpr info = unsafeMakeAggregator - (toColumn . encodeArrayElement info . toPrimExpr) + (toColumn . quoteArrayElement info . toPrimExpr) (fromPrimExpr . fromColumn) (Fallback (sempty info)) Opaleye.arrayAgg @@ -461,7 +461,7 @@ snonEmptyAggExpr :: () => TypeInformation (Unnullify a) -> Aggregator1 (Expr a) (Expr (NonEmpty a)) snonEmptyAggExpr info = unsafeMakeAggregator - (toColumn . encodeArrayElement info . toPrimExpr) + (toColumn . quoteArrayElement info . toPrimExpr) (fromPrimExpr . fromColumn) Empty Opaleye.arrayAgg diff --git a/src/Rel8/Expr/Serialize.hs b/src/Rel8/Expr/Serialize.hs index 5812ad32..95993c4d 100644 --- a/src/Rel8/Expr/Serialize.hs +++ b/src/Rel8/Expr/Serialize.hs @@ -1,3 +1,4 @@ +{-# language DisambiguateRecordFields #-} {-# language FlexibleContexts #-} {-# language NamedFieldPuns #-} {-# language TypeFamilies #-} @@ -24,6 +25,7 @@ import Rel8.Expr.Opaleye ( scastExpr ) import Rel8.Schema.Null ( Unnullify, Nullity( Null, NotNull ), Sql, nullable ) import Rel8.Type ( DBType, typeInformation ) import Rel8.Type.Decoder (Decoder (..)) +import Rel8.Type.Encoder (Encoder (..)) import Rel8.Type.Information ( TypeInformation(..) ) @@ -36,12 +38,12 @@ litExpr = slitExpr nullable typeInformation slitExpr :: Nullity a -> TypeInformation (Unnullify a) -> a -> Expr a -slitExpr nullity info@TypeInformation {encode} = +slitExpr nullity info@TypeInformation {encode = Encoder {quote}} = scastExpr info . Expr . encoder where encoder = case nullity of - Null -> maybe (Opaleye.ConstExpr Opaleye.NullLit) encode - NotNull -> encode + Null -> maybe (Opaleye.ConstExpr Opaleye.NullLit) quote + NotNull -> quote sparseValue :: Nullity a -> TypeInformation (Unnullify a) -> Hasql.Row a diff --git a/src/Rel8/Generic/Construction/ADT.hs b/src/Rel8/Generic/Construction/ADT.hs index f5cc0cf8..7b52f985 100644 --- a/src/Rel8/Generic/Construction/ADT.hs +++ b/src/Rel8/Generic/Construction/ADT.hs @@ -77,10 +77,10 @@ type Unnullifier context = forall a. Spec a -> context (Nullify a) -> context a type NoConstructor :: Symbol -> Symbol -> ErrorMessage type NoConstructor datatype constructor = - ( 'Text "The type `" ':<>: - 'Text datatype ':<>: - 'Text "` has no constructor `" ':<>: - 'Text constructor ':<>: + ( 'Text "The type `" ' :<>: + 'Text datatype ' :<>: + 'Text "` has no constructor `" ' :<>: + 'Text constructor ' :<>: 'Text "`." ) diff --git a/src/Rel8/Generic/Construction/Record.hs b/src/Rel8/Generic/Construction/Record.hs index 9350b442..5d4f3a2f 100644 --- a/src/Rel8/Generic/Construction/Record.hs +++ b/src/Rel8/Generic/Construction/Record.hs @@ -63,8 +63,8 @@ type GConstructor :: (Type -> Type) -> Symbol type family GConstructor rep where GConstructor (M1 D _ (M1 C ('MetaCons name _ _) _)) = name GConstructor (M1 D ('MetaData name _ _ _) _) = TypeError ( - 'Text "`" ':<>: - 'Text name ':<>: + 'Text "`" ' :<>: + 'Text name ' :<>: 'Text "` does not appear to have exactly 1 constructor" ) diff --git a/src/Rel8/Statement/Prepared.hs b/src/Rel8/Statement/Prepared.hs new file mode 100644 index 00000000..2f54638a --- /dev/null +++ b/src/Rel8/Statement/Prepared.hs @@ -0,0 +1,87 @@ +{-# language AllowAmbiguousTypes #-} +{-# language BlockArguments #-} +{-# language FlexibleContexts #-} +{-# language MonoLocalBinds #-} +{-# language NamedFieldPuns #-} +{-# language ScopedTypeVariables #-} +{-# language TypeApplications #-} + +module Rel8.Statement.Prepared ( + input, + prepared, +) where + +-- base +import Data.Functor.Const (Const (Const), getConst) +import Data.Functor.Contravariant (contramap, (>$<)) +import Data.Functor.Identity (runIdentity) +import Prelude + +-- hasql +import qualified Hasql.Encoders as Hasql +import qualified Hasql.Statement as Hasql + +-- opaleye +import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye + +-- rel8 +import Rel8.Expr (Expr) +import Rel8.Expr.Opaleye (fromPrimExpr, scastExpr) +import Rel8.Schema.HTable (hfield, hspecs, htabulateA) +import Rel8.Schema.Null (Nullity (Null, NotNull)) +import Rel8.Schema.Spec (Spec (..)) +import Rel8.Statement (Statement) +import Rel8.Table (Table, fromColumns, toResult) +import Rel8.Table.Serialize (Serializable) +import Rel8.Type.Encoder (binary) +import Rel8.Type.Information (encode) + +-- transformers +import Control.Monad.Trans.State.Strict (evalState, state) + + +{-| Given a 'Rel8.run' function that converts a 'Statement' to a +'Hasql.Statement', return a 'Rel8.run'-like function which instead takes a +/parameterized/ 'Statement' and converts it to a /preparable/ +'Hasql.Statement'. + +The parameters @i@ are sent to the database directly via PostgreSQL's binary +format. For large amounts of data this can be significantly more efficient +than embedding the values in the statement with 'Rel8.lit'. +-} +prepared :: forall a b i o. + Serializable a i => + (Statement b -> Hasql.Statement () o) -> + (a -> Statement b) -> + Hasql.Statement i o +prepared run mkStatement = Hasql.Statement sql (encoder @a) decode True + where + Hasql.Statement sql _ decode _ = run $ mkStatement input + + +encoder :: forall a i. Serializable a i => Hasql.Params i +encoder = + contramap (toResult @_ @a) $ + getConst $ + htabulateA \field -> + case hfield hspecs field of + Spec {nullity, info} -> Const $ + runIdentity . (`hfield` field) >$< + case nullity of + Null -> Hasql.param $ Hasql.nullable build + NotNull -> Hasql.param $ Hasql.nonNullable build + where + build = binary (encode info) + + +input :: Table Expr a => a +input = + fromColumns $ + flip (evalState @Word) 1 do + htabulateA \field -> do + n <- state (\n -> (n, n + 1)) + pure + case hfield hspecs field of + Spec {info} -> + scastExpr info $ fromPrimExpr $ + Opaleye.ConstExpr $ Opaleye.OtherLit $ '$' : show n diff --git a/src/Rel8/Statement/Run.hs b/src/Rel8/Statement/Run.hs index 188cb366..3f3b1470 100644 --- a/src/Rel8/Statement/Run.hs +++ b/src/Rel8/Statement/Run.hs @@ -57,16 +57,15 @@ runN = makeRun RowsAffected -- | Convert a 'Statement' to a runnable 'Hasql.Statement', processing the -- result of the statement as a single row. If the statement returns a number -- of rows other than 1, a runtime exception is thrown. -run1 :: Serializable exprs - a=> Statement (Query exprs) -> Hasql.Statement () a +run1 :: Serializable exprs a => Statement (Query exprs) -> Hasql.Statement () a run1 = makeRun Single -- | Convert a 'Statement' to a runnable 'Hasql.Statement', processing the -- result of the statement as 'Maybe' a single row. If the statement returns -- a number of rows other than 0 or 1, a runtime exception is thrown. -runMaybe :: Serializable exprs - a=> Statement (Query exprs) -> Hasql.Statement () (Maybe a) +runMaybe :: Serializable exprs a + => Statement (Query exprs) -> Hasql.Statement () (Maybe a) runMaybe = makeRun Maybe diff --git a/src/Rel8/Statement/SQL.hs b/src/Rel8/Statement/SQL.hs index aa9dfef2..2ec21367 100644 --- a/src/Rel8/Statement/SQL.hs +++ b/src/Rel8/Statement/SQL.hs @@ -1,8 +1,11 @@ +{-# language FlexibleContexts #-} + module Rel8.Statement.SQL ( showDelete , showInsert , showUpdate , showStatement + , showPreparedStatement ) where @@ -13,12 +16,15 @@ import Prelude import qualified Opaleye.Internal.Tag as Opaleye -- rel8 +import Rel8.Expr (Expr) import Rel8.Statement (Statement, ppDecodeStatement) import Rel8.Statement.Delete ( Delete, ppDelete ) import Rel8.Statement.Insert ( Insert, ppInsert ) +import Rel8.Statement.Prepared (input) import Rel8.Statement.Rows (Rows (Void)) import Rel8.Statement.Select (ppSelect) import Rel8.Statement.Update ( Update, ppUpdate ) +import Rel8.Table (Table) -- transformers import Control.Monad.Trans.State.Strict (evalState) @@ -42,3 +48,9 @@ showUpdate = show . (`evalState` Opaleye.start) . ppUpdate -- | Convert a 'Statement' to a 'String' containing an SQL statement. showStatement :: Statement a -> String showStatement = show . fst . ppDecodeStatement ppSelect Void + + +-- | Convert a parameterized 'Statement' to a 'String' containing an SQL +-- statement. +showPreparedStatement :: Table Expr i => (i -> Statement a) -> String +showPreparedStatement = showStatement . ($ input) diff --git a/src/Rel8/Type.hs b/src/Rel8/Type.hs index c17e1b24..f49835ba 100644 --- a/src/Rel8/Type.hs +++ b/src/Rel8/Type.hs @@ -1,6 +1,7 @@ -{-# language LambdaCase #-} +{-# language DisambiguateRecordFields #-} {-# language FlexibleContexts #-} {-# language FlexibleInstances #-} +{-# language LambdaCase #-} {-# language MonoLocalBinds #-} {-# language MultiWayIf #-} {-# language OverloadedStrings #-} @@ -17,23 +18,19 @@ where -- aeson import Data.Aeson ( Value ) import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Text as Aeson -- attoparsec import qualified Data.Attoparsec.ByteString.Char8 as A --- attoparsec-aeson -import qualified Data.Aeson.Parser as Aeson - -- base import Control.Applicative ((<|>)) import Data.Fixed (Fixed) -import Data.Int ( Int8, Int16, Int32, Int64 ) -import Data.Word (Word8, Word32) +import Data.Functor.Contravariant ((>$<)) +import Data.Int (Int16, Int32, Int64) import Data.List.NonEmpty ( NonEmpty ) import Data.Kind ( Constraint, Type ) import Prelude -import Data.Bits (Bits (..)) -import Data.DoubleWord (fromHiAndLo) import Text.Read (readMaybe) -- bytestring @@ -42,22 +39,19 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy as Lazy ( ByteString ) import qualified Data.ByteString.Lazy as ByteString ( fromStrict, toStrict ) +import qualified Data.ByteString.Builder as B +import Data.ByteString.Builder.Prim (primBounded) -- case-insensitive import Data.CaseInsensitive ( CI ) import qualified Data.CaseInsensitive as CI --- data-textual -import Data.Textual (textual) - -- hasql -import qualified Hasql.Decoders as Hasql +import qualified Hasql.Decoders as Decoders +import qualified Hasql.Encoders as Encoders --- network-ip -import qualified Network.IP.Addr as IP - -import qualified Data.IP -import qualified BinaryParser +-- iproute +import Data.IP (IPRange) -- opaleye import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye @@ -67,27 +61,30 @@ import qualified Opaleye.Internal.HaskellDB.Sql.Default as Opaleye ( quote ) import Rel8.Schema.Null ( NotNull, Sql, nullable ) import Rel8.Type.Array ( listTypeInformation, nonEmptyTypeInformation ) import Rel8.Type.Decimal (PowerOf10, resolution) -import Rel8.Type.Decoder ( Decoder(..) ) +import Rel8.Type.Decoder (Decoder (..)) +import Rel8.Type.Encoder (Encoder (..)) import Rel8.Type.Information ( TypeInformation(..), mapTypeInformation ) import Rel8.Type.Name (TypeName (..)) import Rel8.Type.Parser (parse) -import Rel8.Type.Parser.ByteString (bytestring) -import qualified Rel8.Type.Parser.Time as Time +import qualified Rel8.Type.Builder.ByteString as Builder +import qualified Rel8.Type.Parser.ByteString as Parser +import qualified Rel8.Type.Builder.Time as Builder +import qualified Rel8.Type.Parser.Time as Parser -- scientific -import Data.Scientific ( Scientific ) +import Data.ByteString.Builder.Scientific (scientificBuilder) +import Data.Scientific (Scientific) -- text import Data.Text ( Text ) import qualified Data.Text as Text -import qualified Data.Text.Encoding as Text (decodeUtf8) -import qualified Data.Text.Lazy as Lazy ( Text, unpack ) -import qualified Data.Text.Lazy as Text ( fromStrict, toStrict ) -import qualified Data.Text.Lazy.Encoding as Lazy ( decodeUtf8 ) +import qualified Data.Text.Encoding as Text (decodeUtf8, encodeUtf8Builder) +import qualified Data.Text.Lazy as Lazy (Text, unpack) +import qualified Data.Text.Lazy as Text (fromStrict, toStrict) -- time import Data.Time.Calendar (Day) -import Data.Time.Clock (UTCTime) +import Data.Time.Clock (DiffTime, UTCTime) import Data.Time.LocalTime ( CalendarDiffTime (CalendarDiffTime) , LocalTime @@ -119,16 +116,23 @@ class NotNull a => DBType a where -- | Corresponds to @bool@ instance DBType Bool where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.BoolLit + { encode = + Encoder + { binary = Encoders.bool + , text = \case + False -> "f" + True -> "t" + , quote = Opaleye.ConstExpr . Opaleye.BoolLit + } , decode = Decoder - { binary = Hasql.bool - , parser = \case + { binary = Decoders.bool + , text = \case "t" -> pure True "f" -> pure False input -> Left $ "bool: bad bool " <> show input - , delimiter = ',' } + , delimiter = ',' , typeName = "bool" } @@ -136,20 +140,25 @@ instance DBType Bool where -- | Corresponds to @char@ instance DBType Char where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.StringLit . pure - , typeName = - TypeName - { name = "bpchar" - , modifiers = ["1"] - , arrayDepth = 0 + { encode = + Encoder + { binary = Encoders.char + , text = B.charUtf8 + , quote = Opaleye.ConstExpr . Opaleye.StringLit . pure } , decode = Decoder - { binary = Hasql.char - , parser = \input -> case UTF8.uncons input of + { binary = Decoders.char + , text = \input -> case UTF8.uncons input of Just (char, rest) | BS.null rest -> pure char _ -> Left $ "char: bad char " <> show input - , delimiter = ',' + } + , delimiter = ',' + , typeName = + TypeName + { name = "bpchar" + , modifiers = ["1"] + , arrayDepth = 0 } } @@ -157,13 +166,18 @@ instance DBType Char where -- | Corresponds to @int2@ instance DBType Int16 where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.IntegerLit . toInteger + { encode = + Encoder + { binary = Encoders.int2 + , text = B.int16Dec + , quote = Opaleye.ConstExpr . Opaleye.IntegerLit . toInteger + } , decode = Decoder - { binary = Hasql.int2 - , parser = parse (A.signed A.decimal) - , delimiter = ',' + { binary = Decoders.int2 + , text = parse (A.signed A.decimal) } + , delimiter = ',' , typeName = "int2" } @@ -171,13 +185,18 @@ instance DBType Int16 where -- | Corresponds to @int4@ instance DBType Int32 where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.IntegerLit . toInteger + { encode = + Encoder + { binary = Encoders.int4 + , text = B.int32Dec + , quote = Opaleye.ConstExpr . Opaleye.IntegerLit . toInteger + } , decode = Decoder - { binary = Hasql.int4 - , parser = parse (A.signed A.decimal) - , delimiter = ',' + { binary = Decoders.int4 + , text = parse (A.signed A.decimal) } + , delimiter = ',' , typeName = "int4" } @@ -185,13 +204,18 @@ instance DBType Int32 where -- | Corresponds to @int8@ instance DBType Int64 where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.IntegerLit . toInteger + { encode = + Encoder + { binary = Encoders.int8 + , text = B.int64Dec + , quote = Opaleye.ConstExpr . Opaleye.IntegerLit . toInteger + } , decode = Decoder - { binary = Hasql.int8 - , parser = parse (A.signed A.decimal) - , delimiter = ',' + { binary = Decoders.int8 + , text = parse (A.signed A.decimal) } + , delimiter = ',' , typeName = "int8" } @@ -199,17 +223,28 @@ instance DBType Int64 where -- | Corresponds to @float4@ and @real@ instance DBType Float where typeInformation = TypeInformation - { encode = \x -> Opaleye.ConstExpr - if | x == (1 / 0) -> Opaleye.OtherLit "'Infinity'" - | isNaN x -> Opaleye.OtherLit "'NaN'" - | x == (-1 / 0) -> Opaleye.OtherLit "'-Infinity'" - | otherwise -> Opaleye.DoubleLit $ realToFrac x + { encode = + Encoder + { binary = Encoders.float4 + , text = + \x -> + if | x == (1 / 0) -> "Infinity" + | isNaN x -> "NaN" + | x == (-1 / 0) -> "-Infinity" + | otherwise -> B.floatDec x + , quote = + \x -> Opaleye.ConstExpr + if | x == (1 / 0) -> Opaleye.OtherLit "'Infinity'" + | isNaN x -> Opaleye.OtherLit "'NaN'" + | x == (-1 / 0) -> Opaleye.OtherLit "'-Infinity'" + | otherwise -> Opaleye.DoubleLit $ realToFrac x + } , decode = Decoder - { binary = Hasql.float4 - , parser = parse (floating (realToFrac <$> A.double)) - , delimiter = ',' + { binary = Decoders.float4 + , text = parse (floating (realToFrac <$> A.double)) } + , delimiter = ',' , typeName = "float4" } @@ -217,17 +252,28 @@ instance DBType Float where -- | Corresponds to @float8@ and @double precision@ instance DBType Double where typeInformation = TypeInformation - { encode = \x -> Opaleye.ConstExpr - if | x == (1 / 0) -> Opaleye.OtherLit "'Infinity'" - | isNaN x -> Opaleye.OtherLit "'NaN'" - | x == (-1 / 0) -> Opaleye.OtherLit "'-Infinity'" - | otherwise -> Opaleye.DoubleLit x + { encode = + Encoder + { binary = Encoders.float8 + , text = + \x -> + if | x == (1 / 0) -> "Infinity" + | isNaN x -> "NaN" + | x == (-1 / 0) -> "-Infinity" + | otherwise -> B.doubleDec x + , quote = + \x -> Opaleye.ConstExpr + if | x == (1 / 0) -> Opaleye.OtherLit "'Infinity'" + | isNaN x -> Opaleye.OtherLit "'NaN'" + | x == (-1 / 0) -> Opaleye.OtherLit "'-Infinity'" + | otherwise -> Opaleye.DoubleLit x + } , decode = Decoder - { binary = Hasql.float8 - , parser = parse (floating A.double) - , delimiter = ',' + { binary = Decoders.float8 + , text = parse (floating A.double) } + , delimiter = ',' , typeName = "float8" } @@ -235,49 +281,52 @@ instance DBType Double where -- | Corresponds to @numeric@ instance DBType Scientific where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.NumericLit + { encode = + Encoder + { binary = Encoders.numeric + , text = scientificBuilder + , quote = Opaleye.ConstExpr . Opaleye.NumericLit + } , decode = Decoder - { binary = Hasql.numeric - , parser = parse A.scientific - , delimiter = ',' + { binary = Decoders.numeric + , text = parse A.scientific } + , delimiter = ',' , typeName = "numeric" } -- | Corresponds to @numeric(1000, log₁₀ n)@ instance PowerOf10 n => DBType (Fixed n) where - typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.NumericLit . realToFrac - , decode = - realToFrac <$> - Decoder - { binary = Hasql.numeric - , parser = parse A.scientific - , delimiter = ',' - } - , typeName = - TypeName - { name = "numeric" - , modifiers = ["1000", show (resolution @n)] - , arrayDepth = 0 - } - } + typeInformation = + mapTypeInformation realToFrac realToFrac (typeInformation @Scientific) + { typeName = + TypeName + { name = "numeric" + , modifiers = ["1000", show (resolution @n)] + , arrayDepth = 0 + } + } -- | Corresponds to @timestamptz@ instance DBType UTCTime where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . - formatTime defaultTimeLocale "'%FT%T%QZ'" + Encoder + { binary = Encoders.timestamptz + , text = primBounded Builder.utcTime + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . + formatTime defaultTimeLocale "'%FT%T%QZ'" + } , decode = Decoder - { binary = Hasql.timestamptz - , parser = parse Time.utcTime - , delimiter = ',' + { binary = Decoders.timestamptz + , text = parse Parser.utcTime } + , delimiter = ',' , typeName = "timestamptz" } @@ -286,14 +335,19 @@ instance DBType UTCTime where instance DBType Day where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . - formatTime defaultTimeLocale "'%F'" + Encoder + { binary = Encoders.date + , text = primBounded Builder.day + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . + formatTime defaultTimeLocale "'%F'" + } , decode = Decoder - { binary = Hasql.date - , parser = parse Time.day - , delimiter = ',' + { binary = Decoders.date + , text = parse Parser.day } + , delimiter = ',' , typeName = "date" } @@ -302,14 +356,19 @@ instance DBType Day where instance DBType LocalTime where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . - formatTime defaultTimeLocale "'%FT%T%Q'" + Encoder + { binary = Encoders.timestamp + , text = primBounded Builder.localTime + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . + formatTime defaultTimeLocale "'%FT%T%Q'" + } , decode = Decoder - { binary = Hasql.timestamp - , parser = parse Time.localTime - , delimiter = ',' + { binary = Decoders.timestamp + , text = parse Parser.localTime } + , delimiter = ',' , typeName = "timestamp" } @@ -318,14 +377,19 @@ instance DBType LocalTime where instance DBType TimeOfDay where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . - formatTime defaultTimeLocale "'%T%Q'" + Encoder + { binary = Encoders.time + , text = primBounded Builder.timeOfDay + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . + formatTime defaultTimeLocale "'%T%Q'" + } , decode = Decoder - { binary = Hasql.time - , parser = parse Time.timeOfDay - , delimiter = ',' + { binary = Decoders.time + , text = parse Parser.timeOfDay } + , delimiter = ',' , typeName = "time" } @@ -334,14 +398,19 @@ instance DBType TimeOfDay where instance DBType CalendarDiffTime where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . - formatTime defaultTimeLocale "'%bmon %0Es'" + Encoder + { binary = toDiffTime >$< Encoders.interval + , text = Builder.calendarDiffTime + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . + formatTime defaultTimeLocale "'%bmon %0Es'" + } , decode = Decoder - { binary = CalendarDiffTime 0 . realToFrac <$> Hasql.interval - , parser = parse Time.calendarDiffTime - , delimiter = ',' + { binary = CalendarDiffTime 0 . realToFrac <$> Decoders.interval + , text = parse Parser.calendarDiffTime } + , delimiter = ',' , typeName = "interval" } @@ -349,13 +418,18 @@ instance DBType CalendarDiffTime where -- | Corresponds to @text@ instance DBType Text where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.StringLit . Text.unpack + { encode = + Encoder + { binary = Encoders.text + , text = Text.encodeUtf8Builder + , quote = Opaleye.ConstExpr . Opaleye.StringLit . Text.unpack + } , decode = Decoder - { binary = Hasql.text - , parser = pure . Text.decodeUtf8 - , delimiter = ',' + { binary = Decoders.text + , text = pure . Text.decodeUtf8 } + , delimiter = ',' , typeName = "text" } @@ -383,13 +457,18 @@ instance DBType (CI Lazy.Text) where -- | Corresponds to @bytea@ instance DBType ByteString where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.ByteStringLit + { encode = + Encoder + { binary = Encoders.bytea + , text = Builder.bytestring + , quote = Opaleye.ConstExpr . Opaleye.ByteStringLit + } , decode = Decoder - { binary = Hasql.bytea - , parser = parse bytestring - , delimiter = ',' + { binary = Decoders.bytea + , text = parse Parser.bytestring } + , delimiter = ',' , typeName = "bytea" } @@ -404,15 +483,20 @@ instance DBType Lazy.ByteString where -- | Corresponds to @uuid@ instance DBType UUID where typeInformation = TypeInformation - { encode = Opaleye.ConstExpr . Opaleye.StringLit . UUID.toString + { encode = + Encoder + { binary = Encoders.uuid + , text = B.byteString . UUID.toASCIIBytes + , quote = Opaleye.ConstExpr . Opaleye.StringLit . UUID.toString + } , decode = Decoder - { binary = Hasql.uuid - , parser = \input -> case UUID.fromASCIIBytes input of + { binary = Decoders.uuid + , text = \input -> case UUID.fromASCIIBytes input of Just a -> pure a Nothing -> Left $ "uuid: bad UUID " <> show input - , delimiter = ',' } + , delimiter = ',' , typeName = "uuid" } @@ -421,85 +505,43 @@ instance DBType UUID where instance DBType Value where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . - Opaleye.quote . - Lazy.unpack . Lazy.decodeUtf8 . Aeson.encode + Encoder + { binary = Encoders.jsonb + , text = Aeson.fromEncoding . Aeson.toEncoding + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . Opaleye.quote . + Lazy.unpack . Aeson.encodeToLazyText + } , decode = Decoder - { binary = Hasql.jsonb - , parser = parse Aeson.value - , delimiter = ',' + { binary = Decoders.jsonb + , text = Aeson.eitherDecodeStrict } + , delimiter = ',' , typeName = "jsonb" } -- | Corresponds to @inet@ -instance DBType (IP.NetAddr IP.IP) where +instance DBType IPRange where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.StringLit . IP.printNetAddr - , decode = - Decoder - { binary = (Hasql.custom . const . BinaryParser.run $ netaddrParser - (\netmask x -> IP.netAddr (IP.IPv4 $ IP.IP4 x) netmask) - (\netmask x1 x2 x3 x4 -> IP.netAddr (IP.IPv6 $ IP.IP6 $ fromHiAndLo (fromHiAndLo x1 x2) (fromHiAndLo x3 x4)) netmask) :: Hasql.Value (IP.NetAddr IP.IP)) - , parser = parse $ - textual - <|> (`IP.netAddr` 32) . IP.IPv4 <$> textual - <|> (`IP.netAddr` 128) . IP.IPv6 <$> textual - , delimiter = ',' + Encoder + { binary = Encoders.inet + , text = B.string7 . show + , quote = Opaleye.ConstExpr . Opaleye.StringLit . show } - , typeName = "inet" - } - --- | Corresponds to @inet@ -instance DBType Data.IP.IPRange where - typeInformation = TypeInformation - { encode = - Opaleye.ConstExpr . Opaleye.StringLit . show , decode = Decoder - { binary = (Hasql.custom . const . BinaryParser.run $ netaddrParser - (\netmask x -> Data.IP.IPv4Range $ Data.IP.makeAddrRange (Data.IP.toIPv4w x) $ fromIntegral netmask) - (\netmask x1 x2 x3 x4 -> Data.IP.IPv6Range $ Data.IP.makeAddrRange (Data.IP.toIPv6w (x1, x2, x3, x4)) $ fromIntegral netmask)) - , parser = \str -> case readMaybe $ BS8.unpack str of + { binary = Decoders.inet + , text = \str -> case readMaybe $ BS8.unpack str of Just x -> Right x Nothing -> Left "Failed to parse inet" - , delimiter = ',' } + , delimiter = ',' , typeName = "inet" } --- | Address family AF_INET -inetAddressFamily :: Word8 -inetAddressFamily = - 2 - --- | Address family AF_INET6 -inet6AddressFamily :: Word8 -inet6AddressFamily = - 3 - --- | This is vendored from `postgresql-binary`. -netaddrParser :: (Word8 -> Word32 -> ip) -> (Word8 -> Word32 -> Word32 -> Word32 -> Word32 -> ip) -> BinaryParser.BinaryParser ip -netaddrParser mkIpv4 mkIpv6 = do - af <- intOfSize 1 - netmask <- intOfSize 1 - isCidr <- intOfSize @Int8 1 - ipSize <- intOfSize @Int8 1 - if | af == inetAddressFamily -> - mkIpv4 netmask <$> intOfSize 4 - | af == inet6AddressFamily -> - mkIpv6 netmask <$> intOfSize 4 <*> intOfSize 4 <*> intOfSize 4 <*> intOfSize 4 - | otherwise -> BinaryParser.failure ("Unknown address family: " <> Text.pack (show af)) - -intOfSize :: (Integral a, Bits a) => Int -> BinaryParser.BinaryParser a -intOfSize x = - fmap integralPack (BinaryParser.bytesOfSize x) - where - integralPack = BS.foldl' (\n h -> shiftL n 8 .|. fromIntegral h) 0 - instance Sql DBType a => DBType [a] where typeInformation = listTypeInformation nullable typeInformation @@ -511,3 +553,8 @@ instance Sql DBType a => DBType (NonEmpty a) where floating :: Floating a => A.Parser a -> A.Parser a floating p = p <|> A.signed (1.0 / 0 <$ "Infinity") <|> 0.0 / 0 <$ "NaN" + + +toDiffTime :: CalendarDiffTime -> DiffTime +toDiffTime (CalendarDiffTime months seconds) = + realToFrac (months * 30 * 24 * 60 * 60) + realToFrac seconds diff --git a/src/Rel8/Type/Array.hs b/src/Rel8/Type/Array.hs index 42b75a6c..beb0d9ca 100644 --- a/src/Rel8/Type/Array.hs +++ b/src/Rel8/Type/Array.hs @@ -1,3 +1,4 @@ +{-# language DisambiguateRecordFields #-} {-# language GADTs #-} {-# language LambdaCase #-} {-# language NamedFieldPuns #-} @@ -6,7 +7,7 @@ {-# language ViewPatterns #-} module Rel8.Type.Array - ( array, encodeArrayElement, extractArrayElement + ( array, quoteArrayElement, extractArrayElement , arrayTypeName , listTypeInformation , nonEmptyTypeInformation @@ -21,35 +22,48 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Control.Applicative ((<|>), many) import Data.Bifunctor (first) import Data.Foldable (fold, toList) -import Data.List.NonEmpty ( NonEmpty, nonEmpty ) -import Prelude hiding ( head, last, length, null, repeat, zipWith ) +import Data.Functor.Contravariant ((>$<)) +import Data.List.NonEmpty (NonEmpty, nonEmpty) +import Prelude hiding (head, last, length, null, repeat, zipWith) -- bytestring import Data.ByteString (ByteString) +import Data.ByteString.Builder (Builder, toLazyByteString) +import qualified Data.ByteString.Builder as B import qualified Data.ByteString.Char8 as BS +import qualified Data.ByteString.Lazy as L + +-- case-insensitive +import qualified Data.CaseInsensitive as CI -- hasql -import qualified Hasql.Decoders as Hasql +import qualified Hasql.Decoders as Decoders +import qualified Hasql.Encoders as Encoders -- opaleye import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye -- rel8 -import Rel8.Schema.Null ( Unnullify, Nullity( Null, NotNull ) ) -import Rel8.Type.Decoder (Decoder (..), NullableOrNot (..), Parser) -import Rel8.Type.Information ( TypeInformation(..), parseTypeInformation ) +import Rel8.Schema.Null (Unnullify, Nullity (Null, NotNull)) +import Rel8.Type.Builder.Fold (interfoldMap) +import Rel8.Type.Decoder (Decoder (..), Parser) +import Rel8.Type.Encoder (Encoder (..)) +import Rel8.Type.Information (TypeInformation(..), parseTypeInformation) import Rel8.Type.Name (TypeName (..), showTypeName) +import Rel8.Type.Nullable (NullableOrNot (..)) import Rel8.Type.Parser (parse) -- text import qualified Data.Text as Text +import qualified Data.Text.Lazy as Text (toStrict) +import qualified Data.Text.Lazy.Encoding as Lazy (decodeUtf8) array :: Foldable f => TypeInformation a -> f Opaleye.PrimExpr -> Opaleye.PrimExpr array info = Opaleye.CastExpr (showTypeName (arrayType info) <> "[]") . - Opaleye.ArrayExpr . map (encodeArrayElement info) . toList + Opaleye.ArrayExpr . map (quoteArrayElement info) . toList {-# INLINABLE array #-} @@ -57,25 +71,34 @@ listTypeInformation :: () => Nullity a -> TypeInformation (Unnullify a) -> TypeInformation [a] -listTypeInformation nullity info@TypeInformation {encode, decode} = +listTypeInformation nullity info@TypeInformation {decode, encode, delimiter} = TypeInformation { decode = Decoder - { binary = Hasql.listArray $ case nullity of - Null -> Hasql.nullable (decodeArrayElement info decode) - NotNull -> Hasql.nonNullable (decodeArrayElement info decode) - , parser = case nullity of - Null -> arrayParser (Nullable decode) - NotNull -> arrayParser (NonNullable decode) - , delimiter = ',' + { binary = Decoders.listArray $ case nullity of + Null -> Decoders.nullable (decodeArrayElement info decode) + NotNull -> Decoders.nonNullable (decodeArrayElement info decode) + , text = case nullity of + Null -> arrayParser delimiter (Nullable decode) + NotNull -> arrayParser delimiter (NonNullable decode) + } + , encode = + Encoder + { binary = Encoders.foldableArray $ case nullity of + Null -> Encoders.nullable (encodeArrayElement info encode) + NotNull -> Encoders.nonNullable (encodeArrayElement info encode) + , text = case nullity of + Null -> arrayBuild delimiter (Nullable encode) + NotNull -> arrayBuild delimiter (NonNullable encode) + , quote = case nullity of + Null -> + Opaleye.ArrayExpr . + fmap (quoteArrayElement info . maybe null (quote encode)) + NotNull -> + Opaleye.ArrayExpr . + fmap (quoteArrayElement info . quote encode) } - , encode = case nullity of - Null -> - Opaleye.ArrayExpr . - fmap (encodeArrayElement info . maybe null encode) - NotNull -> - Opaleye.ArrayExpr . - fmap (encodeArrayElement info . encode) + , delimiter = ',' , typeName = arrayTypeName info } where @@ -107,15 +130,21 @@ arrayType info | otherwise = typeName info -decodeArrayElement :: TypeInformation a -> Decoder x -> Hasql.Value x -decodeArrayElement info - | isArray info = \decoder -> - Hasql.refine (first Text.pack . parser decoder) Hasql.bytea +decodeArrayElement :: TypeInformation a -> Decoder x -> Decoders.Value x +decodeArrayElement info Decoder {binary, text} + | isArray info = + Decoders.refine (first Text.pack . text) Decoders.bytea + | otherwise = binary + + +encodeArrayElement :: TypeInformation a -> Encoder x -> Encoders.Value x +encodeArrayElement info Encoder {binary, text} + | isArray info = Text.toStrict . Lazy.decodeUtf8 . toLazyByteString . text >$< Encoders.text | otherwise = binary -encodeArrayElement :: TypeInformation a -> Opaleye.PrimExpr -> Opaleye.PrimExpr -encodeArrayElement info +quoteArrayElement :: TypeInformation a -> Opaleye.PrimExpr -> Opaleye.PrimExpr +quoteArrayElement info | isArray info = Opaleye.CastExpr "text" . Opaleye.CastExpr (showTypeName (typeName info)) | otherwise = id @@ -146,14 +175,48 @@ parseArray delimiter = parse $ do A.char '\\' <|> A.char '"' -arrayParser :: NullableOrNot Decoder a -> Parser [a] -arrayParser = \case - Nullable Decoder {parser, delimiter} -> \input -> do +arrayParser :: Char -> NullableOrNot Decoder a -> Parser [a] +arrayParser delimiter = \case + Nullable Decoder {text} -> \input -> do elements <- parseArray delimiter input - traverse (traverse parser) elements - NonNullable Decoder {parser, delimiter} -> \input -> do + traverse (traverse text) elements + NonNullable Decoder {text} -> \input -> do elements <- parseArray delimiter input - traverse (maybe (Left "array: unexpected null") parser) elements + traverse (maybe (Left "array: unexpected null") text) elements + + +buildArray :: Char -> [Maybe ByteString] -> Builder +buildArray delimiter elements = + B.char8 '{' <> + interfoldMap (B.char8 delimiter) element elements <> + B.char8 '}' + where + element = \case + Nothing -> B.string7 "NULL" + Just a + | BS.null a -> "\"\"" + | CI.mk a == "null" -> escaped + | BS.any (A.inClass (delimiter : "\\\"{}")) a -> escaped + | otherwise -> unescaped + where + unescaped = B.byteString a + escaped = + B.char8 '"' <> BS.foldr ((<>) . escape) mempty a <> B.char8 '"' + where + escape = \case + '"' -> B.string7 "\\\"" + '\\' -> B.string7 "\\\\" + c -> B.char8 c + + +arrayBuild :: Char -> NullableOrNot Encoder a -> [a] -> Builder +arrayBuild delimiter = \case + Nullable Encoder {text} -> + buildArray delimiter . + map (fmap (L.toStrict . toLazyByteString . text)) + NonNullable Encoder {text} -> + buildArray delimiter . + map (Just . L.toStrict . toLazyByteString . text) head :: TypeInformation a -> Opaleye.PrimExpr -> Opaleye.PrimExpr @@ -193,4 +256,4 @@ zero = Opaleye.ConstExpr (Opaleye.IntegerLit 0) plus :: Opaleye.PrimExpr -> Opaleye.PrimExpr -> Opaleye.PrimExpr -plus = Opaleye.BinExpr (Opaleye.:+) \ No newline at end of file +plus = Opaleye.BinExpr (Opaleye.:+) diff --git a/src/Rel8/Type/Builder/ByteString.hs b/src/Rel8/Type/Builder/ByteString.hs new file mode 100644 index 00000000..e149755a --- /dev/null +++ b/src/Rel8/Type/Builder/ByteString.hs @@ -0,0 +1,16 @@ +{-# language OverloadedStrings #-} + +module Rel8.Type.Builder.ByteString ( + bytestring, +) where + +-- base +import Prelude + +-- bytestring +import Data.ByteString (ByteString) +import Data.ByteString.Builder (Builder, byteStringHex, string7) + + +bytestring :: ByteString -> Builder +bytestring bytes = string7 "\\x" <> byteStringHex bytes diff --git a/src/Rel8/Type/Builder/Fold.hs b/src/Rel8/Type/Builder/Fold.hs new file mode 100644 index 00000000..49a1deaa --- /dev/null +++ b/src/Rel8/Type/Builder/Fold.hs @@ -0,0 +1,16 @@ +{-# language LambdaCase #-} + +module Rel8.Type.Builder.Fold ( + interfoldMap +) where + +-- base +import Prelude + + +interfoldMap :: (Foldable t, Monoid m) => m -> (a -> m) -> t a -> m +interfoldMap sep f = maybe mempty id . foldr go Nothing + where + go x = \case + Nothing -> Just (f x) + Just acc -> Just (f x <> sep <> acc) diff --git a/src/Rel8/Type/Builder/Time.hs b/src/Rel8/Type/Builder/Time.hs new file mode 100644 index 00000000..eb26d413 --- /dev/null +++ b/src/Rel8/Type/Builder/Time.hs @@ -0,0 +1,151 @@ +{-# language BangPatterns #-} +{-# language NumericUnderscores #-} +{-# language OverloadedStrings #-} +{-# language PatternSynonyms #-} +{-# language PartialTypeSignatures #-} +{-# language TypeApplications #-} +{-# language ViewPatterns #-} + +{-# options_ghc -Wno-partial-type-signatures #-} +-- bytestring does not export Monoidal so we can't write a complete type +-- signature for 'divide' + +{-# options_ghc -Wno-unused-top-binds #-} +-- GHC considers the YMD pattern unused but we use its selectors + +module Rel8.Type.Builder.Time ( + calendarDiffTime, + day, + localTime, + timeOfDay, + utcTime, +) where + +-- base +import Data.Char (chr) +import Data.Fixed (Fixed (MkFixed), Pico) +import Data.Int (Int32, Int64) +import Prelude hiding ((<>)) + +-- bytestring +import Data.ByteString.Builder (Builder, string7) +import Data.ByteString.Builder.Prim ( + BoundedPrim, condB, emptyB, liftFixedToBounded, + FixedPrim, char8, int32Dec, + (>$<), (>*<), + ) + +-- time +import Data.Time.Calendar (Day, toGregorian) +import Data.Time.Clock (UTCTime (utctDay, utctDayTime)) +import Data.Time.Format.ISO8601 (iso8601Show) +import Data.Time.LocalTime ( + CalendarDiffTime, + LocalTime (localDay, localTimeOfDay), + TimeOfDay (todHour, todMin, todSec), + timeToTimeOfDay + ) + + +digit :: FixedPrim Int +digit = (\x -> chr (x + 48)) >$< char8 + + +digits2 :: FixedPrim Int +digits2 = divide (`quotRem` 10) digit digit + + +digits3 :: FixedPrim Int +digits3 = divide (`quotRem` 10) digits2 digit + + +digits4 :: FixedPrim Int +digits4 = divide (`quotRem` 10) digits3 digit + + +frac :: BoundedPrim Int64 +frac = condB (== 0) emptyB $ liftFixedToBounded (char '.') <> trunc12 + where + trunc12 = + divide + (`quotRem` 1_000_000) + (fromIntegral >$< ifZero trunc6 (liftFixedToBounded digits6)) + (fromIntegral >$< nonZero trunc6) + + digitB = liftFixedToBounded digit + + digits6 = divide (`quotRem` 10) digits5 digit + digits5 = divide (`quotRem` 10) digits4 digit + + trunc6 = divide (`quotRem` 100_000) digitB trunc5 + trunc5 = nonZero $ divide (`quotRem` 10_000) digitB trunc4 + trunc4 = nonZero $ divide (`quotRem` 1_000) digitB trunc3 + trunc3 = nonZero $ divide (`quotRem` 100) digitB trunc2 + trunc2 = nonZero $ divide (`quotRem` 10) digitB trunc1 + trunc1 = nonZero digitB + + nonZero = ifZero emptyB + ifZero = condB (== 0) + + +seconds :: BoundedPrim Pico +seconds = + (\(MkFixed s) -> fromIntegral s `quotRem` 1_000_000_000_000) >$< + (liftFixedToBounded (fromIntegral >$< digits2) >*< frac) + + +year :: BoundedPrim Int32 +year = condB (>= 10000) int32Dec (liftFixedToBounded (fromIntegral >$< digits4)) + + +day :: BoundedPrim Day +day = + (fromIntegral . ymdYear >$< year) <> + liftFixedToBounded + ( char '-' <> (ymdMonth >$< digits2) <> char '-' <> (ymdDay >$< digits2) + ) + + +pattern YMD :: Integer -> Int -> Int -> Day +pattern YMD {ymdYear, ymdMonth, ymdDay} <- + (toGregorian -> (ymdYear, ymdMonth, ymdDay)) + + +timeOfDay :: BoundedPrim TimeOfDay +timeOfDay = + liftFixedToBounded + ( (todHour >$< digits2) <> char ':' <> (todMin >$< digits2) <> char ':' + ) <> + (todSec >$< seconds) + + +utcTime :: BoundedPrim UTCTime +utcTime = + (utctDay >$< day) <> + liftFixedToBounded (char ' ') <> + (timeToTimeOfDay . utctDayTime >$< timeOfDay) <> + liftFixedToBounded (char 'Z') + + +localTime :: BoundedPrim LocalTime +localTime = + (localDay >$< day) <> + liftFixedToBounded (char ' ') <> + (localTimeOfDay >$< timeOfDay) + + +calendarDiffTime :: CalendarDiffTime -> Builder +calendarDiffTime = string7 . iso8601Show + + +char :: Char -> FixedPrim a +char c = (\_ -> c) >$< char8 + + +(<>) :: _ => f a -> f a -> f a +(<>) = divide (\a -> (a, a)) +infixr 6 <> + + +divide :: _ => (a -> (b, c)) -> f b -> f c -> f a +divide f a b = f >$< (a >*< b) diff --git a/src/Rel8/Type/Composite.hs b/src/Rel8/Type/Composite.hs index 0a6a537e..d365ed7f 100644 --- a/src/Rel8/Type/Composite.hs +++ b/src/Rel8/Type/Composite.hs @@ -4,6 +4,7 @@ {-# language DisambiguateRecordFields #-} {-# language FlexibleContexts #-} {-# language GADTs #-} +{-# language LambdaCase #-} {-# language NamedFieldPuns #-} {-# language OverloadedStrings #-} {-# language ScopedTypeVariables #-} @@ -27,7 +28,8 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Control.Applicative ((<|>), many, optional) import Data.Foldable (fold) import Data.Functor.Const (Const (Const), getConst) -import Data.Functor.Identity (Identity (Identity)) +import Data.Functor.Contravariant ((>$<)) +import Data.Functor.Identity (Identity (Identity), runIdentity) import Data.Kind ( Constraint, Type ) import Data.List (uncons) import Prelude @@ -35,9 +37,14 @@ import Prelude -- bytestring import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as BS +import Data.ByteString.Builder (Builder) +import Data.ByteString.Builder (toLazyByteString) +import qualified Data.ByteString.Builder as B +import Data.ByteString.Lazy (toStrict) -- hasql -import qualified Hasql.Decoders as Hasql +import qualified Hasql.Decoders as Decoders +import qualified Hasql.Encoders as Encoders -- opaleye import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye @@ -58,8 +65,11 @@ import Rel8.Table.Ord ( OrdTable ) import Rel8.Table.Rel8able () import Rel8.Table.Serialize ( litHTable ) import Rel8.Type ( DBType, typeInformation ) +import Rel8.Type.Builder.Fold (interfoldMap) import Rel8.Type.Decoder (Decoder (Decoder), Parser) import qualified Rel8.Type.Decoder as Decoder +import Rel8.Type.Encoder (Encoder (Encoder)) +import qualified Rel8.Type.Encoder as Encoder import Rel8.Type.Eq ( DBEq ) import Rel8.Type.Information ( TypeInformation(..) ) import Rel8.Type.Name (TypeName (..)) @@ -91,11 +101,16 @@ instance DBComposite a => DBType (Composite a) where typeInformation = TypeInformation { decode = Decoder - { binary = Hasql.composite (Composite . fromResult @_ @(HKD a Expr) <$> decoder) - , parser = fmap (Composite . fromResult @_ @(HKD a Expr)) . parser - , delimiter = ',' + { binary = Decoders.composite (Composite . fromResult @_ @(HKD a Expr) <$> decoder) + , text = fmap (Composite . fromResult @_ @(HKD a Expr)) . parser } - , encode = encoder . litHTable . toResult @_ @(HKD a Expr) . unComposite + , encode = + Encoder + { binary = Encoders.composite (toResult @_ @(HKD a Expr) . unComposite >$< encoder) + , text = builder . toResult @_ @(HKD a Expr) . unComposite + , quote = quoter . litHTable . toResult @_ @(HKD a Expr) . unComposite + } + , delimiter = ',' , typeName = TypeName { name = compositeTypeName @a @@ -135,7 +150,7 @@ class (DBType a, HKDable a) => DBComposite a where -- single column expression, by combining them into a PostgreSQL composite -- type. compose :: DBComposite a => HKD a Expr -> Expr a -compose = castExpr . fromPrimExpr . encoder . toColumns +compose = castExpr . fromPrimExpr . quoter . toColumns -- | Expand a composite type into a 'HKD'. @@ -150,20 +165,13 @@ decompose (toPrimExpr -> a) = fromColumns $ htabulate \field -> names = toColumns (compositeFields @a) -decoder :: HTable t => Hasql.Composite (t Result) +decoder :: HTable t => Decoders.Composite (t Result) decoder = unwrapApplicative $ htabulateA \field -> case hfield hspecs field of Spec {nullity, info} -> WrapApplicative $ Identity <$> case nullity of - Null -> Hasql.field $ Hasql.nullable $ Decoder.binary $ decode info - NotNull -> Hasql.field $ Hasql.nonNullable $ Decoder.binary $ decode info - - -encoder :: HTable t => t Expr -> Opaleye.PrimExpr -encoder a = Opaleye.FunExpr "ROW" exprs - where - exprs = getConst $ htabulateA \field -> case hfield a field of - expr -> Const [toPrimExpr expr] + Null -> Decoders.field $ Decoders.nullable $ Decoder.binary $ decode info + NotNull -> Decoders.field $ Decoders.nonNullable $ Decoder.binary $ decode info parser :: HTable t => Parser (t Result) @@ -178,10 +186,10 @@ parser input = do mbytes <- StateT $ maybe missing pure . uncons lift $ Identity <$> case hfield hspecs field of Spec {nullity, info} -> case nullity of - Null -> traverse (Decoder.parser (decode info)) mbytes + Null -> traverse (Decoder.text (decode info)) mbytes NotNull -> case mbytes of Nothing -> Left "composite: unexpected null" - Just bytes -> Decoder.parser (decode info) bytes + Just bytes -> Decoder.text (decode info) bytes missing = Left "composite: missing fields" @@ -201,3 +209,54 @@ parseRow = parse $ do BS.singleton <$> do A.char '\\' <|> A.char '"' quote = "\"" <$ A.string "\"\"" + + +encoder :: forall t. HTable t => Encoders.Composite (t Result) +encoder = getConst $ htabulateA @t \field -> + case hfield hspecs field of + Spec {nullity, info} -> Const $ + runIdentity . (`hfield` field) >$< + case nullity of + Null -> Encoders.field $ Encoders.nullable build + NotNull -> Encoders.field $ Encoders.nonNullable build + where + build = Encoder.binary (encode info) + + +builder :: HTable t => t Result -> Builder +builder input = buildRow $ getConst $ htabulateA \field -> + Const $ pure $ + case hfield input field of + Identity a -> + case hfield hspecs field of + Spec {nullity, info} -> case nullity of + Null -> build <$> a + NotNull -> Just $ build a + where + build = + toStrict . toLazyByteString . Encoder.text (encode info) + + +buildRow :: [Maybe ByteString] -> Builder +buildRow elements = + B.char8 '(' <> + interfoldMap (B.char8 ',') (foldMap element) elements <> + B.char8 ')' + where + element a + | BS.null a = "\"\"" + | BS.all (A.notInClass ",\\\"()") a = B.byteString a + | otherwise = + B.char8 '"' <> BS.foldr ((<>) . escape) mempty a <> B.char8 '"' + where + escape = \case + '"' -> B.string7 "\"\"" + '\\' -> B.string7 "\\\\" + c -> B.char8 c + + +quoter :: HTable t => t Expr -> Opaleye.PrimExpr +quoter a = Opaleye.FunExpr "ROW" exprs + where + exprs = getConst $ htabulateA \field -> case hfield a field of + expr -> Const [toPrimExpr expr] diff --git a/src/Rel8/Type/Decimal.hs b/src/Rel8/Type/Decimal.hs index 1c6f94ed..6fcd323f 100644 --- a/src/Rel8/Type/Decimal.hs +++ b/src/Rel8/Type/Decimal.hs @@ -100,4 +100,4 @@ type IsPowerOf10' :: Bool -> Nat -> Constraint type family IsPowerOf10' bool n where IsPowerOf10' 'True _n = () IsPowerOf10' 'False n = - TypeError ('ShowType n ':<>: 'Text " is not a power of 10") \ No newline at end of file + TypeError ('ShowType n ' :<>: 'Text " is not a power of 10") diff --git a/src/Rel8/Type/Decoder.hs b/src/Rel8/Type/Decoder.hs index 5322e7c5..95398e07 100644 --- a/src/Rel8/Type/Decoder.hs +++ b/src/Rel8/Type/Decoder.hs @@ -1,12 +1,10 @@ {-# language DerivingStrategies #-} {-# language DeriveFunctor #-} -{-# language GADTs #-} {-# language NamedFieldPuns #-} {-# language StandaloneKindSignatures #-} module Rel8.Type.Decoder ( Decoder (..), - NullableOrNot (..), Parser, parseDecoder, ) where @@ -35,11 +33,8 @@ type Decoder :: Type -> Type data Decoder a = Decoder { binary :: Hasql.Value a -- ^ How to deserialize from PostgreSQL's binary format. - , parser :: Parser a + , text :: Parser a -- ^ How to deserialize from PostgreSQL's text format. - , delimiter :: Char - -- ^ The delimiter that is used in PostgreSQL's text format in arrays of - -- this type (this is almost always ','). } deriving stock (Functor) @@ -50,15 +45,9 @@ data Decoder a = Decoder -- a given 'Decoder'. The parser is applied when deserializing rows -- returned. parseDecoder :: (a -> Either String b) -> Decoder a -> Decoder b -parseDecoder f Decoder {binary, parser, delimiter} = +parseDecoder f Decoder {binary, text} = Decoder { binary = Hasql.refine (first Text.pack . f) binary - , parser = parser >=> f - , delimiter + , text = text >=> f } - -type NullableOrNot :: (Type -> Type) -> Type -> Type -data NullableOrNot decoder a where - NonNullable :: decoder a -> NullableOrNot decoder a - Nullable :: decoder a -> NullableOrNot decoder (Maybe a) diff --git a/src/Rel8/Type/Encoder.hs b/src/Rel8/Type/Encoder.hs new file mode 100644 index 00000000..30018e3a --- /dev/null +++ b/src/Rel8/Type/Encoder.hs @@ -0,0 +1,42 @@ +{-# language LambdaCase #-} +{-# language NamedFieldPuns #-} +{-# language RecordWildCards #-} +{-# language StandaloneKindSignatures #-} +{-# language StrictData #-} + +module Rel8.Type.Encoder ( + Encoder (..), +) where + +-- base +import Data.Functor.Contravariant (Contravariant, (>$<), contramap) +import Data.Kind (Type) +import Prelude + +-- bytestring +import Data.ByteString.Builder (Builder) + +-- hasql +import qualified Hasql.Encoders as Hasql + +-- opaleye +import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye + + +type Encoder :: Type -> Type +data Encoder a = Encoder + { binary :: Hasql.Value a + -- ^ How to serialize to PostgreSQL's binary format. + , text :: a -> Builder + -- ^ How to serialize to PostgreSQL's text format. + , quote :: a -> Opaleye.PrimExpr + -- ^ How to encode a single Haskell value as an SQL expression. + } + + +instance Contravariant Encoder where + contramap f Encoder {..} = Encoder + { binary = f >$< binary + , text = text . f + , quote = quote . f + } diff --git a/src/Rel8/Type/Enum.hs b/src/Rel8/Type/Enum.hs index 337b2f4b..c9c0e692 100644 --- a/src/Rel8/Type/Enum.hs +++ b/src/Rel8/Type/Enum.hs @@ -1,6 +1,7 @@ {-# language AllowAmbiguousTypes #-} {-# language DataKinds #-} {-# language DefaultSignatures #-} +{-# language DisambiguateRecordFields #-} {-# language FlexibleContexts #-} {-# language FlexibleInstances #-} {-# language LambdaCase #-} @@ -33,7 +34,8 @@ import GHC.TypeLits ( KnownSymbol, symbolVal ) import Prelude hiding ( Enum ) -- hasql -import qualified Hasql.Decoders as Hasql +import qualified Hasql.Decoders as Decoders +import qualified Hasql.Encoders as Encoders -- opaleye import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye @@ -42,6 +44,7 @@ import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye import Rel8.Schema.QualifiedName (QualifiedName) import Rel8.Type ( DBType, typeInformation ) import Rel8.Type.Decoder (Decoder (..)) +import Rel8.Type.Encoder (Encoder (..)) import Rel8.Type.Eq ( DBEq ) import Rel8.Type.Information ( TypeInformation(..) ) import Rel8.Type.Name (TypeName (..)) @@ -49,7 +52,7 @@ import Rel8.Type.Ord ( DBOrd, DBMax, DBMin ) -- text import Data.Text (pack) -import Data.Text.Encoding (decodeUtf8) +import Data.Text.Encoding (decodeUtf8, encodeUtf8Builder) -- | A deriving-via helper type for column types that store an \"enum\" type @@ -70,21 +73,29 @@ newtype Enum a = Enum instance DBEnum a => DBType (Enum a) where typeInformation = TypeInformation - { decode = + { encode = + let + toText (Enum a) = pack $ enumValue a + in + Encoder + { binary = Encoders.enum toText + , text = encodeUtf8Builder . toText + , quote = + Opaleye.ConstExpr . + Opaleye.StringLit . + enumValue @a . + unEnum + } + , decode = let mapping = (pack . enumValue &&& Enum) <$> enumerate unrecognised = Left "enum: unrecognised value" in Decoder - { binary = Hasql.enum (`lookup` mapping) - , parser = maybe unrecognised pure . (`lookup` mapping) . decodeUtf8 - , delimiter = ',' + { binary = Decoders.enum (`lookup` mapping) + , text = maybe unrecognised pure . (`lookup` mapping) . decodeUtf8 } - , encode = - Opaleye.ConstExpr . - Opaleye.StringLit . - enumValue @a . - unEnum + , delimiter = ',' , typeName = TypeName { name = enumTypeName @a diff --git a/src/Rel8/Type/Information.hs b/src/Rel8/Type/Information.hs index ac27cf84..3be42165 100644 --- a/src/Rel8/Type/Information.hs +++ b/src/Rel8/Type/Information.hs @@ -3,25 +3,21 @@ {-# language StandaloneKindSignatures #-} {-# language StrictData #-} -module Rel8.Type.Information - ( TypeInformation(..) - , mapTypeInformation - , parseTypeInformation - ) -where +module Rel8.Type.Information ( + TypeInformation(..), + mapTypeInformation, + parseTypeInformation, +) where -- base -import Data.Kind ( Type ) +import Data.Functor.Contravariant ((>$<)) +import Data.Kind (Type) import Prelude --- opaleye -import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye - -- rel8 -import Rel8.Type.Name (TypeName) - --- text import Rel8.Type.Decoder (Decoder, parseDecoder) +import Rel8.Type.Encoder (Encoder) +import Rel8.Type.Name (TypeName) -- | @TypeInformation@ describes how to encode and decode a Haskell type to and @@ -29,14 +25,17 @@ import Rel8.Type.Decoder (Decoder, parseDecoder) -- database, which is used to accurately type literals. type TypeInformation :: Type -> Type data TypeInformation a = TypeInformation - { encode :: a -> Opaleye.PrimExpr - -- ^ How to encode a single Haskell value as a SQL expression. + { encode :: Encoder a + -- ^ How to serialize a Haskell value to PostgreSQL. , decode :: Decoder a - -- ^ How to deserialize a single result back to Haskell. + -- ^ How to deserialize a PostgreSQL result back to Haskell. + , delimiter :: Char + -- ^ The delimiter that is used in PostgreSQL's text format in arrays of + -- this type (this is almost always ','). , typeName :: TypeName -- ^ The name of the SQL type. } - + -- | Simultaneously map over how a type is both encoded and decoded, while -- retaining the name of the type. This operation is useful if you want to @@ -59,9 +58,10 @@ mapTypeInformation = parseTypeInformation . fmap pure parseTypeInformation :: () => (a -> Either String b) -> (b -> a) -> TypeInformation a -> TypeInformation b -parseTypeInformation to from TypeInformation {encode, decode, typeName} = +parseTypeInformation to from TypeInformation {encode, decode, delimiter, typeName} = TypeInformation - { encode = encode . from - , decode = parseDecoder to decode + { decode = parseDecoder to decode + , encode = from >$< encode + , delimiter , typeName } diff --git a/src/Rel8/Type/JSONBEncoded.hs b/src/Rel8/Type/JSONBEncoded.hs index 4cf4bd50..bd0b6d3a 100644 --- a/src/Rel8/Type/JSONBEncoded.hs +++ b/src/Rel8/Type/JSONBEncoded.hs @@ -1,30 +1,40 @@ +{-# language DisambiguateRecordFields #-} {-# language OverloadedStrings #-} {-# language StandaloneKindSignatures #-} -module Rel8.Type.JSONBEncoded - ( JSONBEncoded(..) - ) -where +module Rel8.Type.JSONBEncoded ( + JSONBEncoded(..), +) where -- aeson import Data.Aeson (FromJSON, ToJSON, eitherDecodeStrict, parseJSON, toJSON) +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Text as Aeson import Data.Aeson.Types (parseEither) -- base import Data.Bifunctor ( first ) +import Data.Functor.Contravariant ((>$<)) import Data.Kind ( Type ) import Prelude -- hasql -import qualified Hasql.Decoders as Hasql +import qualified Hasql.Decoders as Decoders +import qualified Hasql.Encoders as Encoders + +-- opaleye +import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye +import qualified Opaleye.Internal.HaskellDB.Sql.Default as Opaleye (quote) -- rel8 import Rel8.Type ( DBType(..) ) import Rel8.Type.Decoder (Decoder (..)) +import Rel8.Type.Encoder (Encoder (..)) import Rel8.Type.Information ( TypeInformation(..) ) -- text import Data.Text ( pack ) +import Data.Text.Lazy (unpack) -- | Like 'Rel8.JSONEncoded', but works for @jsonb@ columns. @@ -32,14 +42,26 @@ type JSONBEncoded :: Type -> Type newtype JSONBEncoded a = JSONBEncoded { fromJSONBEncoded :: a } deriving (Show, Eq, Ord) + instance (FromJSON a, ToJSON a) => DBType (JSONBEncoded a) where typeInformation = TypeInformation - { encode = encode typeInformation . toJSON . fromJSONBEncoded + { encode = + Encoder + { binary = toJSON . fromJSONBEncoded >$< Encoders.jsonb + , text = Aeson.fromEncoding . Aeson.toEncoding . fromJSONBEncoded + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . + Opaleye.quote . + unpack . Aeson.encodeToLazyText . fromJSONBEncoded + } , decode = Decoder - { binary = Hasql.refine (first pack . fmap JSONBEncoded . parseEither parseJSON) Hasql.jsonb - , parser = fmap JSONBEncoded . eitherDecodeStrict - , delimiter = ',' + { binary = + Decoders.refine + (first pack . fmap JSONBEncoded . parseEither parseJSON) + Decoders.jsonb + , text = fmap JSONBEncoded . eitherDecodeStrict } + , delimiter = ',' , typeName = "jsonb" } diff --git a/src/Rel8/Type/JSONEncoded.hs b/src/Rel8/Type/JSONEncoded.hs index 8194e001..398e73ce 100644 --- a/src/Rel8/Type/JSONEncoded.hs +++ b/src/Rel8/Type/JSONEncoded.hs @@ -1,35 +1,41 @@ +{-# language DisambiguateRecordFields #-} {-# language StandaloneKindSignatures #-} -{-# language OverloadedStrings #-} -{-# language TypeApplications #-} +{-# language OverloadedStrings #-} +{-# language TypeApplications #-} -module Rel8.Type.JSONEncoded ( JSONEncoded(..) ) where +module Rel8.Type.JSONEncoded ( + JSONEncoded(..), +) where -- aeson -import Data.Aeson ( FromJSON, ToJSON, parseJSON ) -import Data.Aeson.Types ( parseEither ) +import Data.Aeson (FromJSON, ToJSON, eitherDecodeStrict, parseJSON, toJSON) import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Text as Aeson +import Data.Aeson.Types (parseEither) -- base -import Data.Kind ( Type ) import Data.Bifunctor (first) +import Data.Functor.Contravariant ((>$<)) +import Data.Kind ( Type ) import Prelude -- hasql -import qualified Hasql.Decoders as Hasql +import qualified Hasql.Decoders as Decoders +import qualified Hasql.Encoders as Encoders -- rel8 import Rel8.Type ( DBType(..) ) +import Rel8.Type.Decoder (Decoder (..)) +import Rel8.Type.Encoder (Encoder (..)) import Rel8.Type.Information ( TypeInformation(..) ) -import Rel8.Type.Decoder ( Decoder(..) ) -- opaleye import qualified Opaleye.Internal.HaskellDB.PrimQuery as Opaleye import qualified Opaleye.Internal.HaskellDB.Sql.Default as Opaleye ( quote ) -- text -import qualified Data.Text as Text -import qualified Data.Text.Lazy as Lazy -import qualified Data.Text.Lazy.Encoding as Lazy +import Data.Text (pack) +import Data.Text.Lazy (unpack) -- | A deriving-via helper type for column types that store a Haskell value @@ -43,14 +49,21 @@ newtype JSONEncoded a = JSONEncoded { fromJSONEncoded :: a } instance (FromJSON a, ToJSON a) => DBType (JSONEncoded a) where typeInformation = TypeInformation { encode = - Opaleye.ConstExpr . Opaleye.OtherLit . Opaleye.quote . - Lazy.unpack . Lazy.decodeUtf8 . - Aeson.encode . fromJSONEncoded + Encoder + { binary = toJSON . fromJSONEncoded >$< Encoders.json + , text = Aeson.fromEncoding . Aeson.toEncoding . fromJSONEncoded + , quote = + Opaleye.ConstExpr . Opaleye.OtherLit . Opaleye.quote . + unpack . Aeson.encodeToLazyText . fromJSONEncoded + } , decode = Decoder - { binary = Hasql.refine (first Text.pack . fmap JSONEncoded . parseEither parseJSON) Hasql.json - , parser = fmap JSONEncoded . Aeson.eitherDecodeStrict - , delimiter = ',' + { binary = + Decoders.refine + (first pack . fmap JSONEncoded . parseEither parseJSON) + Decoders.json + , text = fmap JSONEncoded . eitherDecodeStrict } + , delimiter = ',' , typeName = "json" } diff --git a/src/Rel8/Type/Nullable.hs b/src/Rel8/Type/Nullable.hs new file mode 100644 index 00000000..95d3439f --- /dev/null +++ b/src/Rel8/Type/Nullable.hs @@ -0,0 +1,16 @@ +{-# language GADTs #-} +{-# language StandaloneKindSignatures #-} + +module Rel8.Type.Nullable ( + NullableOrNot (..), +) where + +-- base +import Data.Kind (Type) +import Prelude + + +type NullableOrNot :: (Type -> Type) -> Type -> Type +data NullableOrNot decoder a where + NonNullable :: decoder a -> NullableOrNot decoder a + Nullable :: decoder a -> NullableOrNot decoder (Maybe a) diff --git a/tests/Main.hs b/tests/Main.hs index d942ffa2..ba1e206e 100644 --- a/tests/Main.hs +++ b/tests/Main.hs @@ -37,8 +37,7 @@ import Data.Int ( Int32, Int64 ) import Data.List ( nub, sort ) import Data.Maybe ( catMaybes ) import Data.Ratio ((%)) -import Data.String ( fromString ) -import Data.Word (Word32, Word8) +import Data.Word (Word32) import GHC.Generics ( Generic ) import Prelude hiding (truncate) @@ -70,15 +69,12 @@ import Hedgehog ( property, (===), forAll, cover, diff, evalM, PropertyT, TestT, import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range +-- iproute +import qualified Data.IP + -- mmorph import Control.Monad.Morph ( hoist ) --- network-ip -import Network.IP.Addr (NetAddr, IP, IP4(..), IP6(..), IP46(..), net4Addr, net6Addr, fromNetAddr46, Net4Addr, Net6Addr) -import Data.DoubleWord (Word128(..)) - -import qualified Data.IP - -- rel8 import Rel8 ( Result ) import qualified Rel8 @@ -93,7 +89,7 @@ import Test.Tasty import Test.Tasty.Hedgehog ( testProperty ) -- text -import Data.Text ( Text, pack, unpack ) +import Data.Text ( Text, unpack ) import qualified Data.Text as T import qualified Data.Text.Lazy import Data.Text.Encoding ( decodeUtf8 ) @@ -140,7 +136,6 @@ tests = , testDBEq getTestDatabase , testTableEquality getTestDatabase , testFromRational getTestDatabase - , testFromString getTestDatabase , testCatMaybeTable getTestDatabase , testCatMaybe getTestDatabase , testMaybeTable getTestDatabase @@ -168,7 +163,7 @@ tests = sql "CREATE TABLE test_table ( column1 text not null, column2 bool not null )" sql "CREATE TABLE unique_table ( \"key\" text not null unique, \"value\" text not null )" sql "CREATE SEQUENCE test_seq" - sql "CREATE TYPE composite AS (\"bool\" bool, \"char\" char, \"array\" int4[])" + sql "CREATE TYPE composite AS (\"bool\" bool, \"char\" text, \"array\" int4[])" return db @@ -445,7 +440,7 @@ testAp = databasePropertyTest "Cartesian product (<*>)" \transaction -> do data Composite = Composite { bool :: !Bool - , char :: !Char + , char :: !Text , array :: ![Int32] } deriving stock (Eq, Show, Generic) @@ -456,18 +451,15 @@ instance Rel8.DBComposite Composite where compositeTypeName = "composite" compositeFields = Rel8.namesFromLabels --- | Postgres doesn't support the NULL character (not to be confused with a NULL value) inside strings. -removeNull :: Text -> Text -removeNull = T.filter (/='\0') - testDBType :: IO TmpPostgres.DB -> TestTree testDBType getTestDatabase = testGroup "DBType instances" [ dbTypeTest "Bool" Gen.bool , dbTypeTest "ByteString" $ Gen.bytes (Range.linear 0 128) , dbTypeTest "CalendarDiffTime" genCalendarDiffTime - , dbTypeTest "CI Lazy Text" $ mk . Data.Text.Lazy.fromStrict . removeNull <$> Gen.text (Range.linear 0 10) Gen.unicode - , dbTypeTest "CI Text" $ mk .removeNull <$> Gen.text (Range.linear 0 10) Gen.unicode + , dbTypeTest "Char" Gen.unicode + , dbTypeTest "CI Lazy Text" $ mk . Data.Text.Lazy.fromStrict <$> genText + , dbTypeTest "CI Text" $ mk <$> genText , dbTypeTest "Composite" genComposite , dbTypeTest "Day" genDay , dbTypeTest "Double" $ (/ 10) . fromIntegral @Int @Double <$> Gen.integral (Range.linear (-100) 100) @@ -476,14 +468,13 @@ testDBType getTestDatabase = testGroup "DBType instances" , dbTypeTest "Int32" $ Gen.integral @_ @Int32 Range.linearBounded , dbTypeTest "Int64" $ Gen.integral @_ @Int64 Range.linearBounded , dbTypeTest "Lazy ByteString" $ Data.ByteString.Lazy.fromStrict <$> Gen.bytes (Range.linear 0 128) - , dbTypeTest "Lazy Text" $ Data.Text.Lazy.fromStrict . removeNull <$> Gen.text (Range.linear 0 10) Gen.unicode + , dbTypeTest "Lazy Text" $ Data.Text.Lazy.fromStrict <$> genText , dbTypeTest "LocalTime" genLocalTime , dbTypeTest "Scientific" $ genScientific - , dbTypeTest "Text" $ removeNull <$> Gen.text (Range.linear 0 10) Gen.unicode + , dbTypeTest "Text" genText , dbTypeTest "TimeOfDay" genTimeOfDay , dbTypeTest "UTCTime" $ UTCTime <$> genDay <*> genDiffTime , dbTypeTest "UUID" $ Data.UUID.fromWords <$> genWord32 <*> genWord32 <*> genWord32 <*> genWord32 - , dbTypeTest "INet" genNetAddrIP , dbTypeTest "INet" genIPRange , dbTypeTest "Value" genValue , dbTypeTest "JSONEncoded" genJSONEncoded @@ -497,10 +488,10 @@ testDBType getTestDatabase = testGroup "DBType instances" , databasePropertyTest ("Maybe " <> name) (t (Gen.maybe generator)) getTestDatabase ] - t :: forall a b. (Eq a, Show a, Rel8.Sql Rel8.DBType a, Rel8.ToExprs (Rel8.Expr a) a) + t :: forall a. (Eq a, Show a, Rel8.Sql Rel8.DBType a, Rel8.ToExprs (Rel8.Expr a) a) => Gen a - -> (TestT Transaction () -> PropertyT IO b) - -> PropertyT IO b + -> (TestT Transaction () -> PropertyT IO ()) + -> PropertyT IO () t generator transaction = do x <- forAll generator y <- forAll generator @@ -541,7 +532,31 @@ testDBType getTestDatabase = testGroup "DBType instances" Rel8.aggregate Rel8.listCatExpr $ Rel8.values $ map Rel8.litExpr xsss diff res''''' (==) (concat xsss) - + + transaction do + res <- lift do + statement x $ Rel8.prepared Rel8.run1 $ + Rel8.select @(Rel8.Expr _) . + pure + diff res (==) x + + res' <- lift do + statement [x, y] $ Rel8.prepared Rel8.run1 $ + Rel8.select @(Rel8.ListTable Rel8.Expr (Rel8.Expr _)) . + Rel8.many . Rel8.catListTable + diff res' (==) [x, y] + + res'' <- lift do + statement [[x, y]] $ Rel8.prepared Rel8.run1 $ + Rel8.select @(Rel8.ListTable Rel8.Expr (Rel8.ListTable Rel8.Expr (Rel8.Expr _))) . + Rel8.many . Rel8.many . (Rel8.catListTable >=> Rel8.catListTable) + diff res'' (==) [[x, y]] + + res''' <- lift do + statement [[[x, y]]] $ Rel8.prepared Rel8.run1 $ + Rel8.select @(Rel8.ListTable Rel8.Expr (Rel8.ListTable Rel8.Expr (Rel8.ListTable Rel8.Expr (Rel8.Expr _)))) . + Rel8.many . Rel8.many . Rel8.many . (Rel8.catListTable >=> Rel8.catListTable >=> Rel8.catListTable) + diff res''' (==) [[[x, y]]] genScientific :: Gen Scientific genScientific = (/ 10) . fromIntegral @Int @Scientific <$> Gen.integral (Range.linear (-100) 100) @@ -549,7 +564,7 @@ testDBType getTestDatabase = testGroup "DBType instances" genComposite :: Gen Composite genComposite = do bool <- Gen.bool - char <- Gen.unicode + char <- genText array <- Gen.list (Range.linear 0 10) (Gen.int32 (Range.linear (-10000) 10000)) pure Composite {..} @@ -583,29 +598,13 @@ testDBType getTestDatabase = testGroup "DBType instances" genWord32 :: Gen Word32 genWord32 = Gen.integral Range.linearBounded - genWord128 :: Gen Word128 - genWord128 = Gen.integral Range.linearBounded - - genNetAddrIP :: Gen (NetAddr IP) - genNetAddrIP = - let - genIP4Mask :: Gen Word8 - genIP4Mask = Gen.integral (Range.linearFrom 0 0 32) - - genIPv4 :: Gen (IP46 Net4Addr Net6Addr) - genIPv4 = IPv4 <$> (liftA2 net4Addr (IP4 <$> genWord32) genIP4Mask) - - genIP6Mask :: Gen Word8 - genIP6Mask = Gen.integral (Range.linearFrom 0 0 128) - - genIPv6 :: Gen (IP46 Net4Addr Net6Addr) - genIPv6 = IPv6 <$> (liftA2 net6Addr (IP6 <$> genWord128) genIP6Mask) - - in fromNetAddr46 <$> Gen.choice [ genIPv4, genIPv6 ] - genIPRange :: Gen (Data.IP.IPRange) genIPRange = - let + Gen.choice + [ Data.IP.IPv4Range <$> (Data.IP.makeAddrRange <$> genIPv4 <*> genIP4Mask) + , Data.IP.IPv6Range <$> (Data.IP.makeAddrRange <$> genIPv6 <*> genIP6Mask) + ] + where genIP4Mask :: Gen Int genIP4Mask = Gen.integral (Range.linearFrom 0 0 32) @@ -618,17 +617,16 @@ testDBType getTestDatabase = testGroup "DBType instances" genIPv6 :: Gen (Data.IP.IPv6) genIPv6 = Data.IP.toIPv6w <$> ((,,,) <$> genWord32 <*> genWord32 <*> genWord32 <*> genWord32) - in Gen.choice [ Data.IP.IPv4Range <$> (Data.IP.makeAddrRange <$> genIPv4 <*> genIP4Mask), Data.IP.IPv6Range <$> (Data.IP.makeAddrRange <$> genIPv6 <*> genIP6Mask)] - genKey :: Gen Aeson.Key - genKey = Aeson.Key.fromText <$> Gen.text (Range.linear 0 10) Gen.unicode + genKey = Aeson.Key.fromText <$> genText genValue :: Gen Aeson.Value genValue = Gen.recursive Gen.choice [ pure Aeson.Null , Aeson.Bool <$> Gen.bool , Aeson.Number <$> genScientific - , Aeson.String <$> Gen.text (Range.linear 0 10) Gen.unicode] + , Aeson.String <$> genText + ] [ Aeson.Object . Aeson.KeyMap.fromMap <$> Gen.map (Range.linear 0 10) ((,) <$> genKey <*> genValue) , Aeson.Array . Vector.fromList <$> Gen.list (Range.linear 0 10) genValue ] @@ -642,7 +640,7 @@ testDBEq getTestDatabase = testGroup "DBEq instances" [ dbEqTest "Bool" Gen.bool , dbEqTest "Int32" $ Gen.integral @_ @Int32 Range.linearBounded , dbEqTest "Int64" $ Gen.integral @_ @Int64 Range.linearBounded - , dbEqTest "Text" $ Gen.text (Range.linear 0 10) Gen.unicode + , dbEqTest "Text" $ genText ] where @@ -666,6 +664,15 @@ testDBEq getTestDatabase = testGroup "DBEq instances" res === (x == y) +genText :: Gen Text +genText = removeNull <$> Gen.text (Range.linear 0 10) Gen.unicode + where + -- | Postgres doesn't support the NULL character (not to be confused with a NULL value) inside strings. + removeNull :: Text -> Text + removeNull = T.filter (/= '\0') + + + testTableEquality :: IO TmpPostgres.DB -> TestTree testTableEquality = databasePropertyTest "TestTable equality" \transaction -> do (x, y) <- forAll $ liftA2 (,) genTestTable genTestTable @@ -693,28 +700,17 @@ testFromRational = databasePropertyTest "fromRational" \transaction -> do pure $ fromRational rational diff result (~=) double where - wholeDigits x = fromIntegral $ length $ show $ round x + wholeDigits x = fromIntegral $ length $ show $ round @_ @Integer x -- A Double gives us between 15-17 decimal digits of precision. -- It's tempting to say that two numbers are equal if they differ by less than 1e15. -- But this doesn't hold. -- The precision is split between the whole numer part and the decimal part of the number. -- For instance, a number between 10 and 99 only has around 13 digits of precision in its decimal part. -- Postgres and Haskell show differing amounts of digits in these cases, - a ~= b = abs (a - b) < 10**(-15 + wholeDigits a) + a ~= b = abs (a - b) < 10 ** (-15 + wholeDigits a) infix 4 ~= -testFromString :: IO TmpPostgres.DB -> TestTree -testFromString = databasePropertyTest "fromString" \transaction -> do - str <- forAll $ Gen.list (Range.linear 0 10) Gen.unicode - - transaction do - result <- lift do - statement () $ Rel8.run1 $ Rel8.select do - pure $ fromString str - result === pack str - - testCatMaybeTable :: IO TmpPostgres.DB -> TestTree testCatMaybeTable = databasePropertyTest "catMaybeTable" \transaction -> do rows <- forAll $ Gen.list (Range.linear 0 10) genTestTable @@ -824,7 +820,7 @@ testMaybeTableApplicative = databasePropertyTest "MaybeTable (<*>)" \transaction where genRows :: PropertyT IO [TestTable Result] genRows = forAll do - Gen.list (Range.linear 0 10) $ liftA2 TestTable (Gen.text (Range.linear 0 10) Gen.unicode) (pure True) + Gen.list (Range.linear 0 10) $ liftA2 TestTable genText (pure True) genTestTable :: Gen (TestTable Result)