Skip to content

Commit e739a91

Browse files
Jnm/add cte materialized to postgres (#417)
* Materialized CTEs for Postgres * changelog update * fix tests --------- Co-authored-by: Joel McCracken <mccracken.joel@gmail.com>
1 parent 324de6a commit e739a91

File tree

7 files changed

+212
-13
lines changed

7 files changed

+212
-13
lines changed

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
- [#341](https://github.com/bitemyapp/esqueleto/pull/341/)
1212
- Add functions for `NULLS FIRST` and `NULLS LAST` in the Postgresql
1313
module
14+
- @JoelMcCracken
15+
- [#354](https://github.com/bitemyapp/esqueleto/pull/354), [#417](https://github.com/bitemyapp/esqueleto/pull/417)
16+
- Add `withMaterialized`, `withNotMaterialized` to the PostgreSQL module
1417

1518
3.5.13.2
1619
========

src/Database/Esqueleto/Experimental/From/CommonTableExpression.hs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import Database.Esqueleto.Internal.Internal hiding (From(..), from, on)
3838
-- PostgreSQL 12, non-recursive and side-effect-free queries may be inlined and
3939
-- optimized accordingly if not declared @MATERIALIZED@ to get the previous
4040
-- behaviour. See [the PostgreSQL CTE documentation](https://www.postgresql.org/docs/current/queries-with.html#id-1.5.6.12.7),
41-
-- section Materialization, for more information.
41+
-- section Materialization, for more information. To use a @MATERIALIZED@ query
42+
-- in Esquelto, see functions 'withMaterialized' and 'withRecursiveMaterialized'.
4243
--
4344
-- /Since: 3.4.0.0/
4445
with :: ( ToAlias a
@@ -50,7 +51,7 @@ with query = do
5051
aliasedValue <- toAlias ret
5152
let aliasedQuery = Q $ W.WriterT $ pure (aliasedValue, sideData)
5253
ident <- newIdentFor (DBName "cte")
53-
let clause = CommonTableExpressionClause NormalCommonTableExpression ident (\info -> toRawSql SELECT info aliasedQuery)
54+
let clause = CommonTableExpressionClause NormalCommonTableExpression (\_ _ -> "") ident (\info -> toRawSql SELECT info aliasedQuery)
5455
Q $ W.tell mempty{sdCteClause = [clause]}
5556
ref <- toAliasReference ident aliasedValue
5657
pure $ From $ do
@@ -107,7 +108,8 @@ withRecursive baseCase unionKind recursiveCase = do
107108
ref <- toAliasReference ident aliasedValue
108109
let refFrom = From (pure (ref, (\_ info -> (useIdent info ident, mempty))))
109110
let recursiveQuery = recursiveCase refFrom
110-
let clause = CommonTableExpressionClause RecursiveCommonTableExpression ident
111+
let noModifier _ _ = ""
112+
let clause = CommonTableExpressionClause RecursiveCommonTableExpression noModifier ident
111113
(\info -> (toRawSql SELECT info aliasedQuery)
112114
<> ("\n" <> (unUnionKind unionKind) <> "\n", mempty)
113115
<> (toRawSql SELECT info recursiveQuery)

src/Database/Esqueleto/Internal/Internal.hs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1998,8 +1998,10 @@ data CommonTableExpressionKind
19981998
| NormalCommonTableExpression
19991999
deriving Eq
20002000

2001-
data CommonTableExpressionClause =
2002-
CommonTableExpressionClause CommonTableExpressionKind Ident (IdentInfo -> (TLB.Builder, [PersistValue]))
2001+
type CommonTableExpressionModifierAfterAs = CommonTableExpressionClause -> IdentInfo -> TLB.Builder
2002+
2003+
data CommonTableExpressionClause
2004+
= CommonTableExpressionClause CommonTableExpressionKind CommonTableExpressionModifierAfterAs Ident (IdentInfo -> (TLB.Builder, [PersistValue]))
20032005

20042006
data SubQueryType
20052007
= NormalSubQuery
@@ -3212,14 +3214,15 @@ makeCte info cteClauses =
32123214
| hasRecursive = "WITH RECURSIVE "
32133215
| otherwise = "WITH "
32143216
where
3217+
32153218
hasRecursive =
32163219
elem RecursiveCommonTableExpression
3217-
$ fmap (\(CommonTableExpressionClause cteKind _ _) -> cteKind)
3220+
$ fmap (\(CommonTableExpressionClause cteKind _ _ _) -> cteKind)
32183221
$ cteClauses
32193222

3220-
cteClauseToText (CommonTableExpressionClause _ cteIdent cteFn) =
3223+
cteClauseToText clause@(CommonTableExpressionClause _ cteModifier cteIdent cteFn) =
32213224
first
3222-
(\tlb -> useIdent info cteIdent <> " AS " <> parens tlb)
3225+
(\tlb -> useIdent info cteIdent <> " AS " <> cteModifier clause info <> parens tlb)
32233226
(cteFn info)
32243227

32253228
cteBody =

src/Database/Esqueleto/PostgreSQL.hs

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ module Database.Esqueleto.PostgreSQL
3636
, forKeyShareOf
3737
, filterWhere
3838
, values
39+
, withMaterialized
40+
, withNotMaterialized
3941
, ascNullsFirst
4042
, ascNullsLast
4143
, descNullsFirst
@@ -52,15 +54,22 @@ import Control.Exception (throw)
5254
import Control.Monad (void)
5355
import Control.Monad.IO.Class (MonadIO(..))
5456
import qualified Control.Monad.Trans.Reader as R
57+
import qualified Control.Monad.Trans.Writer as W
5558
import Data.Int (Int64)
5659
import qualified Data.List.NonEmpty as NE
5760
import Data.Maybe
5861
import Data.Proxy (Proxy(..))
5962
import qualified Data.Text.Internal.Builder as TLB
6063
import qualified Data.Text.Lazy as TL
64+
import qualified Data.Text.Lazy.Builder as TLB
6165
import Data.Time.Clock (UTCTime)
6266
import qualified Database.Esqueleto.Experimental as Ex
63-
import Database.Esqueleto.Internal.Internal hiding (random_)
67+
import qualified Database.Esqueleto.Experimental.From as Ex
68+
import Database.Esqueleto.Experimental.From.CommonTableExpression
69+
import Database.Esqueleto.Experimental.From.SqlSetOperation
70+
import Database.Esqueleto.Experimental.ToAlias
71+
import Database.Esqueleto.Experimental.ToAliasReference
72+
import Database.Esqueleto.Internal.Internal hiding (From(..), from, on, random_)
6473
import Database.Esqueleto.Internal.PersistentImport hiding
6574
(uniqueFields, upsert, upsertBy)
6675
import Database.Persist.SqlBackend
@@ -490,7 +499,7 @@ forNoKeyUpdateOf lockableEntities onLockedBehavior =
490499
forShareOf :: LockableEntity a => a -> OnLockedBehavior -> SqlQuery ()
491500
forShareOf lockableEntities onLockedBehavior =
492501
putLocking $ PostgresLockingClauses [PostgresLockingKind PostgresForShare (Just $ LockingOfClause lockableEntities) onLockedBehavior]
493-
502+
494503
-- | `FOR KEY SHARE OF` syntax for postgres locking
495504
-- allows locking of specific tables with a key share lock in a view or join
496505
--
@@ -499,6 +508,82 @@ forKeyShareOf :: LockableEntity a => a -> OnLockedBehavior -> SqlQuery ()
499508
forKeyShareOf lockableEntities onLockedBehavior =
500509
putLocking $ PostgresLockingClauses [PostgresLockingKind PostgresForKeyShare (Just $ LockingOfClause lockableEntities) onLockedBehavior]
501510

511+
-- | @WITH@ @MATERIALIZED@ clause is used to introduce a
512+
-- [Common Table Expression (CTE)](https://en.wikipedia.org/wiki/Hierarchical_and_recursive_queries_in_SQL#Common_table_expression)
513+
-- with the MATERIALIZED keyword. The MATERIALIZED keyword is only supported in PostgreSQL >= version 12.
514+
-- In Esqueleto, CTEs should be used as a subquery memoization tactic. PostgreSQL treats a materialized CTE as an optimization fence.
515+
-- A materialized CTE is always fully calculated, and is not "inlined" with other table joins.
516+
-- Without the MATERIALIZED keyword, PostgreSQL >= 12 may "inline" the CTE as though it was any other join.
517+
-- You should always verify that using a materialized CTE will in fact improve your performance
518+
-- over a regular subquery.
519+
--
520+
-- @
521+
-- select $ do
522+
-- cte <- withMaterialized subQuery
523+
-- cteResult <- from cte
524+
-- where_ $ cteResult ...
525+
-- pure cteResult
526+
-- @
527+
--
528+
--
529+
-- For more information on materialized CTEs, see the PostgreSQL manual documentation on
530+
-- [Common Table Expression Materialization](https://www.postgresql.org/docs/14/queries-with.html#id-1.5.6.12.7).
531+
--
532+
-- @since 3.5.14.0
533+
withMaterialized :: ( ToAlias a
534+
, ToAliasReference a
535+
, SqlSelect a r
536+
) => SqlQuery a -> SqlQuery (Ex.From a)
537+
withMaterialized query = do
538+
(ret, sideData) <- Q $ W.censor (\_ -> mempty) $ W.listen $ unQ query
539+
aliasedValue <- toAlias ret
540+
let aliasedQuery = Q $ W.WriterT $ pure (aliasedValue, sideData)
541+
ident <- newIdentFor (DBName "cte")
542+
let clause = CommonTableExpressionClause NormalCommonTableExpression (\_ _ -> "MATERIALIZED ") ident (\info -> toRawSql SELECT info aliasedQuery)
543+
Q $ W.tell mempty{sdCteClause = [clause]}
544+
ref <- toAliasReference ident aliasedValue
545+
pure $ Ex.From $ pure (ref, (\_ info -> (useIdent info ident, mempty)))
546+
547+
-- | @WITH@ @NOT@ @MATERIALIZED@ clause is used to introduce a
548+
-- [Common Table Expression (CTE)](https://en.wikipedia.org/wiki/Hierarchical_and_recursive_queries_in_SQL#Common_table_expression)
549+
-- with the NOT MATERIALIZED keywords. These are only supported in PostgreSQL >=
550+
-- version 12. In Esqueleto, CTEs should be used as a subquery memoization
551+
-- tactic. PostgreSQL treats a materialized CTE as an optimization fence. A
552+
-- MATERIALIZED CTE is always fully calculated, and is not "inlined" with other
553+
-- table joins. Sometimes, this is undesirable, so postgres provides the NOT
554+
-- MATERIALIZED modifier to prevent this behavior, thus enabling it to possibly
555+
-- decide to treat the CTE as any other join.
556+
--
557+
-- Given the above, it is unlikely that this function will be useful, as a
558+
-- normal join should be used instead, but is provided for completeness.
559+
--
560+
-- @
561+
-- select $ do
562+
-- cte <- withNotMaterialized subQuery
563+
-- cteResult <- from cte
564+
-- where_ $ cteResult ...
565+
-- pure cteResult
566+
-- @
567+
--
568+
--
569+
-- For more information on materialized CTEs, see the PostgreSQL manual documentation on
570+
-- [Common Table Expression Materialization](https://www.postgresql.org/docs/14/queries-with.html#id-1.5.6.12.7).
571+
--
572+
-- @since 3.5.14.0
573+
withNotMaterialized :: ( ToAlias a
574+
, ToAliasReference a
575+
, SqlSelect a r
576+
) => SqlQuery a -> SqlQuery (Ex.From a)
577+
withNotMaterialized query = do
578+
(ret, sideData) <- Q $ W.censor (\_ -> mempty) $ W.listen $ unQ query
579+
aliasedValue <- toAlias ret
580+
let aliasedQuery = Q $ W.WriterT $ pure (aliasedValue, sideData)
581+
ident <- newIdentFor (DBName "cte")
582+
let clause = CommonTableExpressionClause NormalCommonTableExpression (\_ _ -> "NOT MATERIALIZED ") ident (\info -> toRawSql SELECT info aliasedQuery)
583+
Q $ W.tell mempty{sdCteClause = [clause]}
584+
ref <- toAliasReference ident aliasedValue
585+
pure $ Ex.From $ pure (ref, (\_ info -> (useIdent info ident, mempty)))
586+
502587
-- | Ascending order of this field or SqlExpression with nulls coming first.
503588
--
504589
-- @since 3.5.14.0

test/MySQL/Test.hs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@ import Database.Persist.MySQL
2323
, connectPassword
2424
, connectPort
2525
, connectUser
26+
, createMySQLPool
2627
, defaultConnectInfo
2728
, withMySQLConn
28-
, createMySQLPool
2929
)
3030

3131
import Test.Hspec
3232

3333
import Common.Test
34+
import Data.Maybe (fromMaybe)
35+
import System.Environment (lookupEnv)
3436

3537
testMysqlSum :: SpecDb
3638
testMysqlSum = do
@@ -189,6 +191,7 @@ migrateIt = do
189191
mkConnectionPool :: IO ConnectionPool
190192
mkConnectionPool = do
191193
ci <- isCI
194+
mysqlHost <- (fromMaybe "localhost" <$> lookupEnv "MYSQL_HOST")
192195
let connInfo
193196
| ci =
194197
defaultConnectInfo
@@ -200,7 +203,7 @@ mkConnectionPool = do
200203
}
201204
| otherwise =
202205
defaultConnectInfo
203-
{ connectHost = "localhost"
206+
{ connectHost = mysqlHost
204207
, connectUser = "travis"
205208
, connectPassword = "esqutest"
206209
, connectDatabase = "esqutest"

test/PostgreSQL/Test.hs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import Database.Esqueleto hiding (random_)
4040
import Database.Esqueleto.Experimental hiding (from, on, random_)
4141
import qualified Database.Esqueleto.Experimental as Experimental
4242
import qualified Database.Esqueleto.Internal.Internal as ES
43-
import Database.Esqueleto.PostgreSQL (random_)
43+
import Database.Esqueleto.PostgreSQL
44+
(random_, withMaterialized, withNotMaterialized)
4445
import qualified Database.Esqueleto.PostgreSQL as EP
4546
import Database.Esqueleto.PostgreSQL.JSON hiding ((-.), (?.), (||.))
4647
import qualified Database.Esqueleto.PostgreSQL.JSON as JSON
@@ -1232,6 +1233,80 @@ testCommonTableExpressions = do
12321233
pure res
12331234
asserting $ vals `shouldBe` fmap Value [2..11]
12341235

1236+
describe "MATERIALIZED CTEs" $ do
1237+
describe "withNotMaterialized" $ do
1238+
itDb "successfully executes query" $ do
1239+
void $ select $ do
1240+
limitedLordsCte <-
1241+
withNotMaterialized $ do
1242+
lords <- Experimental.from $ Experimental.table @Lord
1243+
limit 10
1244+
pure lords
1245+
lords <- Experimental.from limitedLordsCte
1246+
orderBy [asc $ lords ^. LordId]
1247+
pure lords
1248+
1249+
asserting noExceptions
1250+
1251+
itDb "generates the expected SQL" $ do
1252+
(sql, _) <- showQuery ES.SELECT $ do
1253+
limitedLordsCte <-
1254+
withNotMaterialized $ do
1255+
lords <- Experimental.from $ Experimental.table @Lord
1256+
limit 10
1257+
pure lords
1258+
lords <- Experimental.from limitedLordsCte
1259+
orderBy [asc $ lords ^. LordId]
1260+
pure lords
1261+
1262+
asserting $ sql `shouldBe` T.unlines
1263+
[ "WITH \"cte\" AS NOT MATERIALIZED (SELECT \"Lord\".\"county\" AS \"v_county\", \"Lord\".\"dogs\" AS \"v_dogs\""
1264+
, "FROM \"Lord\""
1265+
, " LIMIT 10"
1266+
, ")"
1267+
, "SELECT \"cte\".\"v_county\", \"cte\".\"v_dogs\""
1268+
, "FROM \"cte\""
1269+
, "ORDER BY \"cte\".\"v_county\" ASC"
1270+
]
1271+
asserting noExceptions
1272+
1273+
1274+
describe "withMaterialized" $ do
1275+
itDb "generates the expected SQL" $ do
1276+
(sql, _) <- showQuery ES.SELECT $ do
1277+
limitedLordsCte <-
1278+
withMaterialized $ do
1279+
lords <- Experimental.from $ Experimental.table @Lord
1280+
limit 10
1281+
pure lords
1282+
lords <- Experimental.from limitedLordsCte
1283+
orderBy [asc $ lords ^. LordId]
1284+
pure lords
1285+
1286+
asserting $ sql `shouldBe` T.unlines
1287+
[ "WITH \"cte\" AS MATERIALIZED (SELECT \"Lord\".\"county\" AS \"v_county\", \"Lord\".\"dogs\" AS \"v_dogs\""
1288+
, "FROM \"Lord\""
1289+
, " LIMIT 10"
1290+
, ")"
1291+
, "SELECT \"cte\".\"v_county\", \"cte\".\"v_dogs\""
1292+
, "FROM \"cte\""
1293+
, "ORDER BY \"cte\".\"v_county\" ASC"
1294+
]
1295+
asserting noExceptions
1296+
1297+
itDb "successfully executes query" $ do
1298+
void $ select $ do
1299+
limitedLordsCte <-
1300+
withMaterialized $ do
1301+
lords <- Experimental.from $ Experimental.table @Lord
1302+
limit 10
1303+
pure lords
1304+
lords <- Experimental.from limitedLordsCte
1305+
orderBy [asc $ lords ^. LordId]
1306+
pure lords
1307+
1308+
asserting noExceptions
1309+
12351310
testPostgresqlLocking :: SpecDb
12361311
testPostgresqlLocking = do
12371312
describe "Monoid instance" $ do

test/docker-compose.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# docker-compose file for running postgres and mysql DBMS
2+
3+
# If using this to run the tests,
4+
# while these containers are running (i.e. after something like)
5+
# (cd test; docker-compose up -d)
6+
# the tests must be told to use the hostname via MYSQL_HOST environment variable
7+
# e.g. something like:
8+
# MYSQL_HOST=127.0.0.1 stack test
9+
10+
version: '3'
11+
services:
12+
postgres:
13+
image: 'postgres:15.2-alpine'
14+
environment:
15+
POSTGRES_USER: esqutest
16+
POSTGRES_PASSWORD: esqutest
17+
POSTGRES_DB: esqutest
18+
ports:
19+
- 5432:5432
20+
mysql:
21+
image: 'mysql:8.0.32'
22+
environment:
23+
MYSQL_USER: travis
24+
MYSQL_PASSWORD: esqutest
25+
MYSQL_ROOT_PASSWORD: esqutest
26+
MYSQL_DATABASE: esqutest
27+
ports:
28+
- 3306:3306

0 commit comments

Comments
 (0)