diff --git a/source/AirGQL/GraphQL.hs b/source/AirGQL/GraphQL.hs index 2d7abb7..f9c19f4 100644 --- a/source/AirGQL/GraphQL.hs +++ b/source/AirGQL/GraphQL.hs @@ -40,6 +40,7 @@ import Protolude ( (<=), (>), (>=), + (||), ) import Protolude qualified as P @@ -213,33 +214,36 @@ getColNamesQuoted columnEntries = opAndValToSql :: HashMap.HashMap Text Value -> [Text] opAndValToSql operatorAndValue = case HashMap.toList operatorAndValue of - [("eq", value)] -> - pure $ - if value == Null - then " IS NULL" - else " == " <> gqlValueToSQLText value - [("neq", value)] -> - if value == Null - then pure " IS NOT NULL" - else - [ " != " <> gqlValueToSQLText value - , " IS NULL" - ] - [("in", List values)] -> - let listValues = values <&> gqlValueToSQLText & intercalate "," - in [" IN (" <> listValues <> ")"] - [("nin", List values)] -> - let listValues = values <&> gqlValueToSQLText & intercalate "," - in [" NOT IN (" <> listValues <> ")"] - <> if P.elem Null values - then [] - else [" IS NULL"] - [("gt", value)] -> [" > " <> gqlValueToSQLText value] - [("gte", value)] -> [" >= " <> gqlValueToSQLText value] - [("lt", value)] -> [" < " <> gqlValueToSQLText value] - [("lte", value)] -> [" <= " <> gqlValueToSQLText value] - [("like", value)] -> [" like " <> gqlValueToSQLText value] - [("ilike", value)] -> [" like " <> gqlValueToSQLText value] + [(op, value)] + | op == "eq" || op == "_eq" -> + pure $ + if value == Null + then " IS NULL" + else " == " <> gqlValueToSQLText value + | op == "neq" || op == "_neq" -> + if value == Null + then pure " IS NOT NULL" + else + [ " != " <> gqlValueToSQLText value + , " IS NULL" + ] + | op == "in" || op == "_in" + , List values <- value -> + let listValues = values <&> gqlValueToSQLText & intercalate "," + in [" IN (" <> listValues <> ")"] + | op == "nin" || op == "_nin" + , List values <- value -> + let listValues = values <&> gqlValueToSQLText & intercalate "," + in [" NOT IN (" <> listValues <> ")"] + <> if P.elem Null values + then [] + else [" IS NULL"] + | op == "gt" || op == "_gt" -> [" > " <> gqlValueToSQLText value] + | op == "gte" || op == "_gte" -> [" >= " <> gqlValueToSQLText value] + | op == "lt" || op == "_lt" -> [" < " <> gqlValueToSQLText value] + | op == "lte" || op == "_lte" -> [" <= " <> gqlValueToSQLText value] + | op == "like" || op == "_like" -> [" like " <> gqlValueToSQLText value] + | op == "ilike" || op == "_ilike" -> [" like " <> gqlValueToSQLText value] filter -> do throw $ userError $ @@ -451,16 +455,21 @@ rowsToGraphQL dbId table updatedRows = & List +-- Attempts to find and decode an argument, given its current name, and a list +-- of deprecated older names. tryGetArg :: forall m a . (FromGraphQL a) => (MonadIO m) => Text + -> [Text] -> HashMap Text Value -> m (Maybe a) -tryGetArg name args = do +tryGetArg name alts args = do case HashMap.lookup name args of - Nothing -> pure Nothing + Nothing -> case alts of + alt : others -> tryGetArg alt others args + [] -> pure Nothing Just value -> case fromGraphQL value of Just decoded -> pure $ Just decoded @@ -470,15 +479,17 @@ tryGetArg name args = do "Argument " <> T.unpack name <> " has invalid format" +-- Similar to `tryGetArg`, but will error out on failure. getArg :: forall m a . (FromGraphQL a) => (MonadIO m) => Text + -> [Text] -> HashMap Text Value -> m a -getArg name args = do - result <- tryGetArg name args +getArg name alts args = do + result <- tryGetArg name alts args case result of Just value -> pure value Nothing -> @@ -487,16 +498,18 @@ getArg name args = do "Argument " <> T.unpack name <> " not found" +-- Similar to `tryGetArg`, but will return a custom value on failure. getArgWithDefault :: forall m a . (FromGraphQL a) => (MonadIO m) => Text + -> [Text] -> HashMap Text Value -> a -> m a -getArgWithDefault name args def = - tryGetArg name args <&> P.fromMaybe def +getArgWithDefault name alts args def = + tryGetArg name alts args <&> P.fromMaybe def executeUpdateMutation @@ -608,13 +621,8 @@ queryType connection accessMode dbId tables = do rows :: [[SQLData]] <- case context.arguments of Arguments args -> do - filterElements <- case args & HashMap.lookup "filter" of - Nothing -> pure [] - Just colToFilter -> case colToFilter of - Object filterObj -> case HashMap.toList filterObj of - [] -> P.throwIO $ userError "Error: Filter must not be empty" - filterElements -> pure filterElements - _ -> pure [] + filterElements :: HashMap Text Value <- + getArgWithDefault "where" ["filter"] args HashMap.empty orderElements :: [(Name, Value)] <- case args & HashMap.lookup "order_by" of @@ -678,7 +686,7 @@ queryType connection accessMode dbId tables = do [ "SELECT COUNT() FROM" , quoteKeyword table.name , "\n" - , getWhereClause filterElements + , getWhereClause $ HashMap.toList filterElements ] -- Will be equal `Just numRows` when the number of @@ -720,7 +728,7 @@ queryType connection accessMode dbId tables = do connection table.name table.columns - filterElements + (HashMap.toList filterElements) orderElements paginationMb @@ -849,7 +857,7 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do -- [ { name: "John", email: "john@example.com" } -- , { name: "Eve", email: "eve@example.com" } -- ] - values :: [HashMap Text Value] <- getArg "objects" argMap + values :: [HashMap Text Value] <- getArg "objects" [] argMap let -- All colums that are contained in the entries containedColumns :: [Text] @@ -866,12 +874,12 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do <&> (\name -> ":" <> doubleXEncodeGql name) onConflictArg :: [HashMap Text Value] <- - getArgWithDefault "on_conflict" argMap [] + getArgWithDefault "on_conflict" [] argMap [] onConflictClauses <- P.for onConflictArg $ \fields -> do let getColumnList fieldName = - getArgWithDefault fieldName fields [] + getArgWithDefault fieldName [] fields [] <&> P.mapMaybe ( \case Enum columnName -> Just columnName @@ -895,7 +903,7 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do <> " = :" <> doubleXEncodeGql column - filterElements <- getArgWithDefault "where" fields mempty + filterElements <- getArgWithDefault "where" ["filter"] fields mempty pure $ "ON CONFLICT (" @@ -983,8 +991,8 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do context <- ask let Arguments args = context.arguments liftIO $ do - filterObj <- getArg "filter" args - pairsToSet <- getArg "set" args + filterObj <- getArg "where" ["filter"] args + pairsToSet <- getArg "set" [] args (numOfChanges, updatedRows) <- case HashMap.toList filterObj of [] -> P.throwIO $ userError "Error: Filter must not be empty" filterElements -> @@ -1002,11 +1010,16 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do let Arguments args = context.arguments let filterElements = args - & HashMap.delete "set" + & HashMap.delete (encodeOutsidePKNames table "_set") + & HashMap.delete (encodeOutsidePKNames table "set") & getByPKFilterElements liftIO $ do - pairsToSet <- getArg (encodeOutsidePKNames table "set") args + pairsToSet <- + getArg + (encodeOutsidePKNames table "_set") + [encodeOutsidePKNames table "set"] + args (numOfChanges, updatedRows) <- executeUpdateMutation connection @@ -1023,7 +1036,7 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do let Arguments args = context.arguments liftIO $ do - filterElements <- getArg "filter" args + filterElements <- getArg "where" ["filter"] args let sqlQuery = Query $ P.unlines diff --git a/source/AirGQL/Introspection.hs b/source/AirGQL/Introspection.hs index b87ee63..c0232dd 100644 --- a/source/AirGQL/Introspection.hs +++ b/source/AirGQL/Introspection.hs @@ -114,22 +114,30 @@ filterType table = do let colName = columnEntry.column_name_gql let typeName = columnTypeName columnEntry let type_ = columnType columnEntry - Type.inputValue colName $ - Type.withDescription ("Compare to a(n) " <> typeName) $ - Type.inputObject - (typeName <> "Comparison") - [ Type.inputValue "eq" type_ - , Type.inputValue "neq" type_ - , Type.inputValue "gt" type_ - , Type.inputValue "gte" type_ - , Type.inputValue "lt" type_ - , Type.inputValue "lte" type_ - , Type.inputValue "like" type_ - , Type.inputValue "ilike" type_ - , Type.inputValue "in" $ Type.list type_ - , Type.inputValue "nin" $ Type.list type_ + + let comparisonField name ty = + [ Type.deprecatedInputValue "Unify naming with Hasura" $ + Type.inputValue name ty + , Type.inputValue ("_" <> name) ty ] + Type.inputValue colName + $ Type.withDescription ("Compare to a(n) " <> typeName) + $ Type.inputObject + (typeName <> "Comparison") + $ P.fold + [ comparisonField "eq" type_ + , comparisonField "neq" type_ + , comparisonField "gt" type_ + , comparisonField "gte" type_ + , comparisonField "lt" type_ + , comparisonField "lte" type_ + , comparisonField "like" type_ + , comparisonField "ilike" type_ + , comparisonField "in" $ Type.list type_ + , comparisonField "nin" $ Type.list type_ + ] + Type.inputObject (doubleXEncodeGql table.name <> "_filter") fieldsWithComparisonExp @@ -175,9 +183,12 @@ tableQueryField table = (Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table) & Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"") & Type.withArguments - [ Type.inputValue "filter" (filterType table) + ( Type.inputValue "filter" (filterType table) & Type.inputValueWithDescription "Filter to select specific rows" - , Type.inputValue "order_by" (Type.list orderType) + & Type.renameInputValue "where" "Unify naming with Hasura" + ) + & Type.withArguments + [ Type.inputValue "order_by" (Type.list orderType) & Type.inputValueWithDescription "Columns used to sort the data" , Type.inputValue "limit" Type.typeInt & Type.inputValueWithDescription "Limit the number of returned rows" @@ -359,15 +370,19 @@ tableUpdateField accessMode table = do & Type.fieldWithDescription ("Update rows in table \"" <> table.name <> "\"") & Type.withArguments - [ Type.inputValue + ( Type.inputValue "set" (Type.nonNull $ tableSetInput table) & Type.inputValueWithDescription "Fields to be updated" - , Type.inputValue + & Type.renameInputValue "_set" "Unify naming with Hasura" + ) + & Type.withArguments + ( Type.inputValue "filter" (Type.nonNull $ filterType table) & Type.inputValueWithDescription "Filter to select rows to be updated" - ] + & Type.renameInputValue "where" "Unify naming with Hasura" + ) tableUpdateFieldByPk @@ -409,11 +424,12 @@ tableDeleteField accessMode table = do & Type.fieldWithDescription ("Delete rows in table \"" <> table.name <> "\"") & Type.withArguments - [ Type.inputValue + ( Type.inputValue "filter" (Type.nonNull $ filterType table) & Type.inputValueWithDescription "Filter to select rows to be deleted" - ] + & Type.renameInputValue "where" "Unify naming with Hasura" + ) tableDeleteFieldByPK diff --git a/source/AirGQL/Introspection/Resolver.hs b/source/AirGQL/Introspection/Resolver.hs index 95aa6bf..226b689 100644 --- a/source/AirGQL/Introspection/Resolver.hs +++ b/source/AirGQL/Introspection/Resolver.hs @@ -30,6 +30,7 @@ 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.Class (ToGraphQL (toGraphQL)) import Language.GraphQL.Error (ResolverException (ResolverException)) import Language.GraphQL.Type qualified as Type import Language.GraphQL.Type.In qualified as In @@ -120,7 +121,7 @@ makeType = $ HashMap.fromList $ ("__typename", typenameResolver) : resolvers _ -> do - Left $ "invalid type in out position: " <> show ty.kind + Left $ "invalid type in out position: " <> show (toGraphQL ty) -- Creates a field which looks up it's value in the object returned by the -- parent resolver. @@ -152,7 +153,7 @@ makeType = _ -> pure () case HashMap.lookup field.name obj of - Just value -> pure value + Just value -> field.customResolver value Nothing -> defaultValue _ -> defaultValue in @@ -219,4 +220,4 @@ makeInType ty = do ty.description $ HashMap.fromList gqlFields _ -> do - Left $ "invalid type in input position: " <> show ty.kind + Left $ "invalid type in input position: " <> show (toGraphQL ty) diff --git a/source/AirGQL/Introspection/Types.hs b/source/AirGQL/Introspection/Types.hs index 56b757f..7910d37 100644 --- a/source/AirGQL/Introspection/Types.hs +++ b/source/AirGQL/Introspection/Types.hs @@ -16,9 +16,12 @@ module AirGQL.Introspection.Types ( enumValueWithDescription, field, fieldWithDescription, + deprecatedField, inputObject, inputValue, inputValueWithDescription, + deprecatedInputValue, + renameInputValue, list, nonNull, object, @@ -40,13 +43,15 @@ module AirGQL.Introspection.Types ( import Protolude ( Bool (False, True), Generic, + IO, Maybe (Just, Nothing), + MonadReader (ask), MonadState (get, put), Monoid (mempty), - Show, State, Text, execState, + filter, for_, not, pure, @@ -54,8 +59,10 @@ import Protolude ( when, ($), (&), + (/=), (<$>), (<>), + (==), ) import Data.HashMap.Strict (HashMap) @@ -71,7 +78,7 @@ data Schema = Schema , mutationType :: Maybe IntrospectionType , directives :: [Directive] } - deriving (Show, Generic) + deriving (Generic) instance ToGraphQL Schema where @@ -98,14 +105,14 @@ data TypeKind | InputObject Name [InputValue] | List IntrospectionType | NonNull IntrospectionType - deriving (Show, Generic) + deriving (Generic) data IntrospectionType = IType { kind :: TypeKind , description :: Maybe Text } - deriving (Show, Generic) + deriving (Generic) instance ToGraphQL IntrospectionType where @@ -214,8 +221,11 @@ data Field = Field , type_ :: IntrospectionType , isDeprecated :: Bool , deprecationReason :: Maybe Text + , -- Allows injecting custom logic into the resulting resolver. The function + -- is given the value of the field in the parent object. + customResolver :: Value.Value -> Value.Resolve IO } - deriving (Show, Generic) + deriving (Generic) instance ToGraphQL Field where @@ -231,14 +241,6 @@ instance ToGraphQL Field where ] -fieldWithDescription :: Text -> Field -> Field -fieldWithDescription newDesc (Field{..}) = - Field - { description = Just newDesc - , .. - } - - field :: Text -> IntrospectionType -> Field field fieldName fieldType = Field @@ -248,6 +250,24 @@ field fieldName fieldType = , type_ = fieldType , isDeprecated = False , deprecationReason = Nothing + , customResolver = pure + } + + +fieldWithDescription :: Text -> Field -> Field +fieldWithDescription newDesc (Field{..}) = + Field + { description = Just newDesc + , .. + } + + +deprecatedField :: Text -> Field -> Field +deprecatedField reason (Field{..}) = + Field + { isDeprecated = True + , deprecationReason = Just reason + , .. } @@ -260,8 +280,10 @@ data InputValue = InputValue , description :: Maybe Text , type_ :: IntrospectionType , defaultValue :: Maybe Value.Value + , isDeprecated :: Bool + , deprecationReason :: Maybe Text } - deriving (Show, Generic) + deriving (Generic) instance ToGraphQL InputValue where @@ -278,6 +300,8 @@ instance ToGraphQL InputValue where Nothing -> Value.Null Just s -> Value.String $ show s ) + , ("isDeprecated", toGraphQL value.isDeprecated) + , ("deprecationReason", toGraphQL value.deprecationReason) ] @@ -288,6 +312,8 @@ inputValue fieldName fieldType = , description = Nothing , type_ = fieldType , defaultValue = Nothing + , isDeprecated = False + , deprecationReason = Nothing } @@ -307,13 +333,37 @@ withDefaultValue newValue (InputValue{..}) = } +deprecatedInputValue :: Text -> InputValue -> InputValue +deprecatedInputValue reason (InputValue{..}) = + InputValue + { isDeprecated = True + , deprecationReason = Just reason + , .. + } + + +-- Rename an input value, deprecating the old version. +renameInputValue :: Text -> Text -> InputValue -> [InputValue] +renameInputValue to reason (InputValue{..}) = + [ InputValue + { name = to + , .. + } + , InputValue + { isDeprecated = True + , deprecationReason = Just reason + , .. + } + ] + + data EnumValue = EnumValue { name :: Text , description :: Maybe Text , isDeprecated :: Bool , deprecationReason :: Maybe Text } - deriving (Show, Generic) + deriving (Generic) instance ToGraphQL EnumValue where @@ -358,7 +408,7 @@ data Directive = Directive , args :: [InputValue] , isRepeatable :: Bool } - deriving (Generic, Show) + deriving (Generic) instance ToGraphQL Directive where @@ -486,7 +536,8 @@ typeField = "__Field" [ field "name" $ nonNull typeString , field "description" typeString - , field "args" $ nonNull $ list $ nonNull typeInputValue + , field "args" (nonNull $ list $ nonNull typeInputValue) + & typeWithDeprecatedFilter , field "type" $ nonNull typeIntrospectionType , field "isDeprecated" $ nonNull typeBool , field "deprecationReason" typeString @@ -507,6 +558,8 @@ typeInputValue = & fieldWithDescription "A GraphQL-formatted string representing \ \the default value for this input value." + , field "isDeprecated" $ nonNull typeBool + , field "deprecationReason" typeString ] & withDescription "Arguments provided to Fields or Directives and the input \ @@ -574,16 +627,11 @@ typeIntrospectionType = , field "interfaces" $ list $ nonNull typeIntrospectionType , field "possibleTypes" $ list $ nonNull typeIntrospectionType , field "fields" (list $ nonNull typeField) - & withArguments - [ inputValue "includeDeprecated" typeBool - & withDefaultValue (toGraphQL False) - ] + & typeWithDeprecatedFilter , field "enumValues" (list $ nonNull typeEnumValue) - & withArguments - [ inputValue "includeDeprecated" typeBool - & withDefaultValue (toGraphQL False) - ] - , field "inputFields" $ list $ nonNull typeInputValue + & typeWithDeprecatedFilter + , field "inputFields" (list $ nonNull typeInputValue) + & typeWithDeprecatedFilter , field "ofType" typeIntrospectionType ] & withDescription @@ -636,7 +684,11 @@ typeDirective = "__Directive" [ field "name" $ nonNull typeString , field "description" typeString - , field "args" $ nonNull $ list $ nonNull typeInputValue + , field "args" (nonNull $ list $ nonNull typeInputValue) + & withArguments + [ inputValue "includeDeprecated" typeBool + & withDefaultValue (toGraphQL False) + ] , field "isRepeatable" $ nonNull typeBool , field "locations" $ nonNull $ list $ nonNull typeDirectiveLocation ] @@ -700,3 +752,38 @@ typeDirectiveLocation = & withDescription "A Directive can be adjacent to many parts of the GraphQL language, \ \a __DirectiveLocation describes one such possible adjacencies." + + +-- Internal helper which adds custom logic to a resolver for handling the +-- `includeDeprecated` filter. +typeWithDeprecatedFilter :: Field -> Field +typeWithDeprecatedFilter Field{..} = + Field + { args = + args + <> [ inputValue "includeDeprecated" typeBool + & withDefaultValue (toGraphQL False) + ] + , customResolver = \case + Value.List values -> do + context <- ask + let + Value.Arguments gqlArgs = context.arguments + includeDeprecated = HashMap.lookup "includeDeprecated" gqlArgs + filtered = + if includeDeprecated == Just (Value.Boolean True) + then values + else + filter + ( \case + Value.Object fields -> + HashMap.lookup "isDeprecated" fields + /= Just (Value.Boolean True) + _ -> True + ) + values + + customResolver $ Value.List filtered + o -> customResolver o + , .. + }