Fix the On Clause Ordering issue (#156)

* Add failing test

* Refactor newIdentFor to not have an error case

* annotation for warning

* refactoring

* Expression parser

* holy shit it works

* Add a shitload of tests

* cross join

* Find a failing case

* Account for that one case

* works

* Composability test

* okay now it tests something

* Documentation updates

* Add since, changelog

* fix
This commit is contained in:
Matt Parsons 2019-10-28 14:06:01 -06:00 committed by GitHub
parent 5c1f0f65fa
commit 91fa258193
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 837 additions and 104 deletions

View File

@ -1,3 +1,11 @@
3.2.0 (unreleased)
=======
- @parsonsmatt
- [#156](https://github.com/bitemyapp/esqueleto/pull/156): Remove the
restriction that `on` clauses must appear in reverse order to the joining
tables.
3.1.3
========
@ -11,7 +19,7 @@
- [#149](https://github.com/bitemyapp/esqueleto/pull/157): Added `associateJoin` query helpers.
3.1.1
========
=======
- @JoseD92
- [#149](https://github.com/bitemyapp/esqueleto/pull/149): Added `upsert` support.

View File

@ -1,7 +1,7 @@
cabal-version: 1.12
name: esqueleto
version: 3.1.3
version: 3.2.0
synopsis: Type-safe EDSL for SQL queries on persistent backends.
description: @esqueleto@ is a bare bones, type-safe EDSL for SQL queries that works with unmodified @persistent@ SQL backends. Its language closely resembles SQL, so you don't have to learn new concepts, just new syntax, and it's fairly easy to predict the generated SQL and optimize it for your backend. Most kinds of errors committed when writing SQL are caught as compile-time errors---although it is possible to write type-checked @esqueleto@ queries that fail at runtime.
.
@ -31,12 +31,13 @@ library
Database.Esqueleto
Database.Esqueleto.Internal.Language
Database.Esqueleto.Internal.Sql
Database.Esqueleto.Internal.Internal
Database.Esqueleto.Internal.ExprParser
Database.Esqueleto.MySQL
Database.Esqueleto.PostgreSQL
Database.Esqueleto.PostgreSQL.JSON
Database.Esqueleto.SQLite
other-modules:
Database.Esqueleto.Internal.Internal
Database.Esqueleto.Internal.PersistentImport
Database.Esqueleto.PostgreSQL.JSON.Instances
Paths_esqueleto
@ -45,10 +46,12 @@ library
build-depends:
base >=4.8 && <5.0
, aeson >=1.0
, attoparsec >= 0.13 && < 0.14
, blaze-html
, bytestring
, containers
, conduit >=1.3
, containers
, monad-logger
, persistent >=2.10.0 && <2.11
, resourcet >=1.2
@ -75,6 +78,7 @@ test-suite mysql
ghc-options: -Wall
build-depends:
base >=4.8 && <5.0
, attoparsec
, blaze-html
, bytestring
, conduit >=1.3
@ -83,6 +87,7 @@ test-suite mysql
, exceptions
, hspec
, monad-logger
, mtl
, mysql
, mysql-simple
, persistent >=2.8.0 && <2.11
@ -110,6 +115,7 @@ test-suite postgresql
build-depends:
base >=4.8 && <5.0
, aeson
, attoparsec
, blaze-html
, bytestring
, conduit >=1.3
@ -118,6 +124,7 @@ test-suite postgresql
, exceptions
, hspec
, monad-logger
, mtl
, persistent >=2.10.0 && <2.11
, persistent-postgresql >= 2.10.0 && <2.11
, persistent-template
@ -144,6 +151,7 @@ test-suite sqlite
ghc-options: -Wall
build-depends:
base >=4.8 && <5.0
, attoparsec
, blaze-html
, bytestring
, conduit >=1.3
@ -152,6 +160,7 @@ test-suite sqlite
, exceptions
, hspec
, monad-logger
, mtl
, persistent >=2.8.0 && <2.11
, persistent-sqlite
, persistent-template

View File

@ -327,16 +327,11 @@ import qualified Database.Persist
-- @
-- 'select' $
-- 'from' $ \\(p1 `'InnerJoin`` f `'InnerJoin`` p2) -> do
-- 'on' (p2 '^.' PersonId '==.' f '^.' FollowFollowed)
-- 'on' (p1 '^.' PersonId '==.' f '^.' FollowFollower)
-- 'on' (p2 '^.' PersonId '==.' f '^.' FollowFollowed)
-- return (p1, f, p2)
-- @
--
-- /Note carefully that the order of the ON clauses is/
-- /reversed!/ You're required to write your 'on's in reverse
-- order because that helps composability (see the documentation
-- of 'on' for more details).
--
-- We also currently support @UPDATE@ and @DELETE@ statements.
-- For example:
--

View File

@ -0,0 +1,82 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
-- | This is an internal module. This module may have breaking changes without
-- a corresponding major version bump. If you use this module, please open an
-- issue with your use-case so we can safely support it.
module Database.Esqueleto.Internal.ExprParser where
import Prelude hiding (takeWhile)
import Control.Applicative ((<|>))
import Control.Monad (void)
import Data.Attoparsec.Text
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as Text
import Database.Persist.Sql
-- | A type representing the access of a table value. In Esqueleto, we get
-- a guarantee that the access will look something like:
--
-- @
-- escape-char [character] escape-char . escape-char [character] escape-char
-- ^^^^^^^^^^^ ^^^^^^^^^^^
-- table name column name
-- @
data TableAccess = TableAccess
{ tableAccessTable :: Text
, tableAccessColumn :: Text
}
deriving (Eq, Ord, Show)
-- | Parse a @SqlExpr (Value Bool)@'s textual representation into a list of
-- 'TableAccess'
parseOnExpr :: SqlBackend -> Text -> Either String (Set TableAccess)
parseOnExpr sqlBackend text = do
c <- mkEscapeChar sqlBackend
parseOnly (onExpr c) text
-- | This function uses the 'connEscapeName' function in the 'SqlBackend' with an
-- empty identifier to pull out an escape character. This implementation works
-- with postgresql, mysql, and sqlite backends.
mkEscapeChar :: SqlBackend -> Either String Char
mkEscapeChar sqlBackend =
case Text.uncons (connEscapeName sqlBackend (DBName "")) of
Nothing ->
Left "Failed to get an escape character from the SQL backend."
Just (c, _) ->
Right c
type ExprParser a = Char -> Parser a
onExpr :: ExprParser (Set TableAccess)
onExpr e = Set.fromList <$> many' tableAccesses
where
tableAccesses = do
skipToEscape e <?> "Skipping to an escape char"
parseTableAccess e <?> "Parsing a table access"
skipToEscape :: ExprParser ()
skipToEscape escapeChar = void (takeWhile (/= escapeChar))
parseEscapedIdentifier :: ExprParser [Char]
parseEscapedIdentifier escapeChar = do
char escapeChar
str <- parseEscapedChars escapeChar
char escapeChar
pure str
parseTableAccess :: ExprParser TableAccess
parseTableAccess ec = do
tableAccessTable <- Text.pack <$> parseEscapedIdentifier ec
_ <- char '.'
tableAccessColumn <- Text.pack <$> parseEscapedIdentifier ec
pure TableAccess {..}
parseEscapedChars :: ExprParser [Char]
parseEscapedChars escapeChar = go
where
twoEscapes = char escapeChar *> char escapeChar
go = many' (notChar escapeChar <|> twoEscapes)

View File

@ -25,11 +25,16 @@
-- | This is an internal module, anything exported by this module
-- may change without a major version bump. Please use only
-- "Database.Esqueleto" if possible.
--
-- If you use this module, please report what your use case is on the issue
-- tracker so we can safely support it.
module Database.Esqueleto.Internal.Internal where
import Control.Applicative ((<|>))
import Control.Arrow ((***), first)
import Control.Exception (Exception, throw, throwIO)
import Control.Monad (ap, MonadPlus(..), void)
import qualified Data.Maybe as Maybe
import Control.Monad (guard, ap, MonadPlus(..), void)
import Control.Monad.IO.Class (MonadIO(..))
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Resource (MonadResource, release)
@ -43,6 +48,8 @@ import qualified Data.Monoid as Monoid
import Data.Proxy (Proxy(..))
import Database.Esqueleto.Internal.PersistentImport
import Database.Persist.Sql.Util (entityColumnNames, entityColumnCount, parseEntityValues, isIdField, hasCompositeKey)
import qualified Data.Set as Set
import Data.Set (Set)
import qualified Control.Monad.Trans.Reader as R
import qualified Control.Monad.Trans.State as S
import qualified Control.Monad.Trans.Writer as W
@ -57,6 +64,8 @@ import qualified Data.Text.Lazy.Builder as TLB
import Data.Typeable (Typeable)
import Text.Blaze.Html (Html)
import Database.Esqueleto.Internal.ExprParser (TableAccess(..), parseOnExpr)
-- | (Internal) Start a 'from' query with an entity. 'from'
-- does two kinds of magic using 'fromStart', 'fromJoin' and
-- 'fromFinish':
@ -80,8 +89,8 @@ fromStart = x
let ed = entityDef (getVal x)
ident <- newIdentFor (entityDB ed)
let ret = EEntity ident
from_ = FromStart ident ed
return (EPreprocessedFrom ret from_)
f' = FromStart ident ed
return (EPreprocessedFrom ret f')
getVal :: SqlQuery (SqlExpr (PreprocessedFrom (SqlExpr (Entity a)))) -> Proxy a
getVal = const Proxy
@ -94,7 +103,7 @@ fromStartMaybe = maybelize <$> fromStart
where
maybelize :: SqlExpr (PreprocessedFrom (SqlExpr (Entity a)))
-> SqlExpr (PreprocessedFrom (SqlExpr (Maybe (Entity a))))
maybelize (EPreprocessedFrom ret from_) = EPreprocessedFrom (EMaybe ret) from_
maybelize (EPreprocessedFrom ret f') = EPreprocessedFrom (EMaybe ret) f'
-- | (Internal) Do a @JOIN@.
fromJoin
@ -105,71 +114,75 @@ fromJoin
fromJoin (EPreprocessedFrom lhsRet lhsFrom)
(EPreprocessedFrom rhsRet rhsFrom) = Q $ do
let ret = smartJoin lhsRet rhsRet
from_ = FromJoin lhsFrom -- LHS
from' = FromJoin lhsFrom -- LHS
(reifyJoinKind ret) -- JOIN
rhsFrom -- RHS
Nothing -- ON
return (EPreprocessedFrom ret from_)
return (EPreprocessedFrom ret from')
-- | (Internal) Finish a @JOIN@.
fromFinish
:: SqlExpr (PreprocessedFrom a)
-> SqlQuery a
fromFinish (EPreprocessedFrom ret from_) = Q $ do
W.tell mempty { sdFromClause = [from_] }
fromFinish (EPreprocessedFrom ret f') = Q $ do
W.tell mempty { sdFromClause = [f'] }
return ret
-- | @WHERE@ clause: restrict the query's result.
where_ :: SqlExpr (Value Bool) -> SqlQuery ()
where_ expr = Q $ W.tell mempty { sdWhereClause = Where expr }
-- | @ON@ clause: restrict the a @JOIN@'s result. The @ON@
-- clause will be applied to the /last/ @JOIN@ that does not
-- have an @ON@ clause yet. If there are no @JOIN@s without
-- @ON@ clauses (either because you didn't do any @JOIN@, or
-- because all @JOIN@s already have their own @ON@ clauses), a
-- runtime exception 'OnClauseWithoutMatchingJoinException' is
-- thrown. @ON@ clauses are optional when doing @JOIN@s.
-- | An @ON@ clause, useful to describe how two tables are related. Cross joins
-- and tuple-joins do not need an 'on' clause, but 'InnerJoin' and the various
-- outer joins do.
--
-- On the simple case of doing just one @JOIN@, for example
-- If you don't include an 'on' clause (or include too many!) then a runtime
-- exception will be thrown.
--
-- As an example, consider this simple join:
--
-- @
-- select $
-- 'select' $
-- 'from' $ \\(foo `'InnerJoin`` bar) -> do
-- 'on' (foo '^.' FooId '==.' bar '^.' BarFooId)
-- ...
-- @
--
-- there's no ambiguity and the rules above just mean that
-- you're allowed to call 'on' only once (as in SQL). If you
-- have many joins, then the 'on's are applied on the /reverse/
-- order that the @JOIN@s appear. For example:
-- We need to specify the clause for joining the two columns together. If we had
-- this:
--
-- @
-- select $
-- 'select' $
-- 'from' $ \\(foo `'CrossJoin`` bar) -> do
-- ...
-- @
--
-- Then we can safely omit the 'on' clause, because the cross join will make
-- pairs of all records possible.
--
-- You can do multiple 'on' clauses in a query. This query joins three tables,
-- and has two 'on' clauses:
--
-- @
-- 'select' $
-- 'from' $ \\(foo `'InnerJoin`` bar `'InnerJoin`` baz) -> do
-- 'on' (baz '^.' BazId '==.' bar '^.' BarBazId)
-- 'on' (foo '^.' FooId '==.' bar '^.' BarFooId)
-- ...
-- @
--
-- The order is /reversed/ in order to improve composability.
-- For example, consider @query1@ and @query2@ below:
-- Old versions of esqueleto required that you provide the 'on' clauses in
-- reverse order. This restriction has been lifted - you can now provide 'on'
-- clauses in any order, and the SQL should work itself out. The above query is
-- now totally equivalent to this:
--
-- @
-- let query1 =
-- 'from' $ \\(foo `'InnerJoin`` bar) -> do
-- 'on' (foo '^.' FooId '==.' bar '^.' BarFooId)
-- query2 =
-- 'from' $ \\(mbaz `'LeftOuterJoin`` quux) -> do
-- return (mbaz '?.' BazName, quux)
-- test1 = (,) \<$\> query1 \<*\> query2
-- test2 = flip (,) \<$\> query2 \<*\> query1
-- 'select' $
-- 'from' $ \\(foo `'InnerJoin`` bar `'InnerJoin`` baz) -> do
-- 'on' (foo '^.' FooId '==.' bar '^.' BarFooId)
-- 'on' (baz '^.' BazId '==.' bar '^.' BarBazId)
-- ...
-- @
--
-- If the order was /not/ reversed, then @test2@ would be
-- broken: @query1@'s 'on' would refer to @query2@'s
-- 'LeftOuterJoin'.
on :: SqlExpr (Value Bool) -> SqlQuery ()
on expr = Q $ W.tell mempty { sdFromClause = [OnClause expr] }
@ -1396,32 +1409,108 @@ newtype SetClause = SetClause (SqlExpr (Value ()))
-- | Collect 'OnClause's on 'FromJoin's. Returns the first
-- unmatched 'OnClause's data on error. Returns a list without
-- 'OnClauses' on success.
collectOnClauses :: [FromClause] -> Either (SqlExpr (Value Bool)) [FromClause]
collectOnClauses = go []
collectOnClauses
:: SqlBackend
-> [FromClause]
-> Either (SqlExpr (Value Bool)) [FromClause]
collectOnClauses sqlBackend = go Set.empty []
where
go [] (f@(FromStart _ _):fs) = fmap (f:) (go [] fs) -- fast path
go acc (OnClause expr :fs) = findMatching acc expr >>= flip go fs
go acc (f:fs) = go (f:acc) fs
go acc [] = return $ reverse acc
go is [] (f@(FromStart i _) : fs) =
fmap (f:) (go (Set.insert i is) [] fs) -- fast path
go idents acc (OnClause expr : fs) = do
(idents', a) <- findMatching idents acc expr
go idents' a fs
go idents acc (f:fs) =
go idents (f:acc) fs
go _ acc [] =
return $ reverse acc
findMatching (f : acc) expr =
case tryMatch expr f of
Just f' -> return (f' : acc)
Nothing -> (f:) <$> findMatching acc expr
findMatching [] expr = Left expr
findMatching
:: Set Ident
-> [FromClause]
-> SqlExpr (Value Bool)
-> Either (SqlExpr (Value Bool)) (Set Ident, [FromClause])
findMatching idents fromClauses expr =
case fromClauses of
f : acc ->
let
idents' =
idents
<> Set.fromList (Maybe.catMaybes [findLeftmostIdent f, findRightmostIdent f])
in
case tryMatch idents' expr f of
Just (idents'', f') ->
return (idents'', f' : acc)
Nothing ->
fmap (f:) <$> findMatching idents' acc expr
[] ->
Left expr
tryMatch expr (FromJoin l k r onClause) =
matchR `mplus` matchC `mplus` matchL -- right to left
where
matchR = (\r' -> FromJoin l k r' onClause) <$> tryMatch expr r
matchL = (\l' -> FromJoin l' k r onClause) <$> tryMatch expr l
matchC = case onClause of
Nothing | k /= CrossJoinKind
-> return (FromJoin l k r (Just expr))
| otherwise -> mzero
Just _ -> mzero
tryMatch _ _ = mzero
findRightmostIdent (FromStart i _) = Just i
findRightmostIdent (FromJoin _ _ r _) = findRightmostIdent r
findRightmostIdent (OnClause {}) = Nothing
findLeftmostIdent (FromStart i _) = Just i
findLeftmostIdent (FromJoin l _ _ _) = findLeftmostIdent l
findLeftmostIdent (OnClause {}) = Nothing
tryMatch
:: Set Ident
-> SqlExpr (Value Bool)
-> FromClause
-> Maybe (Set Ident, FromClause)
tryMatch idents expr fromClause =
case fromClause of
FromJoin l k r onClause ->
matchTable <|> matchR <|> matchC <|> matchL <|> matchPartial -- right to left
where
matchR = fmap (\r' -> FromJoin l k r' onClause)
<$> tryMatch idents expr r
matchL = fmap (\l' -> FromJoin l' k r onClause)
<$> tryMatch idents expr l
matchPartial = do
i1 <- findLeftmostIdent l
i2 <- findRightmostIdent r
guard $
Set.isSubsetOf
identsInOnClause
(Set.fromList [i1, i2])
guard $ k /= CrossJoinKind
guard $ Maybe.isNothing onClause
pure (Set.fromList [] <> idents, FromJoin l k r (Just expr))
matchC =
case onClause of
Nothing
| "?" `T.isInfixOf` renderedExpr ->
return (idents, FromJoin l k r (Just expr))
| Set.null identsInOnClause ->
return (idents, FromJoin l k r (Just expr))
| otherwise ->
Nothing
Just _ ->
Nothing
matchTable = do
i1 <- findLeftmostIdent r
i2 <- findRightmostIdent l
guard $ Set.fromList [i1, i2] `Set.isSubsetOf` identsInOnClause
guard $ k /= CrossJoinKind
guard $ Maybe.isNothing onClause
pure (Set.fromList [i1, i2] <> idents, FromJoin l k r (Just expr))
_ ->
Nothing
where
identsInOnClause =
onExprToTableIdentifiers
renderedExpr =
renderExpr sqlBackend expr
onExprToTableIdentifiers =
Set.map (I . tableAccessTable)
. either error id
. parseOnExpr sqlBackend
$ renderedExpr
-- | A complete @WHERE@ clause.
data WhereClause = Where (SqlExpr (Value Bool))
@ -1476,6 +1565,7 @@ type LockingClause = Monoid.Last LockingKind
-- | Identifier used for table names.
newtype Ident = I T.Text
deriving (Eq, Ord, Show)
-- | List of identifiers already in use and supply of temporary
@ -1489,24 +1579,18 @@ initialIdentState = IdentState mempty
-- | Create a fresh 'Ident'. If possible, use the given
-- 'DBName'.
newIdentFor :: DBName -> SqlQuery Ident
newIdentFor = Q . lift . try . unDBName
newIdentFor (DBName original) = Q $ lift $ findFree Nothing
where
try orig = do
s <- S.get
let go (t:ts) | t `HS.member` inUse s = go ts
| otherwise = use t
go [] = throw (UnexpectedCaseErr NewIdentForError)
go (possibilities orig)
possibilities t = t : map addNum [2..]
where
addNum :: Int -> T.Text
addNum = T.append t . T.pack . show
use t = do
S.modify (\s -> s { inUse = HS.insert t (inUse s) })
return (I t)
findFree msuffix = do
let
withSuffix =
maybe id (\suffix -> (<> T.pack (show suffix))) msuffix original
isInUse <- S.gets (HS.member withSuffix . inUse)
if isInUse
then findFree (succ <$> (msuffix <|> Just (1 :: Int)))
else do
S.modify (\s -> s { inUse = HS.insert withSuffix (inUse s) })
pure (I withSuffix)
-- | Information needed to escape and use identifiers.
type IdentInfo = (SqlBackend, IdentState)
@ -1914,7 +1998,7 @@ selectSource query = do
-- @Value t@. You may use @Value@ to return projections of an
-- @Entity@ (see @('^.')@ and @('?.')@) or to return any other
-- value calculated on the query (e.g., 'countRows' or
-- 'sub_select').
-- 'subSelect').
--
-- The @SqlSelect a r@ class has functional dependencies that
-- allow type information to flow both from @a@ to @r@ and
@ -2209,11 +2293,15 @@ makeSelect info mode_ distinctClause ret = process mode_
plain v = (v, [])
makeFrom :: IdentInfo -> Mode -> [FromClause] -> (TLB.Builder, [PersistValue])
makeFrom
:: IdentInfo
-> Mode
-> [FromClause]
-> (TLB.Builder, [PersistValue])
makeFrom _ _ [] = mempty
makeFrom info mode fs = ret
where
ret = case collectOnClauses fs of
ret = case collectOnClauses (fst info) fs of
Left expr -> throw $ mkExc expr
Right fs' -> keyword $ uncommas' (map (mk Never) fs')
keyword = case mode of
@ -2932,3 +3020,33 @@ insertSelect = void . insertSelectCount
insertSelectCount :: (MonadIO m, PersistEntity a) =>
SqlQuery (SqlExpr (Insertion a)) -> SqlWriteT m Int64
insertSelectCount = rawEsqueleto INSERT_INTO . fmap EInsertFinal
-- | Renders an expression into 'Text'. Only useful for creating a textual
-- representation of the clauses passed to an "On" clause.
--
-- @since 3.2.0
renderExpr :: SqlBackend -> SqlExpr (Value Bool) -> T.Text
renderExpr sqlBackend e =
case e of
ERaw _ mkBuilderValues -> do
let (builder, _) = mkBuilderValues (sqlBackend, initialIdentState)
in (builderToText builder)
ECompositeKey mkInfo ->
throw
. RenderExprUnexpectedECompositeKey
. builderToText
. mconcat
. mkInfo
$ (sqlBackend, initialIdentState)
-- | An exception thrown by 'RenderExpr' - it's not designed to handle composite
-- keys, and will blow up if you give it one.
--
-- @since 3.2.0
data RenderExprException = RenderExprUnexpectedECompositeKey T.Text
deriving Show
-- |
--
-- @since 3.2.0
instance Exception RenderExprException

View File

@ -55,6 +55,7 @@ module Database.Esqueleto.Internal.Sql
, Mode(..)
, NeedParens(..)
, IdentState
, renderExpr
, initialIdentState
, IdentInfo
, SqlSelect(..)
@ -71,6 +72,7 @@ module Database.Esqueleto.Internal.Sql
, parens
, toArgList
, builderToText
, Ident(..)
) where
import Database.Esqueleto.Internal.Internal

View File

@ -2,6 +2,7 @@
{-# OPTIONS_GHC -fno-warn-deprecations #-}
{-# LANGUAGE ConstraintKinds
, CPP
, PartialTypeSignatures
, UndecidableInstances
, EmptyDataDecls
, FlexibleContexts
@ -55,7 +56,9 @@ module Common.Test
, Unique(..)
) where
import Data.Either
import Control.Monad (forM_, replicateM, replicateM_, void)
import Control.Monad.Reader (ask)
import Control.Monad.Catch (MonadCatch)
#if __GLASGOW_HASKELL__ >= 806
import Control.Monad.Fail (MonadFail)
@ -69,8 +72,8 @@ import Database.Esqueleto
import Database.Persist.TH
import Test.Hspec
import UnliftIO
import qualified Data.Attoparsec.Text as AP
import Database.Persist (PersistValue(..))
import Data.Conduit (ConduitT, (.|), runConduit)
import qualified Data.Conduit.List as CL
import qualified Data.List as L
@ -80,16 +83,17 @@ import qualified Data.Text.Lazy.Builder as TLB
import qualified Data.Text.Internal.Lazy as TL
import qualified Database.Esqueleto.Internal.Sql as EI
import qualified UnliftIO.Resource as R
import qualified Database.Esqueleto.Internal.ExprParser as P
-- Test schema
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistUpperCase|
Foo
name Int
Primary name
deriving Show Eq Ord
Bar
quux FooId
deriving Show Eq Ord
Person
name String
@ -101,6 +105,18 @@ share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistUpperCase|
title String
authorId PersonId
deriving Eq Show
Comment
body String
blog BlogPostId
deriving Eq Show
Profile
name String
person PersonId
deriving Eq Show
Reply
guy PersonId
body String
deriving Eq Show
Lord
county String maxlen=100
@ -160,6 +176,35 @@ share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistUpperCase|
Numbers
int Int
double Double
JoinOne
name String
deriving Eq Show
JoinTwo
joinOne JoinOneId
name String
deriving Eq Show
JoinThree
joinTwo JoinTwoId
name String
deriving Eq Show
JoinFour
name String
joinThree JoinThreeId
deriving Eq Show
JoinOther
name String
deriving Eq Show
JoinMany
name String
joinOther JoinOtherId
joinOne JoinOneId
deriving Eq Show
|]
-- Unique Test schema
@ -317,6 +362,7 @@ testSelectFrom run = do
, (p2e, p1e)
, (p2e, p2e) ]
it "works for a self-join via sub_select" $
run $ do
p1k <- insert p1
@ -447,7 +493,7 @@ testSelectFrom run = do
testSelectJoin :: Run -> Spec
testSelectJoin run = do
describe "select/JOIN" $ do
describe "select:JOIN" $ do
it "works with a LEFT OUTER JOIN" $
run $ do
p1e <- insert' p1
@ -604,11 +650,9 @@ testSelectJoin run = do
return p
liftIO $ (entityVal <$> ps) `shouldBe` [p1]
testSelectWhere :: Run -> Spec
testSelectWhere run = do
describe "select/where_" $ do
describe "select where_" $ do
it "works for a simple example with (==.)" $
run $ do
p1e <- insert' p1
@ -828,6 +872,17 @@ testSelectWhere run = do
, (p4e, f42, p2e)
, (p2e, f21, p1e) ]
it "works for a many-to-many explicit join and on order doesn't matter" $ do
run $ void $
selectRethrowingQuery $
from $ \(person `InnerJoin` blog `InnerJoin` comment) -> do
on $ person ^. PersonId ==. blog ^. BlogPostAuthorId
on $ blog ^. BlogPostId ==. comment ^. CommentBlog
pure (person, comment)
-- we only care that we don't have a SQL error
True `shouldBe` True
it "works for a many-to-many explicit join with LEFT OUTER JOINs" $
run $ do
p1e@(Entity p1k _) <- insert' p1
@ -1461,7 +1516,7 @@ testCountingRows run = do
liftIO $ (n :: Int) `shouldBe` expected
testRenderSql :: Run -> Spec
testRenderSql run =
testRenderSql run = do
describe "testRenderSql" $ do
it "works" $ do
(queryText, queryVals) <- run $ renderQuerySelect $
@ -1481,7 +1536,451 @@ testRenderSql run =
`shouldBe`
[toPersistValue ("Johhny Depp" :: TL.Text)]
describe "renderExpr" $ do
it "renders a value" $ do
(c, expr) <- run $ do
conn <- ask
let Right c = P.mkEscapeChar conn
pure $ (,) c $ EI.renderExpr conn $
EI.EEntity (EI.I "user") ^. PersonId
==. EI.EEntity (EI.I "blog_post") ^. BlogPostAuthorId
expr
`shouldBe`
Text.intercalate (Text.singleton c) ["", "user", ".", "id", ""]
<>
" = "
<>
Text.intercalate (Text.singleton c) ["", "blog_post", ".", "authorId", ""]
it "renders ? for a val" $ do
expr <- run $ ask >>= \c -> pure $ EI.renderExpr c (val (PersonKey 0) ==. val (PersonKey 1))
expr `shouldBe` "? = ?"
describe "EEntity Ident behavior" $ do
let
render :: SqlExpr (Entity val) -> Text.Text
render (EI.EEntity (EI.I ident)) = ident
it "renders sensibly" $ do
results <- run $ do
_ <- insert $ Foo 2
_ <- insert $ Foo 3
_ <- insert $ Person "hello" Nothing Nothing 3
select $
from $ \(a `LeftOuterJoin` b) -> do
on $ a ^. FooName ==. b ^. PersonFavNum
pure (val (render a), val (render b))
head results
`shouldBe`
(Value "Foo", Value "Person")
describe "ExprParser" $ do
let parse parser = AP.parseOnly (parser '#')
describe "parseEscapedChars" $ do
let subject = parse P.parseEscapedChars
it "parses words" $ do
subject "hello world"
`shouldBe`
Right "hello world"
it "only returns a single escape-char if present" $ do
subject "i_am##identifier##"
`shouldBe`
Right "i_am#identifier#"
describe "parseEscapedIdentifier" $ do
let subject = parse P.parseEscapedIdentifier
it "parses the quotes out" $ do
subject "#it's a me, mario#"
`shouldBe`
Right "it's a me, mario"
it "requires a beginning and end quote" $ do
subject "#alas, i have no end"
`shouldSatisfy`
isLeft
describe "parseTableAccess" $ do
let subject = parse P.parseTableAccess
it "parses a table access" $ do
subject "#foo#.#bar#"
`shouldBe`
Right P.TableAccess
{ P.tableAccessTable = "foo"
, P.tableAccessColumn = "bar"
}
describe "onExpr" $ do
let subject = parse P.onExpr
it "works" $ do
subject "#foo#.#bar# = #bar#.#baz#"
`shouldBe` do
Right $ S.fromList
[ P.TableAccess
{ P.tableAccessTable = "foo"
, P.tableAccessColumn = "bar"
}
, P.TableAccess
{ P.tableAccessTable = "bar"
, P.tableAccessColumn = "baz"
}
]
it "also works with other nonsense" $ do
subject "#foo#.#bar# = 3"
`shouldBe` do
Right $ S.fromList
[ P.TableAccess
{ P.tableAccessTable = "foo"
, P.tableAccessColumn = "bar"
}
]
it "handles a conjunction" $ do
subject "#foo#.#bar# = #bar#.#baz# AND #bar#.#baz# > 10"
`shouldBe` do
Right $ S.fromList
[ P.TableAccess
{ P.tableAccessTable = "foo"
, P.tableAccessColumn = "bar"
}
, P.TableAccess
{ P.tableAccessTable = "bar"
, P.tableAccessColumn = "baz"
}
]
it "handles ? okay" $ do
subject "#foo#.#bar# = ?"
`shouldBe` do
Right $ S.fromList
[ P.TableAccess
{ P.tableAccessTable = "foo"
, P.tableAccessColumn = "bar"
}
]
it "handles degenerate cases" $ do
subject "false" `shouldBe` pure mempty
subject "true" `shouldBe` pure mempty
subject "1 = 1" `shouldBe` pure mempty
it "works even if an identifier isn't first" $ do
subject "true and #foo#.#bar# = 2"
`shouldBe` do
Right $ S.fromList
[ P.TableAccess
{ P.tableAccessTable = "foo"
, P.tableAccessColumn = "bar"
}
]
testOnClauseOrder :: Run -> Spec
testOnClauseOrder run = describe "On Clause Ordering" $ do
let
setup :: MonadIO m => SqlPersistT m ()
setup = do
ja1 <- insert (JoinOne "j1 hello")
ja2 <- insert (JoinOne "j1 world")
jb1 <- insert (JoinTwo ja1 "j2 hello")
jb2 <- insert (JoinTwo ja1 "j2 world")
jb3 <- insert (JoinTwo ja2 "j2 foo")
_ <- insert (JoinTwo ja2 "j2 bar")
jc1 <- insert (JoinThree jb1 "j3 hello")
jc2 <- insert (JoinThree jb1 "j3 world")
_ <- insert (JoinThree jb2 "j3 foo")
_ <- insert (JoinThree jb3 "j3 bar")
_ <- insert (JoinThree jb3 "j3 baz")
_ <- insert (JoinFour "j4 foo" jc1)
_ <- insert (JoinFour "j4 bar" jc2)
jd1 <- insert (JoinOther "foo")
jd2 <- insert (JoinOther "bar")
_ <- insert (JoinMany "jm foo hello" jd1 ja1)
_ <- insert (JoinMany "jm foo world" jd1 ja2)
_ <- insert (JoinMany "jm bar hello" jd2 ja1)
_ <- insert (JoinMany "jm bar world" jd2 ja2)
pure ()
describe "identical results for" $ do
it "three tables" $ do
abcs <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
pure (a, b, c)
acbs <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c) -> do
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
pure (a, b, c)
listsEqualOn abcs acbs $ \(Entity _ j1, Entity _ j2, Entity _ j3) ->
(joinOneName j1, joinTwoName j2, joinThreeName j3)
it "four tables" $ do
xs0 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c `InnerJoin` d) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
pure (a, b, c, d)
xs1 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c `InnerJoin` d) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
pure (a, b, c, d)
xs2 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c `InnerJoin` d) -> do
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
pure (a, b, c, d)
xs3 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c `InnerJoin` d) -> do
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
pure (a, b, c, d)
xs4 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c `InnerJoin` d) -> do
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
pure (a, b, c, d)
let getNames (j1, j2, j3, j4) =
( joinOneName (entityVal j1)
, joinTwoName (entityVal j2)
, joinThreeName (entityVal j3)
, joinFourName (entityVal j4)
)
listsEqualOn xs0 xs1 getNames
listsEqualOn xs0 xs2 getNames
listsEqualOn xs0 xs3 getNames
listsEqualOn xs0 xs4 getNames
it "associativity of innerjoin" $ do
xs0 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c `InnerJoin` d) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
pure (a, b, c, d)
xs1 <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` (c `InnerJoin` d)) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
pure (a, b, c, d)
xs2 <- run $ do
setup
select $
from $ \(a `InnerJoin` (b `InnerJoin` c) `InnerJoin` d) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
pure (a, b, c, d)
xs3 <- run $ do
setup
select $
from $ \(a `InnerJoin` (b `InnerJoin` c `InnerJoin` d)) -> do
on (a ^. JoinOneId ==. b ^. JoinTwoJoinOne)
on (b ^. JoinTwoId ==. c ^. JoinThreeJoinTwo)
on (c ^. JoinThreeId ==. d ^. JoinFourJoinThree)
pure (a, b, c, d)
let getNames (j1, j2, j3, j4) =
( joinOneName (entityVal j1)
, joinTwoName (entityVal j2)
, joinThreeName (entityVal j3)
, joinFourName (entityVal j4)
)
listsEqualOn xs0 xs1 getNames
listsEqualOn xs0 xs2 getNames
listsEqualOn xs0 xs3 getNames
it "inner join on two entities" $ do
(xs0, xs1) <- run $ do
pid <- insert $ Person "hello" Nothing Nothing 3
_ <- insert $ BlogPost "good poast" pid
_ <- insert $ Profile "cool" pid
xs0 <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr) -> do
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
on $ p ^. PersonId ==. pr ^. ProfilePerson
pure (p, b, pr)
xs1 <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr) -> do
on $ p ^. PersonId ==. pr ^. ProfilePerson
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
pure (p, b, pr)
pure (xs0, xs1)
listsEqualOn xs0 xs1 $ \(Entity _ p, Entity _ b, Entity _ pr) ->
(personName p, blogPostTitle b, profileName pr)
it "inner join on three entities" $ do
res <- run $ do
pid <- insert $ Person "hello" Nothing Nothing 3
_ <- insert $ BlogPost "good poast" pid
_ <- insert $ BlogPost "good poast #2" pid
_ <- insert $ Profile "cool" pid
_ <- insert $ Reply pid "u wot m8"
_ <- insert $ Reply pid "how dare you"
bprr <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr `InnerJoin` r) -> do
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
on $ p ^. PersonId ==. pr ^. ProfilePerson
on $ p ^. PersonId ==. r ^. ReplyGuy
pure (p, b, pr, r)
brpr <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr `InnerJoin` r) -> do
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
on $ p ^. PersonId ==. r ^. ReplyGuy
on $ p ^. PersonId ==. pr ^. ProfilePerson
pure (p, b, pr, r)
prbr <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr `InnerJoin` r) -> do
on $ p ^. PersonId ==. pr ^. ProfilePerson
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
on $ p ^. PersonId ==. r ^. ReplyGuy
pure (p, b, pr, r)
prrb <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr `InnerJoin` r) -> do
on $ p ^. PersonId ==. pr ^. ProfilePerson
on $ p ^. PersonId ==. r ^. ReplyGuy
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
pure (p, b, pr, r)
rprb <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr `InnerJoin` r) -> do
on $ p ^. PersonId ==. r ^. ReplyGuy
on $ p ^. PersonId ==. pr ^. ProfilePerson
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
pure (p, b, pr, r)
rbpr <- selectRethrowingQuery $
from $ \(p `InnerJoin` b `InnerJoin` pr `InnerJoin` r) -> do
on $ p ^. PersonId ==. r ^. ReplyGuy
on $ p ^. PersonId ==. b ^. BlogPostAuthorId
on $ p ^. PersonId ==. pr ^. ProfilePerson
pure (p, b, pr, r)
pure [bprr, brpr, prbr, prrb, rprb, rbpr]
forM_ (zip res (drop 1 (cycle res))) $ \(a, b) -> a `shouldBe` b
it "many-to-many" $ do
ac <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c) -> do
on (a ^. JoinOneId ==. b ^. JoinManyJoinOne)
on (c ^. JoinOtherId ==. b ^. JoinManyJoinOther)
pure (a, c)
ca <- run $ do
setup
select $
from $ \(a `InnerJoin` b `InnerJoin` c) -> do
on (c ^. JoinOtherId ==. b ^. JoinManyJoinOther)
on (a ^. JoinOneId ==. b ^. JoinManyJoinOne)
pure (a, c)
listsEqualOn ac ca $ \(Entity _ a, Entity _ b) ->
(joinOneName a, joinOtherName b)
it "left joins on order" $ do
ca <- run $ do
setup
select $
from $ \(a `LeftOuterJoin` b `InnerJoin` c) -> do
on (c ?. JoinOtherId ==. b ?. JoinManyJoinOther)
on (just (a ^. JoinOneId) ==. b ?. JoinManyJoinOne)
orderBy [asc $ a ^. JoinOneId, asc $ c ?. JoinOtherId]
pure (a, c)
ac <- run $ do
setup
select $
from $ \(a `LeftOuterJoin` b `InnerJoin` c) -> do
on (just (a ^. JoinOneId) ==. b ?. JoinManyJoinOne)
on (c ?. JoinOtherId ==. b ?. JoinManyJoinOther)
orderBy [asc $ a ^. JoinOneId, asc $ c ?. JoinOtherId]
pure (a, c)
listsEqualOn ac ca $ \(Entity _ a, b) ->
(joinOneName a, maybe "NULL" (joinOtherName . entityVal) b)
it "doesn't require an on for a crossjoin" $ do
void $ run $
select $
from $ \(a `CrossJoin` b) -> do
pure (a :: SqlExpr (Entity JoinOne), b :: SqlExpr (Entity JoinTwo))
it "errors with an on for a crossjoin" $ do
(void $ run $
select $
from $ \(a `CrossJoin` b) -> do
on $ a ^. JoinOneId ==. b ^. JoinTwoJoinOne
pure (a, b))
`shouldThrow` \(OnClauseWithoutMatchingJoinException _) ->
True
it "left joins associativity" $ do
ca <- run $ do
setup
select $
from $ \(a `LeftOuterJoin` (b `InnerJoin` c)) -> do
on (c ?. JoinOtherId ==. b ?. JoinManyJoinOther)
on (just (a ^. JoinOneId) ==. b ?. JoinManyJoinOne)
orderBy [asc $ a ^. JoinOneId, asc $ c ?. JoinOtherId]
pure (a, c)
ca' <- run $ do
setup
select $
from $ \(a `LeftOuterJoin` b `InnerJoin` c) -> do
on (c ?. JoinOtherId ==. b ?. JoinManyJoinOther)
on (just (a ^. JoinOneId) ==. b ?. JoinManyJoinOne)
orderBy [asc $ a ^. JoinOneId, asc $ c ?. JoinOtherId]
pure (a, c)
listsEqualOn ca ca' $ \(Entity _ a, b) ->
(joinOneName a, maybe "NULL" (joinOtherName . entityVal) b)
it "composes queries still" $ do
let
query1 =
from $ \(foo `InnerJoin` bar) -> do
on (foo ^. FooId ==. bar ^. BarQuux)
pure (foo, bar)
query2 =
from $ \(p `LeftOuterJoin` bp) -> do
on (p ^. PersonId ==. bp ^. BlogPostAuthorId)
pure (p, bp)
(a, b) <- run $ do
fid <- insert $ Foo 5
_ <- insert $ Bar fid
pid <- insert $ Person "hey" Nothing Nothing 30
_ <- insert $ BlogPost "WHY" pid
a <- select ((,) <$> query1 <*> query2)
b <- select (flip (,) <$> query1 <*> query2)
pure (a, b)
listsEqualOn a (map (\(x, y) -> (y, x)) b) id
listsEqualOn :: (Show a1, Eq a1) => [a2] -> [a2] -> (a2 -> a1) -> Expectation
listsEqualOn a b f = map f a `shouldBe` map f b
tests :: Run -> Spec
tests run = do
@ -1503,6 +2002,7 @@ tests run = do
testCase run
testCountingRows run
testRenderSql run
testOnClauseOrder run
insert' :: ( Functor m
@ -1535,12 +2035,15 @@ cleanDB
:: (forall m. RunDbMonad m
=> SqlPersistT (R.ResourceT m) ())
cleanDB = do
delete $ from $ \(_ :: SqlExpr (Entity Foo)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Bar)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Foo)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity BlogPost)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Follow)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Person)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Reply)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Comment)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Profile)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity BlogPost)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Follow)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Person)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Deed)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Lord)) -> return ()
@ -1557,10 +2060,26 @@ cleanDB = do
delete $ from $ \(_ :: SqlExpr (Entity Point)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity Numbers)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity JoinMany)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity JoinFour)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity JoinThree)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity JoinTwo)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity JoinOne)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity JoinOther)) -> return ()
cleanUniques
:: (forall m. RunDbMonad m
=> SqlPersistT (R.ResourceT m) ())
cleanUniques =
delete $ from $ \(_ :: SqlExpr (Entity OneUnique)) -> return ()
delete $ from $ \(_ :: SqlExpr (Entity OneUnique)) -> return ()
selectRethrowingQuery
:: (MonadIO m, EI.SqlSelect a r, MonadUnliftIO m)
=> SqlQuery a
-> SqlPersistT m [r]
selectRethrowingQuery query =
select query
`catch` \(SomeException e) -> do
(text, _) <- renderQuerySelect query
liftIO . throwIO . userError $ Text.unpack text <> "\n\n" <> show e

View File

@ -958,10 +958,10 @@ testInsertUniqueViolation =
insert u3) `shouldThrow` (==) exception
where
exception = SqlError {
sqlState = "23505",
sqlExecStatus = FatalError,
sqlErrorMsg = "duplicate key value violates unique constraint \"UniqueValue\"",
sqlErrorDetail = "Key (value)=(0) already exists.",
sqlState = "23505",
sqlExecStatus = FatalError,
sqlErrorMsg = "duplicate key value violates unique constraint \"UniqueValue\"",
sqlErrorDetail = "Key (value)=(0) already exists.",
sqlErrorHint = ""}
testUpsert :: Spec