graphql-engine/server/src-lib/Hasura/GraphQL/Schema/Update.hs
Auke Booij 1007ea27ae server: refactor MonadSchema into MonadMemoize
Followup to hasura/graphql-engine-mono#4713.

The `memoizeOn` method, part of `MonadSchema`, originally had the following type:
```haskell
  memoizeOn
    :: (HasCallStack, Ord a, Typeable a, Typeable b, Typeable k)
    => TH.Name
    -> a
    -> m (Parser k n b)
    -> m (Parser k n b)
```
The reason for operating on `Parser`s specifically was that the `MonadSchema` effect would additionally initialize certain `Unique` values, which appear (nested in) the type of `Parser`.

hasura/graphql-engine-mono#518 changed the type of `memoizeOn`, to additionally allow memoizing `FieldParser`s. These also contained a `Unique` value, which was similarly initialized by the `MonadSchema` effect. The new type of `memoizeOn` was as follows:
```haskell
  memoizeOn
    :: forall p d a b
     . (HasCallStack, HasDefinition (p n b) d, Ord a, Typeable p, Typeable a, Typeable b)
    => TH.Name
    -> a
    -> m (p n b)
    -> m (p n b)
```

Note the type `p n b` of the value being memoized: by choosing `p` to be either `Parser k` or `FieldParser`, both can be memoized. Also note the new `HasDefinition (p n b) d` constraint, which provided a `Lens` for accessing the `Unique` value to be initialized.

A quick simplification is that the `HasCallStack` constraint has never been used by any code. This was realized in hasura/graphql-engine-mono#4713, by removing that constraint.

hasura/graphql-engine-mono#2980 removed the `Unique` value from our GraphQL-related types entirely, as their original purpose was never truly realized. One part of removing `Unique` consisted of dropping the `HasDefinition (p n b) d` constraint from `memoizeOn`.

What I didn't realize at the time was that this meant that the type of `memoizeOn` could be generalized and simplified much further. This PR finally implements that generalization. The new type is as follows:
```haskell
  memoizeOn ::
    forall a p.
    (Ord a, Typeable a, Typeable p) =>
    TH.Name ->
    a ->
    m p ->
    m p
```

This change has a couple of consequences.

1. While constructing the schema, we often output `Maybe (Parser ...)`, to model that the existence of certain pieces of GraphQL schema sometimes depends on the permissions that a certain role has. The previous versions of `memoizeOn` were not able to handle this, as the only thing they could memoize was fully-defined (if not yet fully-evaluated) `(Field)Parser`s. This much more general API _would_ allow memoizing `Maybe (Parser ...)`s. However, we probably have to be continue being cautious with this: if we blindly memoize all `Maybe (Parser ...)`s, the resulting code may never be able to decide whether the value is `Just` or `Nothing` - i.e. it never commits to the existence-or-not of a GraphQL schema fragment. This would manifest as a non-well-founded knot tying, and this would get reported as an error by the implementation of `memoizeOn`.

   tl;dr: This generalization _technically_ allows for memoizing `Maybe` values, but we probably still want to avoid doing so.

   For this reason, the PR adds a specialized version of `memoizeOn` to `Hasura.GraphQL.Schema.Parser`.
2. There is no longer any need to connect the `MonadSchema` knot-tying effect with the `MonadParse` effect. In fact, after this PR, the `memoizeOn` method is completely GraphQL-agnostic, and so we implement hasura/graphql-engine-mono#4726, separating `memoizeOn` from `MonadParse` entirely - `memoizeOn` can be defined and implemented as a general Haskell typeclass method.

   Since `MonadSchema` has been made into a single-type-parameter type class, it has been renamed to something more general, namely `MonadMemoize`. Its only task is to memoize arbitrary `Typeable p` objects under a combined key consisting of a `TH.Name` and a `Typeable a`.

   Also for this reason, the new `MonadMemoize` has been moved to the more general `Control.Monad.Memoize`.
3. After this change, it's somewhat clearer what `memoizeOn` does: it memoizes an arbitrary value of a `Typeable` type. The only thing that needs to be understood in its implementation is how the manual blackholing works. There is no more semantic interaction with _any_ GraphQL code.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4725
Co-authored-by: Daniel Harvey <4729125+danieljharvey@users.noreply.github.com>
GitOrigin-RevId: 089fa2e82c2ce29da76850e994eabb1e261f9c92
2022-08-04 13:45:53 +00:00

350 lines
13 KiB
Haskell

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE TemplateHaskell #-}
-- | This module provides common building blocks for composing Schema Parsers
-- used in the schema of Update Mutations.
module Hasura.GraphQL.Schema.Update
( UpdateOperator (..),
updateOperator,
buildUpdateOperators,
presetColumns,
setOp,
incOp,
updateTable,
updateTableByPk,
mkUpdateObject,
)
where
import Data.Has (Has (getter))
import Data.HashMap.Strict qualified as M
import Data.HashMap.Strict.Extended qualified as M
import Data.List.NonEmpty qualified as NE
import Data.Text.Extended ((<>>))
import Hasura.Base.Error (QErr)
import Hasura.Base.ToErrorValue
import Hasura.GraphQL.Schema.Backend (BackendSchema (..), BackendTableSelectSchema (..), MonadBuildSchema, columnParser)
import Hasura.GraphQL.Schema.BoolExp (boolExp)
import Hasura.GraphQL.Schema.Common (Scenario (..), SchemaContext (..), mapField, partialSQLExpToUnpreparedValue, retrieve)
import Hasura.GraphQL.Schema.Mutation (mutationSelectionSet, primaryKeysArguments)
import Hasura.GraphQL.Schema.NamingCase
import Hasura.GraphQL.Schema.Parser qualified as P
import Hasura.GraphQL.Schema.Table (getTableGQLName, tableColumns, tableUpdateColumns)
import Hasura.GraphQL.Schema.Typename
import Hasura.Prelude
import Hasura.RQL.IR.BoolExp (AnnBoolExp, annBoolExpTrue)
import Hasura.RQL.IR.Returning (MutationOutputG (..))
import Hasura.RQL.IR.Root (RemoteRelationshipField)
import Hasura.RQL.IR.Update (AnnotatedUpdateG (..))
import Hasura.RQL.IR.Value
import Hasura.RQL.Types.Backend (Backend (..))
import Hasura.RQL.Types.Column (ColumnInfo (..), isNumCol)
import Hasura.RQL.Types.Source
import Hasura.RQL.Types.Table
import Language.GraphQL.Draft.Syntax (Description (..), Name (..), Nullability (..), litName)
-- | @UpdateOperator b m n op@ represents one single update operator for a
-- backend @b@.
--
-- The type variable @op@ is the backend-specific data type that represents
-- update operators, typically in the form of a sum-type with an
-- @UnpreparedValue b@ in each constructor.
--
-- The @UpdateOperator b m n@ is a @Functor@. There exist building blocks of
-- common update operators (such as 'setOp', etc.) which have @op ~
-- UnpreparedValue b@. The Functor instance lets you wrap the generic update
-- operators in backend-specific tags.
data UpdateOperator b m n op = UpdateOperator
{ updateOperatorApplicableColumn :: ColumnInfo b -> Bool,
updateOperatorParser ::
Name ->
TableName b ->
NonEmpty (ColumnInfo b) ->
m (P.InputFieldsParser n (HashMap (Column b) op))
}
deriving (Functor)
-- | The top-level component for building update operators parsers.
--
-- * It implements the @preset@ functionality from Update Permissions (see
-- <https://hasura.io/docs/latest/graphql/core/auth/authorization/permission-rules.html#column-presets
-- Permissions user docs>). Use the 'presetColumns' function to extract those from the update permissions.
-- * It validates that that the update fields parsed are sound when taken as a
-- whole, i.e. that some changes are actually specified (either in the
-- mutation query text or in update preset columns) and that each column is
-- only used in one operator.
buildUpdateOperators ::
forall b r m n op.
MonadBuildSchema b r m n =>
-- | Columns with @preset@ expressions
(HashMap (Column b) op) ->
-- | Update operators to include in the Schema
[UpdateOperator b m n op] ->
TableInfo b ->
m (P.InputFieldsParser n (HashMap (Column b) op))
buildUpdateOperators presetCols ops tableInfo = do
parsers :: P.InputFieldsParser n [HashMap (Column b) op] <-
sequenceA . catMaybes <$> traverse (runUpdateOperator tableInfo) ops
pure $
parsers
`P.bindFields` ( \opExps -> do
let withPreset = presetCols : opExps
mergeDisjoint @b withPreset
)
-- | The columns that have 'preset' definitions applied to them. (see
-- <https://hasura.io/docs/latest/graphql/core/auth/authorization/permission-rules.html#column-presets
-- Permissions user docs>)
presetColumns :: UpdPermInfo b -> HashMap (Column b) (UnpreparedValue b)
presetColumns = fmap partialSQLExpToUnpreparedValue . upiSet
-- | Produce an InputFieldsParser from an UpdateOperator, but only if the operator
-- applies to the table (i.e., it admits a non-empty column set).
runUpdateOperator ::
forall b r m n op.
MonadBuildSchema b r m n =>
TableInfo b ->
UpdateOperator b m n op ->
m
( Maybe
( P.InputFieldsParser
n
(HashMap (Column b) op)
)
)
runUpdateOperator tableInfo UpdateOperator {..} = do
let tableName = tableInfoName tableInfo
tableGQLName <- getTableGQLName tableInfo
roleName <- retrieve scRole
let columns = tableUpdateColumns roleName tableInfo
let applicableCols :: Maybe (NonEmpty (ColumnInfo b)) =
nonEmpty . filter updateOperatorApplicableColumn $ columns
(sequenceA :: Maybe (m a) -> m (Maybe a))
(applicableCols <&> updateOperatorParser tableGQLName tableName)
-- | Merge the results of parsed update operators. Throws an error if the same
-- column has been specified in multiple operators.
mergeDisjoint ::
forall b m t.
(Backend b, P.MonadParse m) =>
[HashMap (Column b) t] ->
m (HashMap (Column b) t)
mergeDisjoint parsedResults = do
let unioned = M.unionsAll parsedResults
duplicates = M.keys $ M.filter (not . null . NE.tail) unioned
unless (null duplicates) $
P.parseError
( "Column found in multiple operators: "
<> toErrorValue duplicates
<> "."
)
return $ M.map NE.head unioned
-- | Construct a parser for a single update operator.
--
-- @updateOperator _ "op" fp MkOp ["col1","col2"]@ gives a parser that accepts
-- objects in the shape of:
--
-- > op: {
-- > col1: "x",
-- > col2: "y"
-- > }
--
-- And (morally) parses into values:
--
-- > M.fromList [("col1", MkOp (fp "x")), ("col2", MkOp (fp "y"))]
updateOperator ::
forall n r m b a.
(P.MonadParse n, MonadReader r m, Has MkTypename r, Backend b) =>
Name ->
Name ->
(ColumnInfo b -> m (P.Parser 'P.Both n a)) ->
NonEmpty (ColumnInfo b) ->
Description ->
Description ->
m (P.InputFieldsParser n (HashMap (Column b) a))
updateOperator tableGQLName opName mkParser columns opDesc objDesc = do
fieldParsers :: NonEmpty (P.InputFieldsParser n (Maybe (Column b, a))) <-
for columns \columnInfo -> do
let fieldName = ciName columnInfo
fieldDesc = ciDescription columnInfo
fieldParser <- mkParser columnInfo
pure $
P.fieldOptional fieldName fieldDesc fieldParser
`mapField` \value -> (ciColumn columnInfo, value)
objName <- mkTypename $ tableGQLName <> opName <> $$(litName "_input")
pure $
fmap (M.fromList . (fold :: Maybe [(Column b, a)] -> [(Column b, a)])) $
P.fieldOptional opName (Just opDesc) $
P.object objName (Just objDesc) $
(catMaybes . toList) <$> sequenceA fieldParsers
{-# ANN updateOperator ("HLint: ignore Use tuple-section" :: String) #-}
setOp ::
forall b n r m.
( BackendSchema b,
MonadReader r m,
Has MkTypename r,
Has NamingCase r,
MonadError QErr m,
P.MonadParse n
) =>
UpdateOperator b m n (UnpreparedValue b)
setOp = UpdateOperator {..}
where
updateOperatorApplicableColumn = const True
updateOperatorParser tableGQLName tableName columns = do
let typedParser columnInfo =
fmap mkParameter
<$> columnParser
(ciType columnInfo)
(Nullability $ ciIsNullable columnInfo)
updateOperator
tableGQLName
$$(litName "_set")
typedParser
columns
"sets the columns of the filtered rows to the given values"
(Description $ "input type for updating data in table " <>> tableName)
incOp ::
forall b m n r.
( Backend b,
MonadReader r m,
MonadError QErr m,
P.MonadParse n,
BackendSchema b,
Has MkTypename r,
Has NamingCase r
) =>
UpdateOperator b m n (UnpreparedValue b)
incOp = UpdateOperator {..}
where
updateOperatorApplicableColumn = isNumCol
updateOperatorParser tableGQLName tableName columns = do
let typedParser columnInfo =
fmap mkParameter
<$> columnParser
(ciType columnInfo)
(Nullability $ ciIsNullable columnInfo)
updateOperator
tableGQLName
$$(litName "_inc")
typedParser
columns
"increments the numeric columns with given value of the filtered values"
(Description $ "input type for incrementing numeric columns in table " <>> tableName)
-- | Construct a root field, normally called update_tablename, that can be used
-- to update rows in a DB table specified by filters. Only returns a parser if
-- there are columns the user is allowed to update; otherwise returns Nothing.
updateTable ::
forall b r m n.
( MonadBuildSchema b r m n,
BackendTableSelectSchema b
) =>
-- | backend-specific data needed to perform an update mutation
P.InputFieldsParser n (BackendUpdate b (UnpreparedValue b)) ->
Scenario ->
-- | table source
SourceInfo b ->
-- | table info
TableInfo b ->
-- | field display name
Name ->
-- | field description, if any
Maybe Description ->
m (Maybe (P.FieldParser n (AnnotatedUpdateG b (RemoteRelationshipField UnpreparedValue) (UnpreparedValue b))))
updateTable backendUpdate scenario sourceInfo tableInfo fieldName description = runMaybeT do
let tableName = tableInfoName tableInfo
columns = tableColumns tableInfo
whereName = $$(litName "where")
whereDesc = "filter the rows which have to be updated"
viewInfo = _tciViewInfo $ _tiCoreInfo tableInfo
guard $ isMutable viIsUpdatable viewInfo
roleName <- retrieve scRole
updatePerms <- hoistMaybe $ _permUpd $ getRolePermInfo roleName tableInfo
-- If we're in a frontend scenario, we should not include backend_only updates
-- For more info see Note [Backend only permissions]
guard $ not $ scenario == Frontend && upiBackendOnly updatePerms
whereArg <- lift $ P.field whereName (Just whereDesc) <$> boolExp sourceInfo tableInfo
selection <- lift $ mutationSelectionSet sourceInfo tableInfo
tCase <- asks getter
let argsParser = liftA2 (,) backendUpdate whereArg
pure $
P.subselection fieldName description argsParser selection
<&> mkUpdateObject tableName columns updatePerms (Just tCase) . fmap MOutMultirowFields
-- | Construct a root field, normally called 'update_tablename_by_pk', that can be used
-- to update a single in a DB table, specified by primary key. Only returns a
-- parser if there are columns the user is allowed to update and if the user has
-- select permissions on all primary keys; otherwise returns Nothing.
updateTableByPk ::
forall b r m n.
MonadBuildSchema b r m n =>
BackendTableSelectSchema b =>
-- | backend-specific data needed to perform an update mutation
P.InputFieldsParser n (BackendUpdate b (UnpreparedValue b)) ->
Scenario ->
-- | table source
SourceInfo b ->
-- | table info
TableInfo b ->
-- | field display name
Name ->
-- | field description, if any
Maybe Description ->
m (Maybe (P.FieldParser n (AnnotatedUpdateG b (RemoteRelationshipField UnpreparedValue) (UnpreparedValue b))))
updateTableByPk backendUpdate scenario sourceInfo tableInfo fieldName description = runMaybeT $ do
let columns = tableColumns tableInfo
tableName = tableInfoName tableInfo
viewInfo = _tciViewInfo $ _tiCoreInfo tableInfo
guard $ isMutable viIsUpdatable viewInfo
roleName <- retrieve scRole
updatePerms <- hoistMaybe $ _permUpd $ getRolePermInfo roleName tableInfo
-- If we're in a frontend scenario, we should not include backend_only updates
-- For more info see Note [Backend only permissions]
guard $ not $ scenario == Frontend && upiBackendOnly updatePerms
pkArgs <- MaybeT $ primaryKeysArguments tableInfo
selection <- MaybeT $ tableSelectionSet sourceInfo tableInfo
tCase <- asks getter
lift $ do
tableGQLName <- getTableGQLName tableInfo
pkObjectName <- mkTypename $ tableGQLName <> $$(litName "_pk_columns_input")
let pkFieldName = $$(litName "pk_columns")
pkObjectDesc = Description $ "primary key columns input for table: " <> unName tableGQLName
pkParser = P.object pkObjectName (Just pkObjectDesc) pkArgs
argsParser = (,) <$> backendUpdate <*> P.field pkFieldName Nothing pkParser
pure $
P.subselection fieldName description argsParser selection
<&> mkUpdateObject tableName columns updatePerms (Just tCase) . fmap MOutSinglerowObject
mkUpdateObject ::
Backend b =>
TableName b ->
[ColumnInfo b] ->
UpdPermInfo b ->
(Maybe NamingCase) ->
( ( BackendUpdate b (UnpreparedValue b),
AnnBoolExp b (UnpreparedValue b)
),
MutationOutputG b (RemoteRelationshipField UnpreparedValue) (UnpreparedValue b)
) ->
AnnotatedUpdateG b (RemoteRelationshipField UnpreparedValue) (UnpreparedValue b)
mkUpdateObject _auTable _auAllCols updatePerms _auNamingConvention ((_auBackend, whereExp), _auOutput) =
AnnotatedUpdateG {..}
where
permissionFilter = fmap partialSQLExpToUnpreparedValue <$> upiFilter updatePerms
_auWhere = (permissionFilter, whereExp)
_auCheck = maybe annBoolExpTrue ((fmap . fmap) partialSQLExpToUnpreparedValue) $ upiCheck updatePerms