orville-postgresql-1.1.0.0: src/Orville/PostgreSQL/Plan/Operation.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- |
Copyright : Flipstone Technology Partners 2023-2025
License : MIT
Stability : Stable
@since 1.0.0.0
-}
module Orville.PostgreSQL.Plan.Operation
( Operation (..)
, AssertionFailed
, mkAssertionFailed
, WherePlanner (..)
, byField
, byFieldTuple
, byMarshaller
, findOne
, findOneWhere
, findAll
, findAllWhere
, findSelect
, askParam
, assertRight
, SelectOperation (..)
, selectOperation
)
where
import Control.Exception (Exception)
import qualified Data.ByteString.Char8 as BS8
import qualified Data.Foldable as Fold
import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.Map.Strict as Map
import qualified Data.Maybe as Maybe
import qualified Data.Sequence as Seq
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Orville.PostgreSQL.Execution as Exec
import qualified Orville.PostgreSQL.Expr as Expr
import qualified Orville.PostgreSQL.Marshall as Marshall
import qualified Orville.PostgreSQL.Monad as Monad
import qualified Orville.PostgreSQL.Plan.Explanation as Exp
import Orville.PostgreSQL.Plan.Many (Many)
import qualified Orville.PostgreSQL.Plan.Many as Many
import qualified Orville.PostgreSQL.Raw.RawSql as RawSql
import qualified Orville.PostgreSQL.Schema as Schema
{- | 'Operation' provides a stucture for building primitive operations that can be
incorporated into a 'Database.Orville.PostgreSQL.Plan.Plan'. An 'Operation'
provides base case implementations of the various plan execution functions.
You only need to care about this type if you want to create new custom
operations to include in a 'Database.Orville.PostgreSQL.Plan.Plan' beyond
those already provided in the 'Database.Orville.PostgreSQL.Plan.Plan'
API.
You can build your own custom 'Operation' values either directly, or using
the function and types in this module, such as 'WherePlanner' (via 'findAll',
etc), or 'SelectOperation' (via 'selectOperation').
@since 1.0.0.0
-}
data Operation param result = Operation
{ executeOperationOne ::
forall m.
Monad.MonadOrville m =>
param ->
m (Either AssertionFailed result)
-- ^ 'executeOperationOne' will be called when a plan is
-- executed with a single input parameter.
, executeOperationMany ::
forall m.
Monad.MonadOrville m =>
NonEmpty param ->
m (Either AssertionFailed (Many param result))
-- ^ 'executeOperationMany' will be called when a plan is executed with
-- multiple input parameters (via 'Orville.PostgreSQL.Plan.planMany').
, explainOperationOne :: Exp.Explanation
-- ^ 'explainOperationOne' will be called when producing an explanation
-- of what the plan will do when given one input parameter. Plans that do
-- not perform any interesting IO interactions should generally return an
-- empty explanation.
, explainOperationMany :: Exp.Explanation
-- ^ 'explainOperationMany' will be called when producing an explanation of
-- what the plan will do when given multiple input parameters (via
-- 'Orville.PostgreSQL.Plan.planMany'). Plans that do not perform any
-- interesting IO interactions should generally return an empty explanation.
}
{- | 'AssertionFailed' may be returned from the execute functions of an
'Operation' to indicate that some expected invariant has failed. For example,
following a foreign key that is enforced by the database only to find that no
record exists. When an 'Operation' returns an 'AssertionFailed' value during
plan execution, the error is thrown as an exception using the
'Control.Monad.Catch.MonadThrow' instance for whatever monad the plan is
executing in.
@since 1.0.0.0
-}
newtype AssertionFailed
= AssertionFailed String
deriving
( -- | @since 1.0.0.0
Show
)
{- | 'mkAssertionFailed' builds an 'AssertionFailed' error from an error message.
@since 1.0.0.0
-}
mkAssertionFailed :: String -> AssertionFailed
mkAssertionFailed =
AssertionFailed
-- | @since 1.0.0.0
instance Exception AssertionFailed
{- | 'askParam' simply returns the parameter given from the plan.
@since 1.0.0.0
-}
askParam :: Operation param param
askParam =
Operation
{ executeOperationOne = pure . Right
, executeOperationMany = \params -> pure . Right $ Many.fromKeys (Fold.toList params) Right
, explainOperationOne = Exp.noExplanation
, explainOperationMany = Exp.noExplanation
}
{- | 'assertRight' returns the value on the 'Right' side of an 'Either'. If
the 'Either' is a 'Left', it raises 'AssertionFailed' with the message
from the 'Left' side of the 'Either'.
@since 1.0.0.0
-}
assertRight :: Operation (Either String a) a
assertRight =
Operation
{ executeOperationOne = \eitherA ->
pure $
case eitherA of
Left err ->
Left (AssertionFailed $ "Assertion failed: " <> err)
Right b ->
Right b
, executeOperationMany = \eitherAs ->
pure $
case sequence eitherAs of
Left err ->
Left (AssertionFailed $ "Assertion failed: " <> err)
Right _ ->
let
errorOnLeft :: forall a b. Either a b -> Either Many.NotAKey b
errorOnLeft eitherA =
case eitherA of
Left _ ->
-- We proved all the values above didn't have any lefts in
-- then, so if we get a left here it must not be one of the
-- keys from the Many.
Left Many.NotAKey
Right a ->
Right a
in
Right $ Many.fromKeys (Fold.toList eitherAs) errorOnLeft
, explainOperationOne = Exp.noExplanation
, explainOperationMany = Exp.noExplanation
}
{- | The functions below ('findOne', 'findAll', etc) accept a 'WherePlanner'
to determine how to build the where conditions for executing a 'Exec.Select'
statement as part of a plan operation.
For simple queries, you can use the functions such as 'byField' that are
provided here to build a 'WherePlanner', but you may also build your own
custom 'WherePlanner' for more advanced use cases.
If you need to execute a custom query that cannot be built by providing a
custom where clause via 'WherePlanner', you may want to use more
direct 'selectOperation' functions.
@since 1.0.0.0
-}
data WherePlanner param = WherePlanner
{ paramMarshaller :: forall entity. (entity -> param) -> Marshall.SqlMarshaller entity param
-- ^ The 'paramMarshaller' function provided here will be used to decode
-- the parameter field from the result set so that the row can be properly
-- associated with the input parameter that matched it.
, executeOneWhereCondition :: param -> Expr.BooleanExpr
-- ^ 'executeOneWhereCondition' must build a where condition that will
-- match only those rows that match the input paramater.
, executeManyWhereCondition :: NonEmpty param -> Expr.BooleanExpr
-- ^ 'executeManyWhereCondition' must build a where condition that will
-- match only those rows that match any (not all!) of the input parameters.
, explainOneWhereCondition :: Expr.BooleanExpr
-- ^ 'explainOneWhereCondition' must build a where condition that is suitable
-- to be used as an example of what 'executeManyWhereCondition' would return
-- when given a parameter. This where condition will be used when producing
-- explanations of plans. For example, this could fill in either an example
-- or dummy value.
, explainManyWhereCondition :: Expr.BooleanExpr
-- ^ 'explainManyWhereCondition' must build a where condition that is
-- suitable to be used as an example of what 'executeOneWhereCondition' would
-- return when given a list of parameters. This where condition will be
-- used when producing explanations of plans. For example, this could fill in
-- either an example or dummy value.
}
{- | Builds a 'WherePlanner' that will match on a single
'FieldDefinition.FieldDefinition'. The resulting 'WherePlanner' can be used
with functions such as 'findOne' and 'findAll' to construct an 'Operation'.
@since 1.0.0.0
-}
byField ::
Ord fieldValue =>
Marshall.FieldDefinition nullability fieldValue ->
WherePlanner fieldValue
byField fieldDef =
let
stringyField =
stringifyField fieldDef
in
WherePlanner
{ paramMarshaller = flip Marshall.marshallField fieldDef
, executeOneWhereCondition = Marshall.fieldEquals fieldDef
, executeManyWhereCondition = Marshall.fieldIn fieldDef . dedupeFieldValues
, explainOneWhereCondition = Marshall.fieldEquals stringyField $ T.pack "EXAMPLE VALUE"
, explainManyWhereCondition = Marshall.fieldIn stringyField $ fmap T.pack ("EXAMPLE VALUE 1" :| ["EXAMPLE VALUE 2"])
}
{- | Builds a 'WherePlanner' that will match on a 2-tuple of
'FieldDefinition.FieldDefinition's. The resulting 'WherePlanner' can be used
with functions such as 'findOne' and 'findAll' to construct an 'Operation'.
@since 1.0.0.0
-}
byFieldTuple ::
forall nullabilityA fieldValueA nullabilityB fieldValueB.
(Ord fieldValueA, Ord fieldValueB) =>
Marshall.FieldDefinition nullabilityA fieldValueA ->
Marshall.FieldDefinition nullabilityB fieldValueB ->
WherePlanner (fieldValueA, fieldValueB)
byFieldTuple fieldDefA fieldDefB =
let
stringyFieldA =
stringifyField fieldDefA
stringyFieldB =
stringifyField fieldDefB
marshaller ::
(a -> (fieldValueA, fieldValueB)) ->
Marshall.SqlMarshaller a (fieldValueA, fieldValueB)
marshaller accessor =
(,)
<$> Marshall.marshallField (fst . accessor) fieldDefA
<*> Marshall.marshallField (snd . accessor) fieldDefB
packAll :: NonEmpty (String, String) -> NonEmpty (T.Text, T.Text)
packAll =
fmap (\(a, b) -> (T.pack a, T.pack b))
in
WherePlanner
{ paramMarshaller = marshaller
, executeOneWhereCondition = \fieldValue -> Marshall.fieldTupleIn fieldDefA fieldDefB (fieldValue :| [])
, executeManyWhereCondition = Marshall.fieldTupleIn fieldDefA fieldDefB . dedupeFieldValues
, explainOneWhereCondition =
Marshall.fieldTupleIn
stringyFieldA
stringyFieldB
(packAll $ ("EXAMPLE VALUE A", "EXAMPLE VALUE B") :| [])
, explainManyWhereCondition =
Marshall.fieldTupleIn
stringyFieldA
stringyFieldB
(packAll (("EXAMPLE VALUE A 1", "EXAMPLE VALUE B 1") :| [("EXAMPLE VALUE A 2", "EXAMPLE VALUE B 2")]))
}
{- | Builds a 'WherePlanner' that will match on the writable fields of a
'Marshall.SqlMarshaller'. The resulting 'WherePlanner' can be used with
functions such as 'findOne' and 'findAll' to construct an 'Operation'.
@since 1.1.0.0
-}
byMarshaller ::
Ord param =>
Marshall.SqlMarshaller param param ->
WherePlanner param
byMarshaller marshaller =
let
(fieldCount, stringyMarshaller) = stringifyMarshaller marshaller
examples = fmap (\i -> T.pack $ "EXAMPLE VALUE " <> show i) [1 .. fieldCount]
in
WherePlanner
{ paramMarshaller = flip Marshall.marshallNested marshaller
, executeOneWhereCondition = Marshall.marshallerEquals marshaller
, executeManyWhereCondition = Marshall.marshallerIn marshaller . dedupeFieldValues
, explainOneWhereCondition = Marshall.marshallerEquals stringyMarshaller examples
, explainManyWhereCondition =
Marshall.marshallerIn
stringyMarshaller
(fmap (<> T.pack " A") examples :| [fmap (<> T.pack " B") examples])
}
dedupeFieldValues :: Ord a => NonEmpty a -> NonEmpty a
dedupeFieldValues (first :| rest) =
let
dedupedWithoutFirst =
Set.toList
. Set.delete first
. Set.fromList
$ rest
in
first :| dedupedWithoutFirst
{- | 'findOne' builds a planning primitive that finds (at most) one row from the
given table where the column value for the provided 'Core.FieldDefinition'
matches the plan's input parameter. When executed on multiple parameters, it
fetches all rows where the field matches the inputs and arbitrarily picks at
most one of those rows to use as the result for each input.
@since 1.0.0.0
-}
findOne ::
Ord param =>
Schema.TableDefinition key writeEntity readEntity ->
WherePlanner param ->
Operation param (Maybe readEntity)
findOne tableDef wherePlanner =
findOneWithOpts tableDef wherePlanner mempty
{- | 'findOneWhere' is similar to 'findOne' but allows a 'Expr.BooleanExpr' to be
specified that is added to the database query to restrict which rows are
returned.
@since 1.0.0.0
-}
findOneWhere ::
Ord param =>
Schema.TableDefinition key writeEntity readEntity ->
WherePlanner param ->
Expr.BooleanExpr ->
Operation param (Maybe readEntity)
findOneWhere tableDef wherePlanner cond =
findOneWithOpts tableDef wherePlanner (Exec.where_ cond)
{- | 'findOneWithOpts' is a internal helper used by 'findOne' and 'findOneWhere'.
@since 1.0.0.0
-}
findOneWithOpts ::
Ord param =>
Schema.TableDefinition key writeEntity readEntity ->
WherePlanner param ->
Exec.SelectOptions ->
Operation param (Maybe readEntity)
findOneWithOpts tableDef wherePlanner opts =
selectOperation selectOp
where
selectOp =
SelectOperation
{ selectOne = \param ->
select (opts <> Exec.where_ (executeOneWhereCondition wherePlanner param) <> Exec.limit 1)
, selectMany = \params ->
select (opts <> Exec.where_ (executeManyWhereCondition wherePlanner params))
, explainSelectOne =
select (opts <> Exec.where_ (explainOneWhereCondition wherePlanner))
, explainSelectMany =
select (opts <> Exec.where_ (explainManyWhereCondition wherePlanner))
, categorizeRow = fst
, produceResult = fmap snd . Maybe.listToMaybe
}
select =
Exec.selectMarshalledColumns
marshaller
(Schema.tableName tableDef)
marshaller =
Marshall.mapSqlMarshaller
( \m ->
(,)
<$> paramMarshaller wherePlanner fst
<*> Marshall.marshallNested snd m
)
(Schema.tableMarshaller tableDef)
{- | 'findAll' builds a planning primitive that finds all the rows from the given
table where the column value for the provided field matches the plan's input
parameter. When executed on multiple parameters, all rows are fetched in a
single query and then associated with their respective inputs after being
fetched.
@since 1.0.0.0
-}
findAll ::
Ord param =>
Schema.TableDefinition key writeEntity readEntity ->
WherePlanner param ->
Operation param [readEntity]
findAll tableDef wherePlanner =
findAllWithOpts tableDef wherePlanner mempty
{- | 'findAllWhere' is similar to 'findAll' but allows a 'Expr.BooleanExpr' to be
specified that is added to the database query to restrict which rows are
returned.
@since 1.0.0.0
-}
findAllWhere ::
Ord param =>
Schema.TableDefinition key writeEntity readEntity ->
WherePlanner param ->
Expr.BooleanExpr ->
Operation param [readEntity]
findAllWhere tableDef wherePlanner cond =
findAllWithOpts tableDef wherePlanner (Exec.where_ cond)
{- | 'findAllWithOpts' is an internal helper used by 'findAll' and 'findAllWhere'.
@since 1.0.0.0
-}
findAllWithOpts ::
Ord param =>
Schema.TableDefinition key writeEntity readEntity ->
WherePlanner param ->
Exec.SelectOptions ->
Operation param [readEntity]
findAllWithOpts tableDef wherePlanner opts =
selectOperation selectOp
where
selectOp =
SelectOperation
{ selectOne = \param ->
select (opts <> Exec.where_ (executeOneWhereCondition wherePlanner param))
, selectMany = \params ->
select (opts <> Exec.where_ (executeManyWhereCondition wherePlanner params))
, explainSelectOne =
select (opts <> Exec.where_ (explainOneWhereCondition wherePlanner))
, explainSelectMany =
select (opts <> Exec.where_ (explainManyWhereCondition wherePlanner))
, categorizeRow = fst
, produceResult = map snd
}
select =
Exec.selectMarshalledColumns
marshaller
(Schema.tableName tableDef)
marshaller =
Marshall.mapSqlMarshaller
( \m ->
(,)
<$> paramMarshaller wherePlanner fst
<*> Marshall.marshallNested snd m
)
(Schema.tableMarshaller tableDef)
{- | 'stringifyField' arbitrarily re-labels the 'Marshall.SqlType' of a field
definition as text. It is an internal helper function that is used for
constructing 'Expr.BooleanExpr' clauses used to generate sql when explaining
how a plan will be executed. Relabeling the type as 'T.Text' allows us to use
text values as example inputs in the queries when for explaining plans.
@since 1.0.0.0
-}
stringifyField ::
Marshall.FieldDefinition nullability a ->
Marshall.FieldDefinition nullability T.Text
stringifyField =
Marshall.convertField (const Marshall.unboundedText)
{- | 'stringifyMarshaller' arbitrarily re-labels the 'Marshall.SqlType' of all the
writable fields in a marshaller as 'T.Text'. Used internally to generate SQL
for explanations. Also returns the number of re-labled fields to facilitate
generating a list of example values.
@since 1.1.0.0
-}
stringifyMarshaller ::
Marshall.SqlMarshaller a b ->
(Int, Marshall.SqlMarshaller [T.Text] [T.Text])
stringifyMarshaller marshaller =
let
mkFieldMarshaller ::
Marshall.FieldQualifier qualifier =>
Maybe qualifier ->
Marshall.FieldDefinition nullability a ->
(Int -> Marshall.SqlMarshaller [T.Text] T.Text)
mkFieldMarshaller mbQualifier fieldDef =
\idx ->
maybe id Marshall.marshallQualifyFields mbQualifier $
Marshall.marshallField
(!! idx)
( Marshall.unboundedTextField
. Marshall.fieldNameToString
. Marshall.fieldName
$ fieldDef
)
marshallerConstructors :: [Int -> Marshall.SqlMarshaller [T.Text] T.Text]
marshallerConstructors =
Marshall.foldMarshallerFields marshaller [] $
Marshall.collectFromField
Marshall.ExcludeReadOnlyColumns
mkFieldMarshaller
stringyMarshaller :: Marshall.SqlMarshaller [T.Text] [T.Text]
stringyMarshaller =
sequenceA (zipWith ($) marshallerConstructors [(0 :: Int) ..])
in
(length marshallerConstructors, stringyMarshaller)
{- | 'SelectOperation' is a helper type for building 'Operation' primitives that
run 'Ex.cSelect' queries. Specifying the fields of 'SelectOperation' and then
using the 'selectOperation' function to build an 'Operation' is more
convenient than building functions to execute the queries that are required
by the 'Operation' type.
Note: If you only need to build a custom where clause based on the
'Operation' parameter, you may want to use a custom 'WherePlanner' with one
of the existing 'findOne' or 'findAll' functions.
If you cannot respresent your custom operation using 'SelectOperation' then
you need to build the 'Operation' value directly yourself.
@since 1.0.0.0
-}
data SelectOperation param row result = SelectOperation
{ selectOne :: param -> Exec.Select row
-- ^ 'selectOne' will be called to build the 'Exec.Select' query that should
-- be run when there is a single input parameter while executing a plan.
-- Note that the "One-ness" here refers to the single input parameter
-- rather than the result. See 'produceResult' below for more information
-- about returning one value vs. many from a 'SelectOperation'.
, selectMany :: NonEmpty param -> Exec.Select row
-- ^ 'selectMany' will be called to build the 'Exec.Select' query that should
-- be run when there are multiple parameters while executing a plan.
-- Note that the "Many-ness" here refers to the multiple input parameters
-- rather than the result. See 'produceResult' below for more information
-- about returning one value vs. many from a 'SelectOperation'.
, explainSelectOne :: Exec.Select row
-- ^ 'explainSelectOne' should show a representative query of what will
-- be returned when 'selectOne' is used. No input parameter is available
-- here to build the query, however, because this value is used to
-- explain a plan without actually running it.
, explainSelectMany :: Exec.Select row
-- ^ 'explainSelectMany' should show a representative query of what will
-- be returned when 'selectMany is used. No input parameters are available
-- here to build the query, however, because this value is used to
-- explain a plan without actually running it.
, categorizeRow :: row -> param
-- ^ 'categorizeRow' will be used when a plan is executed with multiple
-- parameters to determine which input parameter the row should be
-- associated with.
, produceResult :: [row] -> result
-- ^ 'produceResult' will be used to convert the @row@ type returned by the
-- 'Exec.Select' queries for the operation input to the @result@ type that is
-- present as the output of the operation. The input rows will be all the
-- inputs associated with a single parameter. The @result@ type constructed
-- here need not be a single value. For instance, 'findAll' uses the list
-- type as the @result@ type and 'findOne' uses 'Maybe'.
}
{- | 'selectOperation' builds a primitive planning 'Operation' using the functions
given by a 'SelectOperation'. If you are implementing a custom operation that
runs a select statement, it is probably easier to use this function rather
than building the 'Operation' functions directly.
@since 1.0.0.0
-}
selectOperation ::
Ord param =>
SelectOperation param row result ->
Operation param result
selectOperation selectOp =
Operation
{ executeOperationOne = executeSelectOne selectOp
, executeOperationMany = executeSelectMany selectOp
, explainOperationOne = explainSelect $ explainSelectOne selectOp
, explainOperationMany = explainSelect $ explainSelectMany selectOp
}
explainSelect :: Exec.Select row -> Exp.Explanation
explainSelect =
Exp.explainStep . BS8.unpack . RawSql.toExampleBytes . Exec.selectToQueryExpr
{- | 'executeSelectOne' is an internal helper function that executes a
'SelectOperation' on a single input parameter.
@since 1.0.0.0
-}
executeSelectOne ::
Monad.MonadOrville m =>
SelectOperation param row result ->
param ->
m (Either AssertionFailed result)
executeSelectOne selectOp param =
Right . produceResult selectOp
<$> (Exec.executeSelect . selectOne selectOp $ param)
{- | 'executeSelectMany' is an internal helper function that executes a
'SelectOperation' on multiple input parameters.
@since 1.0.0.0
-}
executeSelectMany ::
forall param row result m.
(Ord param, Monad.MonadOrville m) =>
SelectOperation param row result ->
NonEmpty param ->
m (Either AssertionFailed (Many param result))
executeSelectMany selectOp params = do
rows <- Exec.executeSelect . selectMany selectOp $ params
let
paramList :: [param]
paramList = Fold.toList params
-- Seed add initial map with an empty seq for every input parameter
-- to guarantee that each param is a key in the map even if no rows
-- where returned from the select query for that param.
emptyRowsMap :: Map.Map param (Seq.Seq a)
emptyRowsMap =
Map.fromList
. fmap (\param -> (param, Seq.empty))
$ paramList
insertRow results row =
Map.alter
(\mbRows -> Just (Maybe.fromMaybe Seq.empty mbRows Seq.|> row))
(categorizeRow selectOp row)
results
rowMap =
produceResult selectOp . Fold.toList <$> Fold.foldl' insertRow emptyRowsMap rows
manyRows =
Many.fromKeys paramList $ \param ->
case Map.lookup param rowMap of
Nothing ->
-- Because we seeded the map above with all the input parameters we
-- can be sure that if we don't find a value in the map here it is
-- because the function parameter is not one of the original inputs
-- rather than just an input for which no rows were returned by the
-- select query.
Left Many.NotAKey
Just row ->
Right row
pure . Right $ manyRows
{- | 'findSelect' builds a plan 'Operation' where the select that is run does not
use the input parameters for the plan in any way. The 'executeOperationMany'
function of the resulting 'Operation' will run the query once and use the
entire result set as the result each of the input parameters in turn.
@since 1.0.0.0
-}
findSelect :: forall param row. Exec.Select row -> Operation param [row]
findSelect select =
let
executeOne :: Monad.MonadOrville m => param -> m (Either a [row])
executeOne _ =
Right <$> Exec.executeSelect select
executeMany :: Monad.MonadOrville m => NonEmpty param -> m (Either a (Many param [row]))
executeMany params = do
rows <- Exec.executeSelect select
pure . Right $ Many.fromKeys (Fold.toList params) (const (Right rows))
selectToSqlString :: Exec.Select readEntity -> String
selectToSqlString =
BS8.unpack
. RawSql.toExampleBytes
. Exec.selectToQueryExpr
in
Operation
{ executeOperationOne = executeOne
, executeOperationMany = executeMany
, explainOperationOne = Exp.explainStep (selectToSqlString select)
, explainOperationMany = Exp.explainStep (selectToSqlString select)
}