1
Fork 0
mirror of https://github.com/Airsequel/AirGQL.git synced 2025-09-18 19:34:32 +02:00

Implement partial errors in grqphql API

Make it so the parent resolver returns a failed field named `foo` as
`__error_foo` containing the error message. This field can then be
picked up by the child resolver in order to produce a local error.
This commit is contained in:
prescientmoon 2025-03-10 21:38:31 +01:00
commit 41f1752983
3 changed files with 96 additions and 85 deletions
source/AirGQL
tests/Tests

View file

@ -102,8 +102,7 @@ import AirGQL.Types.SchemaConf (
SchemaConf (accessMode, maxRowsPerTable, pragmaConf), SchemaConf (accessMode, maxRowsPerTable, pragmaConf),
) )
import AirGQL.Types.Utils (encodeToText) import AirGQL.Types.Utils (encodeToText)
import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText) import AirGQL.Utils (colToFileUrl, quoteKeyword, quoteText)
import Data.Either.Extra qualified as Either
import Data.List qualified as List import Data.List qualified as List
import Language.GraphQL.Class (FromGraphQL (fromGraphQL)) import Language.GraphQL.Class (FromGraphQL (fromGraphQL))
@ -385,7 +384,19 @@ gqlValueToSQLData = \case
Object obj -> SQLText $ show obj Object obj -> SQLText $ show obj
rowToGraphQL :: Text -> TableEntry -> [SQLData] -> Either [(Text, Text)] Value -- The way Airsequel works at the moment is by generating one big GQL object at
-- the root-most resolver, and then having child resolvers pick up the sections
-- they need.
--
-- One issue with this way of doing things is that we don't have a way to
-- generate location-specific errors, thus we used to simply error out at the
-- first issue, without returning partial results.
--
-- The "hack" I came up to fix this, is to return a failed field named "foo" as
-- a field named "__error_foo" containing the text of the error. The child
-- resolvers can later pick up this error, and fail themselves only, thus
-- returning partial results.
rowToGraphQL :: Text -> TableEntry -> [SQLData] -> Value
rowToGraphQL dbId table row = rowToGraphQL dbId table row =
let let
buildMetadataJson :: Text -> Text -> Text buildMetadataJson :: Text -> Text -> Text
@ -393,80 +404,51 @@ rowToGraphQL dbId table row =
object ["url" .= colToFileUrl dbId table.name colName rowid] object ["url" .= colToFileUrl dbId table.name colName rowid]
& encodeToText & encodeToText
parseSqlData :: (ColumnEntry, SQLData) -> Either (Text, Text) (Text, Value) parseSqlData :: (ColumnEntry, SQLData) -> (Text, Value)
parseSqlData (colEntry, colVal) = parseSqlData (colEntry, colVal) =
if "BLOB" `T.isPrefixOf` colEntry.datatype if "BLOB" `T.isPrefixOf` colEntry.datatype
then then
pure ( colEntry.column_name_gql
( colEntry.column_name_gql , case colVal of
, case colVal of SQLNull -> Null
SQLNull -> Null SQLInteger id ->
SQLInteger id -> String $
String $ buildMetadataJson colEntry.column_name (show id)
buildMetadataJson colEntry.column_name (show id) SQLText id ->
SQLText id -> String $
String $ buildMetadataJson colEntry.column_name id
buildMetadataJson colEntry.column_name id _ -> Null
_ -> Null )
)
else case sqlDataToGQLValue colEntry.datatype colVal of else case sqlDataToGQLValue colEntry.datatype colVal of
Left err -> Left err ->
Left ( "__error_" <> colEntry.column_name_gql
(colEntry.column_name_gql, err) , String err
)
Right gqlData -> Right gqlData ->
Right ( colEntry.column_name_gql
( colEntry.column_name_gql , case colEntry.datatype of
, case colEntry.datatype of -- Coerce value to nullable String
-- Coerce value to nullable String -- if no datatype is set.
-- if no datatype is set. -- This happens for columns in views.
-- This happens for columns in views. "" -> gqlValueToNullableString gqlData
"" -> gqlValueToNullableString gqlData _ -> gqlData
_ -> gqlData )
)
in in
-- => [(ColumnEntry, SQLData)] -- => [(ColumnEntry, SQLData)]
P.zip table.columns row P.zip table.columns row
-- => [Either (Text, Text) (Text, Value)] -- => [(Text, Value)]
<&> parseSqlData <&> parseSqlData
-- => Either [(Text, Text)] (Text, Value) -- => HashMap Text Value
& collectErrorList & HashMap.fromList
-- => Either [(Text, Text)] (HashMap Text Value) -- => Value
<&> HashMap.fromList & Object
-- => Either [(Text, Text)] Value
<&> Object
rowsToGraphQL rowsToGraphQL :: Text -> TableEntry -> [[SQLData]] -> Value
:: Text
-> TableEntry
-> [[SQLData]]
-> Either [(Text, Text)] Value
rowsToGraphQL dbId table updatedRows = rowsToGraphQL dbId table updatedRows =
updatedRows updatedRows
-- => [Either [(Text, Text)] Value]
<&> rowToGraphQL dbId table <&> rowToGraphQL dbId table
-- => Either [[(Text, Text)]] [Value] & List
& collectErrorList
-- => Either [(Text, Text)] [Value]
& Either.mapLeft P.join
-- => Either [(Text, Text)] Value
<&> List
-- | Formats errors from `row(s)ToGraphQL` and throws them.
colErrorsToUserError :: forall m a. (MonadIO m) => Either [(Text, Text)] a -> m a
colErrorsToUserError = \case
Right v -> pure v
Left errors ->
let
errorLines =
errors
<&> \(column, err) -> "On column " <> show column <> ": " <> err
in
P.throwIO $
userError $
T.unpack $
"Multiple errors occurred:\n" <> P.unlines errorLines
tryGetArg tryGetArg
@ -742,7 +724,7 @@ queryType connection accessMode dbId tables = do
orderElements orderElements
paginationMb paginationMb
colErrorsToUserError $ rowsToGraphQL dbId table rows pure $ rowsToGraphQL dbId table rows
getDbEntriesByPK :: TableEntry -> Out.Resolve IO getDbEntriesByPK :: TableEntry -> Out.Resolve IO
getDbEntriesByPK tableEntry = do getDbEntriesByPK tableEntry = do
@ -764,7 +746,7 @@ queryType connection accessMode dbId tables = do
case P.head queryResult of case P.head queryResult of
Nothing -> pure Null Nothing -> pure Null
Just row -> Just row ->
colErrorsToUserError $ pure $
rowToGraphQL rowToGraphQL
dbId dbId
tableEntry tableEntry
@ -835,9 +817,7 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
mutationResponse :: TableEntry -> Int -> [[SQLData]] -> IO Value mutationResponse :: TableEntry -> Int -> [[SQLData]] -> IO Value
mutationResponse table numChanges rows = do mutationResponse table numChanges rows = do
returning <- let returning = rowsToGraphQL dbId table rows
colErrorsToUserError $
rowsToGraphQL dbId table rows
pure $ pure $
Object $ Object $
@ -848,11 +828,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
mutationByPKResponse :: TableEntry -> Int -> Maybe [SQLData] -> IO Value mutationByPKResponse :: TableEntry -> Int -> Maybe [SQLData] -> IO Value
mutationByPKResponse table numChanges mbRow = do mutationByPKResponse table numChanges mbRow = do
returning <- case mbRow of let returning = case mbRow of
Nothing -> pure Null Nothing -> Null
Just row -> Just row -> rowToGraphQL dbId table row
colErrorsToUserError $
rowToGraphQL dbId table row
pure $ pure $
Object $ Object $

View file

@ -1,3 +1,6 @@
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Replace case with maybe" #-}
module AirGQL.Introspection.Resolver ( module AirGQL.Introspection.Resolver (
makeType, makeType,
makeConstField, makeConstField,
@ -8,9 +11,9 @@ import Protolude (
Either (Left), Either (Left),
IO, IO,
Int, Int,
Maybe (Just, Nothing),
MonadReader (ask), MonadReader (ask),
Text, Text,
fromMaybe,
pure, pure,
show, show,
($), ($),
@ -23,7 +26,11 @@ import Protolude (
import Protolude qualified as P import Protolude qualified as P
import AirGQL.Introspection.Types qualified as IType import AirGQL.Introspection.Types qualified as IType
import Control.Exception qualified as Exception
import Data.HashMap.Strict qualified as HashMap import Data.HashMap.Strict qualified as HashMap
import Data.Text qualified as T
import GHC.IO.Exception (userError)
import Language.GraphQL.Error (ResolverException (ResolverException))
import Language.GraphQL.Type qualified as Type import Language.GraphQL.Type qualified as Type
import Language.GraphQL.Type.In qualified as In import Language.GraphQL.Type.In qualified as In
import Language.GraphQL.Type.Out qualified as Out import Language.GraphQL.Type.Out qualified as Out
@ -32,6 +39,14 @@ import Language.GraphQL.Type.Out qualified as Out
type Result = Either Text type Result = Either Text
throwResolverError :: Text -> m a
throwResolverError err =
Exception.throw $
ResolverException $
userError $
T.unpack err
{-| Turns a type descriptor into a graphql output type, erroring out on input {-| Turns a type descriptor into a graphql output type, erroring out on input
types. Child resolvers look up their respective fields in the value produced by types. Child resolvers look up their respective fields in the value produced by
their parent. their parent.
@ -123,18 +138,23 @@ makeType =
let defaultValue = let defaultValue =
if Out.isNonNullType ty if Out.isNonNullType ty
then then
Type.String $ throwResolverError $
"Error: field '" "Error: field '"
<> field.name <> field.name
<> "' not found " <> "' not found "
else Type.Null else pure Type.Null
case context.values of case context.values of
Type.Object obj -> Type.Object obj -> do
pure $ let errorValue = HashMap.lookup ("__error_" <> field.name) obj
fromMaybe defaultValue $ P.for_ errorValue $ \case
HashMap.lookup field.name obj Type.String err -> throwResolverError err
_ -> pure defaultValue _ -> pure ()
case HashMap.lookup field.name obj of
Just value -> pure value
Nothing -> defaultValue
_ -> defaultValue
in in
makeTypeWithDepth 0 makeTypeWithDepth 0

View file

@ -820,15 +820,28 @@ main = void $ do
rmSpaces rmSpaces
[raw| [raw|
{ {
"data": null, "data": {
"test": [{
"alsobig": null,
"big": null
}]
},
"errors": [{ "errors": [{
"locations": [{ "locations": [{
"column": 3, "column": 5,
"line": 2 "line": 3
}], }],
"message": "message":
"user error (Multiple errors occurred:\nOn column \"big\": Integer 8000000000 would overflow. This happens because SQLite uses 64-bit ints, but GraphQL uses 32-bit ints. Use a Number (64-bit float) or Text column instead.\nOn column \"alsobig\": Integer 9000000000 would overflow. This happens because SQLite uses 64-bit ints, but GraphQL uses 32-bit ints. Use a Number (64-bit float) or Text column instead.\n)", "user error (Integer 8000000000 would overflow. This happens because SQLite uses 64-bit ints, but GraphQL uses 32-bit ints. Use a Number (64-bit float) or Text column instead.)",
"path": ["test"] "path": ["test", 0, "big"]
}, {
"locations": [{
"column": 5,
"line": 4
}],
"message":
"user error (Integer 9000000000 would overflow. This happens because SQLite uses 64-bit ints, but GraphQL uses 32-bit ints. Use a Number (64-bit float) or Text column instead.)",
"path": ["test", 0, "alsobig"]
}] }]
} }
|] |]