1
Fork 0
mirror of https://github.com/Airsequel/AirGQL.git synced 2025-07-29 14:23:18 +03: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),
)
import AirGQL.Types.Utils (encodeToText)
import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText)
import Data.Either.Extra qualified as Either
import AirGQL.Utils (colToFileUrl, quoteKeyword, quoteText)
import Data.List qualified as List
import Language.GraphQL.Class (FromGraphQL (fromGraphQL))
@ -385,7 +384,19 @@ gqlValueToSQLData = \case
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 =
let
buildMetadataJson :: Text -> Text -> Text
@ -393,80 +404,51 @@ rowToGraphQL dbId table row =
object ["url" .= colToFileUrl dbId table.name colName rowid]
& encodeToText
parseSqlData :: (ColumnEntry, SQLData) -> Either (Text, Text) (Text, Value)
parseSqlData :: (ColumnEntry, SQLData) -> (Text, Value)
parseSqlData (colEntry, colVal) =
if "BLOB" `T.isPrefixOf` colEntry.datatype
then
pure
( colEntry.column_name_gql
, case colVal of
SQLNull -> Null
SQLInteger id ->
String $
buildMetadataJson colEntry.column_name (show id)
SQLText id ->
String $
buildMetadataJson colEntry.column_name id
_ -> Null
)
( colEntry.column_name_gql
, case colVal of
SQLNull -> Null
SQLInteger id ->
String $
buildMetadataJson colEntry.column_name (show id)
SQLText id ->
String $
buildMetadataJson colEntry.column_name id
_ -> Null
)
else case sqlDataToGQLValue colEntry.datatype colVal of
Left err ->
Left
(colEntry.column_name_gql, err)
( "__error_" <> colEntry.column_name_gql
, String err
)
Right gqlData ->
Right
( colEntry.column_name_gql
, case colEntry.datatype of
-- Coerce value to nullable String
-- if no datatype is set.
-- This happens for columns in views.
"" -> gqlValueToNullableString gqlData
_ -> gqlData
)
( colEntry.column_name_gql
, case colEntry.datatype of
-- Coerce value to nullable String
-- if no datatype is set.
-- This happens for columns in views.
"" -> gqlValueToNullableString gqlData
_ -> gqlData
)
in
-- => [(ColumnEntry, SQLData)]
P.zip table.columns row
-- => [Either (Text, Text) (Text, Value)]
-- => [(Text, Value)]
<&> parseSqlData
-- => Either [(Text, Text)] (Text, Value)
& collectErrorList
-- => Either [(Text, Text)] (HashMap Text Value)
<&> HashMap.fromList
-- => Either [(Text, Text)] Value
<&> Object
-- => HashMap Text Value
& HashMap.fromList
-- => Value
& Object
rowsToGraphQL
:: Text
-> TableEntry
-> [[SQLData]]
-> Either [(Text, Text)] Value
rowsToGraphQL :: Text -> TableEntry -> [[SQLData]] -> Value
rowsToGraphQL dbId table updatedRows =
updatedRows
-- => [Either [(Text, Text)] Value]
<&> rowToGraphQL dbId table
-- => Either [[(Text, Text)]] [Value]
& 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
& List
tryGetArg
@ -742,7 +724,7 @@ queryType connection accessMode dbId tables = do
orderElements
paginationMb
colErrorsToUserError $ rowsToGraphQL dbId table rows
pure $ rowsToGraphQL dbId table rows
getDbEntriesByPK :: TableEntry -> Out.Resolve IO
getDbEntriesByPK tableEntry = do
@ -764,7 +746,7 @@ queryType connection accessMode dbId tables = do
case P.head queryResult of
Nothing -> pure Null
Just row ->
colErrorsToUserError $
pure $
rowToGraphQL
dbId
tableEntry
@ -835,9 +817,7 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
mutationResponse :: TableEntry -> Int -> [[SQLData]] -> IO Value
mutationResponse table numChanges rows = do
returning <-
colErrorsToUserError $
rowsToGraphQL dbId table rows
let returning = rowsToGraphQL dbId table rows
pure $
Object $
@ -848,11 +828,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
mutationByPKResponse :: TableEntry -> Int -> Maybe [SQLData] -> IO Value
mutationByPKResponse table numChanges mbRow = do
returning <- case mbRow of
Nothing -> pure Null
Just row ->
colErrorsToUserError $
rowToGraphQL dbId table row
let returning = case mbRow of
Nothing -> Null
Just row -> rowToGraphQL dbId table row
pure $
Object $

View file

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

View file

@ -820,15 +820,28 @@ main = void $ do
rmSpaces
[raw|
{
"data": null,
"data": {
"test": [{
"alsobig": null,
"big": null
}]
},
"errors": [{
"locations": [{
"column": 3,
"line": 2
"column": 5,
"line": 3
}],
"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)",
"path": ["test"]
"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", 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"]
}]
}
|]