pg-schema-0.5.0.0: src/PgSchema/DML/Select.hs
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE DerivingVia #-}
module PgSchema.DML.Select where
import Control.Monad.RWS
import Control.Monad
import Data.Bifunctor
import Data.Foldable as F
import Data.Functor
import Data.List as L
import Data.List.NonEmpty as NE
import Data.Maybe
import Data.Monoid
import Data.Singletons
import Data.String
import Data.Text as T
import Data.Tuple
import PgSchema.DML.Select.Types
import PgSchema.Ann
import Database.PostgreSQL.Simple hiding(In(..))
import Database.PostgreSQL.Simple.Types(PGArray(..))
import PgSchema.Schema
import GHC.Generics
import GHC.TypeLits
import PgSchema.Types
import PgSchema.Utils.Internal
import PgSchema.Utils.TF (Snd)
import Prelude as P
data QueryRead sch t = QueryRead
{ qrCurrTabNum :: Int
, qrPath :: [Text]
, qrParam :: QueryParam sch t }
data ParentInfo = ParentInfo
{ piRelDbName :: Text
, piFromNum :: Int
, piToNum :: Int
, piParentTab :: NameNS
, piRefs :: [Ref' Text]
, piPath :: [Text] }
deriving Show
data QueryState = QueryState
{ qsLastTabNum :: Int
, qsParents :: [ParentInfo] }
deriving Show
type MonadQuery sch t m = (MonadRWS (QueryRead sch t) [SomeToField] QueryState m)
type Selectable ann r = (CRecInfo ann r, FromRow (PgTag ann r))
-- | Run a single @SELECT@ for root table @tab@ (see annotation @ann@ with schema
-- @sch@ and 'Renamer' @ren@) and decode rows into @[r]@, also returning the SQL
-- text and bind parameters (for tracing or debugging).
--
-- The desired result type @r@ /fixes the shape/ of each row: typically a record
-- with columns of the root table and nested Haskell values for relations along the
-- schema graph. There may be several /child/-side fields (often lists of nested
-- records) and several /parent/-side fields (nested records; wrapped in 'Maybe'
-- when the foreign-key side you join from allows NULL).
--
-- What actually appears in the generated SQL beyond that shape is controlled by
-- the 'QueryParam': filters, which paths are traversed, ordering on a branch
-- (e.g. child rows sorted by their position number), limits, and similar options.
-- You only configure in 'QueryParam' what you need for this query—not every
-- possible relation field in @r@.
--
-- Build 'QueryParam' with the 'MonadQP' API.
--
selectSch :: forall ann -> forall r. (Selectable ann r, ann ~ 'Ann ren sch d tab)
=> Connection -> QueryParam sch tab -> IO ([r], (Text,[SomeToField]))
selectSch ann @r conn (selectText ann @r -> (sql,fs)) =
trace' ("\n\n" <> T.unpack sql <> "\n\n" <> P.show fs <> "\n\n")
$ (,(sql,fs)) . fmap (unPgTag @ann @r) <$> query conn (fromString $ T.unpack sql) fs
-- | Return the generated @SELECT@ SQL text (and bind parameters), e.g. for debugging.
selectText :: forall ann -> forall r. (CRecInfo ann r, ann ~ 'Ann ren sch d tab)
=> QueryParam sch tab -> (Text,[SomeToField])
selectText ann @r qp = evalRWS (selectM "" (getRecordInfo @ann @r)) (qr0 qp) qs0
qr0 :: QueryParam sch tab -> QueryRead sch tab
qr0 qrParam = QueryRead
{ qrCurrTabNum = 0 , qrPath = [] , qrParam }
qs0 :: QueryState
qs0 = QueryState { qsLastTabNum = 0, qsParents = [] }
two :: (a,b,c) -> (a,b)
two (a,b,_) = (a,b)
third :: (a,b,c) -> c
third (_,_,c) = c
jsonPairing :: [(Text, Text)] -> Text
jsonPairing fs = "jsonb_build_object(" <> T.intercalate "," pairs <> ")"
where
pairs = mapMaybe (\(a,b) -> Just $ "'" <> b <> "'," <> a) fs
newtype TextI (s::Symbol) = TextI { unTextI :: Text}
instance KnownSymbol s => Semigroup (TextI s) where
TextI a <> TextI b =
TextI $ T.intercalate (demote @s) $ L.filter (not . T.null) [a,b]
instance KnownSymbol s => Monoid (TextI s) where mempty = TextI mempty
selectM :: MonadQuery sch t m => Text -> RecordInfo Text -> m Text
selectM refTxt ri = do
QueryRead {..} <- ask
(fmap two -> flds) <- traverse fieldM ri.fields
-- qsParents are collected "in depth" (it is ok for joins etc) but in reverse order
parents <- gets
$ fmap (\p -> p { piPath = L.reverse p.piPath}) . L.reverse . qsParents
let
basePath = L.reverse qrPath
(unTextI -> condText, condPars) = F.fold $ L.reverse
$ mapMaybe (\(CondWithPath @path cond) -> let p = demote @path in
if
| not (basePath `L.isPrefixOf` p) -> Nothing
| p == basePath -> Just $ first TextI $ pgCond qrCurrTabNum cond
| otherwise -> L.find ((p ==) . (basePath <>) . (.piPath)) parents
<&> \pari -> first (TextI @" and ") $ pgCond pari.piToNum cond
) qrParam.qpConds
(unTextI -> ordText, ordPars) = F.fold $ L.reverse
$ mapMaybe (\(OrdWithPath @path ord) -> let p = demote @path in
if
| not (basePath `L.isPrefixOf` p) -> Nothing
| p == basePath -> Just $ pgOrd qrCurrTabNum ord
| otherwise -> L.find ((p ==) . (basePath <>) . (.piPath)) parents
<&> \pari -> pgOrd pari.piToNum ord
) qrParam.qpOrds
(distTexts, distPars) = F.fold $ L.reverse
$ mapMaybe (\(DistWithPath @path dist) -> let p = demote @path in
if
| not (basePath `L.isPrefixOf` p) -> Nothing
| p == basePath -> Just $ pgDist qrCurrTabNum dist
| otherwise -> L.find ((p ==) . (basePath <>) . (.piPath)) parents
<&> \pari -> pgDist pari.piToNum dist
) qrParam.qpDistinct
sel
| L.null basePath =
T.intercalate "," $ L.map (\(a,b) -> a <> " \"" <> b <> "\"") flds
| otherwise = jsonPairing flds
whereText = let conds = L.filter (not . T.null) [refTxt, condText] in
if L.null conds then mempty else " where " <> T.intercalate " and " conds
orderText
| T.null ordText && T.null distTexts.orderBy.unTextI = ""
| otherwise = " order by " <> T.intercalate ","
(L.filter (not . T.null) [distTexts.orderBy.unTextI, ordText])
distinctText
| distTexts.distinct.getAny && T.null distTexts.distinctOn.unTextI = " distinct "
| T.null distTexts.distinctOn.unTextI = ""
| otherwise = " distinct on (" <> distTexts.distinctOn.unTextI <> ") "
qsLimOff = loByPath (L.reverse qrPath) $ qpLOs qrParam
groupByText
| P.null aggrs || P.null others = ""
| otherwise = " group by " <> T.intercalate ","
(L.nub $ others >>= \fi -> case fi.fieldKind of
RFPlain{} -> [fi.fieldDbName]
RFToHere _ refs -> refs <&> \ref -> ref.toName
RFFromHere _ refs -> refs <&> \ref -> ref.fromName
_ -> [])
where
(aggrs, others) = L.partition
(\fld -> case fld.fieldKind of { RFAggr{} -> True; _ -> False })
ri.fields
tell $ distPars <> condPars <> distPars <> ordPars
pure $ "select "
<> distinctText
<> sel
<> " from " <> qualName ri.tabName <> " t" <> show' qrCurrTabNum
<> " " <> T.unwords (joinText <$> trace' (show' parents) parents)
<> whereText
<> groupByText
<> orderText
<> qsLimOff
-- | SQL text for the column expression, result alias, and emptiness-test expression
-- (non-obvious for nested relation fields).
fieldM :: MonadQuery sch tab m => FieldInfo Text -> m (Text, Text, Text)
fieldM fi = case fi.fieldKind of
RFEmpty s -> pure ("null", s, "true")
RFSelfRef{} -> error "Impossible: RFSelfRef should be changed to RFFromHere or RFToHere"
RFPlain {} -> do
n <- asks qrCurrTabNum
let val = "t" <> show' n <> "." <> fi.fieldDbName
pure (val, fi.fieldName, val <> " is null")
RFAggr _ (T.drop 1 . T.toLower . T.show -> fname) _ -> do
n <- asks qrCurrTabNum
let val = fname <> "(" <> "t" <> show' n <> "." <> fi.fieldDbName <> ")"
pure case fname of
"count" -> ("count(*)", fi.fieldName, " false")
_ -> (val, fi.fieldName, val <> " is null")
RFFromHere ri refs -> do
QueryRead {..} <- ask
modify \QueryState{qsLastTabNum = (+1) -> n2, qsParents} -> QueryState
{ qsLastTabNum = n2
-- , qsJoins = joinText qrCurrTabNum n2 : qsJoins
, qsParents = ParentInfo
{ piRelDbName = fi.fieldDbName
, piFromNum = qrCurrTabNum
, piToNum = n2
, piParentTab = ri.tabName
, piRefs = refs
, piPath = fi.fieldDbName : qrPath } : qsParents }
n2 <- gets qsLastTabNum
(flds, pars) <- listen $ local
(\qr -> qr{ qrCurrTabNum = n2, qrPath = fi.fieldDbName : qrPath })
$ traverse fieldM ri.fields
val <- if L.any (fdNullable . fromDef) refs
then do
tell pars
pure $ "case when " <> T.intercalate " and " (third <$> flds)
<> " then null else " <> jsonPairing (two <$> flds) <> " end"
else pure $ jsonPairing $ two <$> flds
pure (val, fi.fieldName, val <> " is null")
RFToHere ri refs -> do
QueryRead{..} <- ask
QueryState {qsLastTabNum = (+1) -> tabNum, qsParents} <- get
modify (const $ QueryState tabNum [])
selText <- local
(\qr -> qr { qrCurrTabNum = tabNum, qrPath = fi.fieldDbName : qrPath })
$ selectM (refCond tabNum qrCurrTabNum refs) ri
modify (\qs -> qs { qsParents = qsParents })
let
val = "array(" <> selText <> ")"
pure ("array_to_json(" <> val <> ")", fi.fieldName, val <> " = '{}'")
joinText :: ParentInfo -> Text
joinText ParentInfo{..} =
outer <> "join " <> qualName piParentTab <> " t" <> show' piToNum
<> " on " <> refCond piFromNum piToNum piRefs
where
outer
| hasNullableRefs piRefs = "left outer "
| otherwise = ""
refCond :: Int -> Int -> [Ref] -> Text
refCond nFrom nTo = T.intercalate " and " . fmap compFlds
where
compFlds Ref {fromName, toName} =
fldt nFrom fromName <> "=" <> fldt nTo toName
where
fldt n = (("t" <> show' n <> ".") <>)
withLOWithPath
:: forall sch t r. (LO -> r) -> [Text] -> LimOffWithPath sch t -> Maybe r
withLOWithPath f p (LimOffWithPath @p lo) =
guard (p == demote @p) >> pure (f lo)
withLOsWithPath
:: forall sch t r. (LO -> r) -> [Text] -> [LimOffWithPath sch t] -> Maybe r
withLOsWithPath f p = join . L.find isJust . L.map (withLOWithPath f p)
lowp :: forall sch t. forall (path::[Symbol]) ->
(ToStar path, TabPath sch t path
, Snd (TabOnPath2 sch t path) ~ RelMany) => LO -> LimOffWithPath sch t
lowp p = LimOffWithPath @p
rootLO :: forall sch t. LO -> LimOffWithPath sch t
rootLO = lowp []
convLO :: LO -> Text
convLO (LO ml mo) =
maybe "" ((" limit " <>) . show') ml
<> maybe "" ((" offset " <>) . show') mo
loByPath :: forall sch t. [Text] -> [LimOffWithPath sch t] -> Text
loByPath p = fromMaybe mempty . withLOsWithPath convLO p
runCond :: Int -> CondMonad a -> (a,[SomeToField])
runCond n x = evalRWS x ("q", pure n) 0
tabPref :: CondMonad Text
tabPref = asks \case
(_, n :| []) -> "t" <> show' n
(p, n :| (np : _)) -> "t" <> show' np <> p <> show' n
qual :: forall (fld :: Symbol). ToStar fld => CondMonad Text
qual = tabPref <&> (<> "." <> (demote @fld))
--
convCond :: forall sch t . Cond sch t -> CondMonad Text
convCond = \case
EmptyCond -> pure mempty
Cmp @n cmp v -> do
tell [SomeToField v]
qual @n <&> (<> " " <> showCmp cmp <> " ?")
In @n (NE.toList -> vs) -> do
tell [SomeToField $ PGArray vs]
qual @n <&> (<> " = any(?::" <> qualName (getFldDef @sch @t @n).fdType <> "[])")
InArr @n vs -> do
tell [SomeToField $ PGArray vs]
qual @n <&> (<> " = any(?::" <> qualName (getFldDef @sch @t @n).fdType <> "[])")
Null @n -> qual @n <&> (<> " is null")
Not c -> getNot <$> convCond c
BoolOp bo c1 c2 -> getBoolOp bo <$> convCond c1 <*> convCond c2
Child @_ @ref tabParam cond ->
getRef @(RdFrom (TRelDef sch ref)) True (demote @(TRelDef sch ref)).rdCols
tabParam cond
Parent @_ @ref cond ->
getRef @(RdTo (TRelDef sch ref)) False (demote @(TRelDef sch ref)).rdCols
defTabParam cond
UnsafeCond m -> m
where
getNot c
| c == mempty = mempty
| otherwise = "not (" <> c <> ")"
getBoolOp bo cc1 cc2
| cc1 == mempty = cc2 -- so EmptyCond works both with &&& and |||
| cc2 == mempty = cc1
| otherwise = case bo of
And -> cc1 <> " and " <> cc2
Or -> "(" <> cc1 <> " or " <> cc2 <> ")"
getRef
:: forall tab. CTabDef sch tab
=> Bool -> [(Text, Text)] -> TabParam sch tab -> Cond sch tab
-> CondMonad Text
getRef isChild cols tabParam cond = do
tpp <- tabPref
modify (+1)
cnum <- get
(tpc, condInt, TextI ordInt, condExt) <- local (second (cnum <|))
$ (,,,) <$> tabPref <*> convCond tabParam.cond
<*> convOrd tabParam.order <*> convCond cond
pure $ mkExists tpp tpc condInt ordInt condExt
where
mkExists tpp tpc cin oint cout
= "exists (select 1 from (select * from " <> tn <> " " <> tpc
<> " where "
<> T.intercalate " and " (
(\(ch,pr) -> tpc <> "." <> ch <> " = "
<> tpp <> "." <> pr)
. (if isChild then id else swap)
<$> cols)
<> (if T.null cin then "" else " and " <> cin)
<> case tabParam of
TabParam EmptyCond [] (LO Nothing Nothing) -> ""
TabParam _ _ (convLO -> loTxt) ->
(if T.null oint then "" else " order by " <> oint) <> loTxt
<> ") " <> tpc
<> (if T.null cout then "" else " where " <> cout)
<> ")"
where
tn = qualName $ demote @tab
pgCond :: forall sch t . Int -> Cond sch t -> (Text, [SomeToField])
pgCond n cond = evalRWS (convCond cond) ("q", pure n) 0
pgOrd :: forall sch t. Int -> [OrdFld sch t] -> (TextI ",", [SomeToField])
pgOrd n ord = evalRWS (convOrd ord) ("o", pure n) 0
pgDist :: forall sch t. Int -> Dist sch t -> (DistTexts, [SomeToField])
pgDist n dist = evalRWS (convDist dist) ("o", pure n) 0
withCondWithPath :: forall sch t r. (forall t'. Cond sch t' -> r) ->
[Text] -> CondWithPath sch t -> Maybe r
withCondWithPath f p (CondWithPath @p' cond) = f cond <$ guard (p == demote @p')
withCondsWithPath :: forall sch t r. (forall t'. Cond sch t' -> r) ->
[Text] -> [CondWithPath sch t] -> Maybe r
withCondsWithPath f p = join . L.find isJust . L.map (withCondWithPath f p)
cwp :: forall path -> forall sch t t1.
(t1 ~ TabOnPath sch t path, ToStar path) => Cond sch t1 -> CondWithPath sch t
cwp p = CondWithPath @p
rootCond :: Cond sch t -> CondWithPath sch t
rootCond = cwp []
condByPath :: Int -> [Text] -> [CondWithPath sch t] -> (Text, [SomeToField])
condByPath num p = F.fold . withCondsWithPath (pgCond num) p
ordByPath :: Int -> [Text] -> [OrdWithPath sch t] -> (TextI ",", [SomeToField])
ordByPath num p = F.fold . withOrdsWithPath (pgOrd num) p
distByPath :: Int -> [Text] -> [DistWithPath sch t] -> (DistTexts, [SomeToField])
distByPath num p = F.fold . withDistsWithPath (pgDist num) p
withOrdWithPath :: forall sch t r. (forall t'. [OrdFld sch t'] -> r) ->
[Text] -> OrdWithPath sch t -> Maybe r
withOrdWithPath f p (OrdWithPath @p ord) = f ord <$ guard (p == demote @p)
withDistWithPath :: forall sch t r. (forall t'. Dist sch t' -> r) ->
[Text] -> DistWithPath sch t -> Maybe r
withDistWithPath f p (DistWithPath @p dist) = f dist <$ guard (p == demote @p)
--
withOrdsWithPath :: forall sch t r . (forall t'. [OrdFld sch t'] -> r) ->
[Text] -> [OrdWithPath sch t] -> Maybe r
withOrdsWithPath f p = join . L.find isJust . L.map (withOrdWithPath f p)
withDistsWithPath :: forall sch t r . (forall t'. Dist sch t' -> r) ->
[Text] -> [DistWithPath sch t] -> Maybe r
withDistsWithPath f p = join . L.find isJust . L.map (withDistWithPath f p)
owp :: forall path -> forall sch t t'.
(ToStar path, TabOnPath sch t path ~ t') =>
[OrdFld sch t'] -> OrdWithPath sch t
owp p = OrdWithPath @p
rootOrd :: forall sch t. [OrdFld sch t] -> OrdWithPath sch t
rootOrd = owp []
dwp :: forall path -> forall sch t t'.
(ToStar path, TabOnPath2 sch t path ~ '(t', 'RelMany)) =>
Dist sch t' -> DistWithPath sch t
dwp p = DistWithPath @p
rootDist :: forall sch t. Dist sch t -> DistWithPath sch t
rootDist = dwp []
convPreOrd :: forall sch tab. [OrdFld sch tab] -> CondMonad [(Text, OrdDirection)]
convPreOrd = traverse processFld
where
processFld = \case
OrdFld @fld od -> (, od) <$> qual @fld
UnsafeOrd m -> m
renderOrd :: [(Text, OrdDirection)] -> TextI ","
renderOrd = foldMap (TextI . render)
where
render (t, show' -> od) = t <> " " <> od <> " nulls last"
convOrd :: forall sch tab. [OrdFld sch tab] -> CondMonad (TextI ",")
convOrd = fmap renderOrd . convPreOrd
data DistTexts = DistTexts
{ distinct :: Any
, distinctOn :: TextI ","
, orderBy :: TextI "," }
deriving Generic
deriving (Semigroup, Monoid) via (Generically DistTexts)
convDist :: forall sch tab. Dist sch tab -> CondMonad DistTexts
convDist = \case
Distinct -> pure $ mempty { distinct = Any True }
DistinctOn ofs -> convPreOrd ofs <&> \xs -> mempty
{ distinctOn = foldMap (TextI . fst) xs
, orderBy = renderOrd xs }