diff --git a/source/AirGQL/GraphQL.hs b/source/AirGQL/GraphQL.hs index 6b2a4ae..2d7abb7 100644 --- a/source/AirGQL/GraphQL.hs +++ b/source/AirGQL/GraphQL.hs @@ -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 $ diff --git a/source/AirGQL/Introspection/Resolver.hs b/source/AirGQL/Introspection/Resolver.hs index 017d431..95aa6bf 100644 --- a/source/AirGQL/Introspection/Resolver.hs +++ b/source/AirGQL/Introspection/Resolver.hs @@ -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 diff --git a/tests/Tests/QuerySpec.hs b/tests/Tests/QuerySpec.hs index 9850a39..14d0eef 100644 --- a/tests/Tests/QuerySpec.hs +++ b/tests/Tests/QuerySpec.hs @@ -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"] }] } |]