From 2ee5152d54124bd9afae7c12cac91f653fabe85b Mon Sep 17 00:00:00 2001 From: prescientmoon <git@moonythm.dev> Date: Wed, 9 Oct 2024 20:51:29 +0200 Subject: [PATCH] Implement querying by PK - I realised by PK querying doesn't need arguments like filter, limit or offset, so I removed the argument name collision logic I added in the last commit - I implemented the actual logic for querying by PK. The code is a lot shorter due to the new introspection system - I moved the normal queries to the new introspection system for consistency. - I moved the logic of "coerce columns without a datatype" away from the resolver and into the "sql -> gql" converter as to be able to reuse the default introspection resolver which simply looks up fields into the parent resolver's result. - I added some comments documenting the Introspection.Resolver stuff and it's behaviour --- source/AirGQL/GraphQL.hs | 374 +++++++++++------------- source/AirGQL/Introspection.hs | 85 +++--- source/AirGQL/Introspection/Resolver.hs | 36 ++- 3 files changed, 244 insertions(+), 251 deletions(-) diff --git a/source/AirGQL/GraphQL.hs b/source/AirGQL/GraphQL.hs index d678437..22927a0 100644 --- a/source/AirGQL/GraphQL.hs +++ b/source/AirGQL/GraphQL.hs @@ -75,7 +75,7 @@ import Language.GraphQL.Type as GQL ( EnumValue (EnumValue), InputField (InputField), Resolver (EventStreamResolver, ValueResolver), - ScalarType, + ScalarType (ScalarType), Schema, Value (Boolean, Enum, Float, Int, List, Null, Object, String), boolean, @@ -101,12 +101,15 @@ import AirGQL.GQLWrapper ( inArgumentToArgument, outFieldToField, ) + import AirGQL.Introspection (getSchemaResolver, typeNameResolver) +import AirGQL.Introspection qualified as Introspection +import AirGQL.Introspection.Resolver qualified as Introspection import AirGQL.Lib ( AccessMode (ReadAndWrite, ReadOnly, WriteOnly), ColumnEntry (column_name, datatype, datatype_gql), GqlTypeName (root), - TableEntry (name), + TableEntry (columns, name), column_name_gql, getColumns, ) @@ -120,10 +123,14 @@ import AirGQL.Types.SchemaConf ( ) import AirGQL.Types.Utils (encodeToText) import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText) +import Data.Either.Extra qualified as Either typeNameToScalarType :: Maybe GqlTypeName -> ScalarType -typeNameToScalarType Nothing = string +typeNameToScalarType Nothing = + ScalarType + "any" + (Just "A type that could result to any kind of GQL scalar") typeNameToScalarType (Just typeName) = case typeName.root of "Int" -> int @@ -195,13 +202,7 @@ colNamesWithValResolver columnEntries = Object obj -> case obj & HashMap.lookup colEntry.column_name_gql of Nothing -> String "Error: Field does not exist" - Just val -> - case colEntry.datatype of - -- Coerce value to nullable String - -- if no datatype is set. - -- This happens for columns in views. - "" -> gqlValueToNullableString val - _ -> val + Just val -> val _ -> String "Error: Value could not be retrieved" in ( colEntry.column_name_gql @@ -441,141 +442,15 @@ queryType connection accessMode dbId tables = do documentation = "Available queries for database \"" <> dbId <> "\"" - getOutField :: Text -> IO (Out.Field IO) - getOutField tableName = do - columnEntries <- liftIO $ getColumns dbId connection tableName + getOutField :: TableEntry -> IO (Out.Field IO) + getOutField table = + case Introspection.makeField $ Introspection.tableQueryField table of + Left err -> P.throwIO $ userError $ T.unpack err + Right result -> pure result - let - colNamesWithOrderingTerm :: [(Text, InputField)] - colNamesWithOrderingTerm = - columnEntries <&> \colEntry -> - ( colEntry.column_name_gql - , InputField - (Just $ "Ordering term for " <> colEntry.column_name_gql) - ( In.NamedEnumType $ - EnumType - "OrderingTerm" - (Just "Ordering object for the column") - ( HashMap.fromList - [ ("ASC", EnumValue (Just "ASC")) - , ("asc", EnumValue (Just "ASC")) - , ("DESC", EnumValue (Just "DESC")) - , ("desc", EnumValue (Just "DESC")) - ] - ) - ) - Nothing -- Default value - ) - - typeNameField :: Text -> [(Text, Resolver IO)] - typeNameField nameOfTable = - let - typeNameOutField = - outFieldToField $ - OutField - { descriptionMb = Just $ "The type name of " <> nameOfTable - , fieldType = Out.NonNullScalarType string - , arguments = HashMap.empty - } - in - [ - ( "__typename" - , ValueResolver typeNameOutField $ - pure $ - String $ - doubleXEncodeGql nameOfTable <> "_row" - ) - ] - - pure $ - outFieldToField $ - OutField - { descriptionMb = Just $ "Provides entries from " <> tableName - , fieldType = - Out.ListType $ - Out.NamedObjectType $ - Out.ObjectType - tableName - (Just "short desc") - [] - ( HashMap.fromList $ - colNamesWithValResolver columnEntries - <> typeNameField tableName - ) - , arguments = - HashMap.fromList - [ - ( "filter" - , inArgumentToArgument $ - InArgument - { argDescMb = Just "Filter objects" - , argType = - NamedInputObjectType $ - InputObjectType - (doubleXEncodeGql tableName <> "_filter") - ( Just - "Filter objects for the specified columns" - ) - (HashMap.fromList (colNamesWithFilterField tableName columnEntries)) - , valueMb = Nothing - } - ) - , - ( "order_by" - , inArgumentToArgument $ - InArgument - { argDescMb = Just "Order by the specified columns" - , argType = - In.ListType $ - In.NamedInputObjectType $ - InputObjectType - (doubleXEncodeGql tableName <> "_order_by") - (Just "Options for ordering by columns") - (HashMap.fromList colNamesWithOrderingTerm) - , valueMb = Nothing - } - ) - , - ( "limit" - , inArgumentToArgument $ - InArgument - { argDescMb = - Just "Limit the number of returned rows." - , argType = In.NamedScalarType int - , valueMb = Nothing - } - ) - , - ( "offset" - , inArgumentToArgument $ - InArgument - { argDescMb = - Just - "Change the index rows \ - \start being returned from" - , argType = In.NamedScalarType int - , valueMb = Nothing - } - ) - ] - } - -- -- TODO: Use for retrieving record by primary key - -- , arguments = HashMap.fromList $ columnEntries - -- <&> (\colEntry -> - -- ( colEntry.column_name_gql :: Text - -- , inArgumentToArgument $ InArgument - -- { argDescMb = Just "Retrieve object by primary key" - -- , argType = In.NamedScalarType $ - -- typeNameToScalarType $ colEntry.datatype - -- , valueMb = Nothing - -- } - -- ) - -- ) - - getDbEntries :: Text -> Out.Resolve IO - getDbEntries tableName = do + getDbEntries :: TableEntry -> Out.Resolve IO + getDbEntries table = do context <- ask - colEntries <- liftIO $ getColumns dbId connection tableName rows :: [[SQLData]] <- case context.arguments of Arguments args -> do @@ -647,7 +522,7 @@ queryType connection accessMode dbId tables = do Query $ P.fold [ "SELECT COUNT() FROM" - , quoteKeyword tableName + , quoteKeyword table.name , "\n" , getWhereClause filterElements ] @@ -682,68 +557,105 @@ queryType connection accessMode dbId tables = do , show numRows , " rows. " , "Consider setting the `limit` argument on your query: `{ " - , T.unpack tableName + , T.unpack table.name , " (limit: 50) { ... } }`" ] liftIO $ executeSqlQuery connection - tableName - colEntries + table.name + table.columns filterElements orderElements paginationMb - rowsToList dbId tableName colEntries rows + colErrorsToUserError $ rowsToGraphQL dbId table.name table.columns rows + + getOutByPKField :: TableEntry -> IO (Maybe (Out.Field IO)) + getOutByPKField tableEntry = do + let fieldMb = Introspection.tableQueryByPKField tableEntry + P.for fieldMb $ \field -> do + let fieldEither = Introspection.makeField field + + case fieldEither of + Left err -> P.throwIO $ userError $ T.unpack err + Right result -> pure result + + getDbEntriesByPK :: TableEntry -> Out.Resolve IO + getDbEntriesByPK tableEntry = do + context <- ask + + let + Arguments args = context.arguments + filterElements = do + (key, value) <- HashMap.toList args + pure (key, Object $ HashMap.singleton "eq" value) + + -- This query can return at most one row, so we don't worry checking for + -- COUNT() and asserting it's within the set limits. + queryResult <- + liftIO $ + executeSqlQuery + connection + tableEntry.name + tableEntry.columns + filterElements + [] + Nothing + + case P.head queryResult of + Nothing -> pure Null + Just row -> + colErrorsToUserError $ + rowToGraphQL + dbId + tableEntry.name + tableEntry.columns + row getResolvers :: IO (HashMap.HashMap Text (Resolver IO)) getResolvers = do let + -- Exceptions must be converted to ResolverExceptions + -- to be picked up by GQL query executor + wrapResolver :: Out.Resolve IO -> Out.Resolve IO + wrapResolver resolver = + catchAll + resolver + (throw . ResolverException) + getTableTuple :: TableEntry -> IO (Text, Resolver IO) getTableTuple table = do - outField <- getOutField table.name + outField <- getOutField table pure ( doubleXEncodeGql table.name , ValueResolver outField - ( -- Exceptions must be converted to ResolverExceptions - -- to be picked up by GQL query executor - catchAll - (getDbEntries table.name) - (throw . ResolverException) - ) + $ wrapResolver + $ getDbEntries table ) - getTableTuples :: IO [(Text, Resolver IO)] - getTableTuples = - P.for tables getTableTuple + getTableByPKTuple :: TableEntry -> IO (Maybe (Text, Resolver IO)) + getTableByPKTuple table = do + fieldMb <- getOutByPKField table + P.for fieldMb $ \outField -> do + pure + ( doubleXEncodeGql $ table.name <> "_by_pk" + , ValueResolver + outField + $ wrapResolver + $ getDbEntriesByPK table + ) - getTableTuples <&> HashMap.fromList - - -- -- TODO: Add support for retriving record by ID - -- getResolversPrimaryKey :: IO (HashMap.HashMap Text (Resolver IO)) - -- getResolversPrimaryKey = do - -- let - -- getTableTuple table = do - -- outField <- getOutField $ table.name - -- pure - -- ( table.name) <> "_by_pk" - -- , ValueResolver - -- outField - -- (getDbEntries $ table.name) - -- ) - - -- getTableTuples :: IO [(Text, Resolver IO)] - -- getTableTuples = - -- sequence $ tables <&> getTableTuple - - -- getTableTuples <&> HashMap.fromList + queryMany <- P.for tables getTableTuple + queryByPKMbs <- P.for tables getTableByPKTuple + let queryByPK = P.catMaybes queryByPKMbs + pure $ HashMap.fromList $ queryMany <> queryByPK resolvers <- getResolvers schemaResolver <- getSchemaResolver accessMode tables - -- resolversPrimaryKey <- getResolversPrimaryKey let -- Resolve = ReaderT Context m Value wrapResolve resolve = do @@ -923,8 +835,64 @@ getMutationResponse accessMode tableName columnEntries = } -rowsToList :: (MonadIO m) => Text -> Text -> [ColumnEntry] -> [[SQLData]] -> m Value -rowsToList dbId tableName columnEntries updatedRows = +rowToGraphQL :: Text -> Text -> [ColumnEntry] -> [SQLData] -> Either [(Text, Text)] Value +rowToGraphQL dbId tableName columnEntries row = + let + buildMetadataJson :: Text -> Text -> Text + buildMetadataJson colName rowid = + object ["url" .= colToFileUrl dbId tableName colName rowid] + & encodeToText + + parseSqlData :: (ColumnEntry, SQLData) -> Either (Text, Text) (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 + ) + else case sqlDataToGQLValue colEntry.datatype colVal of + Left err -> + Left + (colEntry.column_name_gql, 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 + ) + in + -- => [(ColumnEntry, SQLData)] + P.zip columnEntries row + -- => [Either (Text, Text) (Text, Value)] + <&> parseSqlData + -- => Either [(Text, Text)] (Text, Value) + & collectErrorList + -- => Either [(Text, Text)] (HashMap Text Value) + <&> HashMap.fromList + -- => Either [(Text, Text)] Value + <&> Object + + +rowsToGraphQL + :: Text + -> Text + -> [ColumnEntry] + -> [[SQLData]] + -> Either [(Text, Text)] Value +rowsToGraphQL dbId tableName columnEntries updatedRows = let buildMetadataJson :: Text -> Text -> Text buildMetadataJson colName rowid = @@ -970,18 +938,26 @@ rowsToList dbId tableName columnEntries updatedRows = ) -- => Either [[(Text, Text)]] [Value] & collectErrorList - & \case - Right values -> pure $ List values - Left errors -> - let - errorLines = - P.join errors - <&> \(column, err) -> "On column " <> show column <> ": " <> err - in - P.throwIO $ - userError $ - T.unpack $ - "Multiple errors occurred:\n" <> P.unlines errorLines + -- => 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 executeSqlMutation @@ -1403,7 +1379,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do pure (P.length sqlDataRows, returnedRows & P.concat) (numOfChanges, returnedRows) <- insertInDb context.arguments - returning <- rowsToList dbId tableName columnEntries returnedRows + returning <- + colErrorsToUserError $ + rowsToGraphQL dbId tableName columnEntries returnedRows pure $ Object $ @@ -1434,7 +1412,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do filterElements _ -> pure (0, []) - returning <- rowsToList dbId tableName columnEntries updatedRows + returning <- + colErrorsToUserError $ + rowsToGraphQL dbId tableName columnEntries updatedRows pure $ Object $ @@ -1491,7 +1471,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do _ -> pure (0, []) Nothing -> pure (0, []) - returning <- rowsToList dbId tableName columnEntries deletedRows + returning <- + colErrorsToUserError $ + rowsToGraphQL dbId tableName columnEntries deletedRows pure $ Object $ diff --git a/source/AirGQL/Introspection.hs b/source/AirGQL/Introspection.hs index abac643..084eaff 100644 --- a/source/AirGQL/Introspection.hs +++ b/source/AirGQL/Introspection.hs @@ -1,6 +1,8 @@ module AirGQL.Introspection ( typeNameResolver, getSchemaResolver, + tableQueryField, + tableQueryByPKField, ) where @@ -131,8 +133,8 @@ tableRowType table = do Type.object (doubleXEncodeGql table.name <> "_row") fields -tableQueryCommonArgs :: TableEntry -> [Type.InputValue] -tableQueryCommonArgs table = +tableQueryField :: TableEntry -> Type.Field +tableQueryField table = let fieldsWithOrderingTerm = table.columns <&> \columnEntry -> do @@ -149,62 +151,47 @@ tableQueryCommonArgs table = <> "\"." ) in - [ Type.inputValue "filter" (filterType table) - & Type.inputValueWithDescription "Filter to select specific rows" - , 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" - , Type.inputValue "offset" Type.typeInt - & Type.inputValueWithDescription "The index to start returning rows from" - ] + Type.field + (doubleXEncodeGql table.name) + (Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table) + & Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"") + & Type.withArguments + [ Type.inputValue "filter" (filterType table) + & Type.inputValueWithDescription "Filter to select specific rows" + , 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" + , Type.inputValue "offset" Type.typeInt + & Type.inputValueWithDescription "The index to start returning rows from" + ] -tableQueryField :: TableEntry -> Type.Field -tableQueryField table = - Type.field - (doubleXEncodeGql table.name) - (Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table) - & Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"") - & Type.withArguments (tableQueryCommonArgs table) - - -restrictedArgNames :: [Text] -restrictedArgNames = ["limit", "offset", "order_by", "filter"] - - -mkArgName :: Text -> Text -mkArgName name = do - let encoded = doubleXEncodeGql name - if P.elem encoded restrictedArgNames - then encoded <> "_" - else encoded - - -tableQueryByPk :: TableEntry -> Type.Field -tableQueryByPk table = do +tableQueryByPKField :: TableEntry -> Maybe Type.Field +tableQueryByPKField table = do let pks = List.filter (\col -> col.primary_key) table.columns -- We filter out the rowid column, unless it is the only one - let withoutRowid = case pks of - [first] | first.isRowid -> [first] - _ -> List.filter (\col -> P.not col.isRowid) pks + withoutRowid <- case pks of + [] -> Nothing + [first] | first.isRowid -> Just [first] + _ -> Just $ List.filter (\col -> P.not col.isRowid) pks let pkArguments = withoutRowid <&> \column -> do - let name = mkArgName column.column_name_gql + let name = doubleXEncodeGql column.column_name_gql Type.inputValue name $ Type.nonNull $ columnType column - Type.field - (doubleXEncodeGql table.name <> "_by_pk") - (tableRowType table) - & Type.fieldWithDescription - ( "Rows from the table \"" - <> table.name - <> "\", accessible by their primary key" - ) - & Type.withArguments (tableQueryCommonArgs table) - & Type.withArguments pkArguments + pure $ + Type.field + (doubleXEncodeGql table.name <> "_by_pk") + (tableRowType table) + & Type.fieldWithDescription + ( "Rows from the table \"" + <> table.name + <> "\", accessible by their primary key" + ) + & Type.withArguments pkArguments mutationResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType @@ -363,7 +350,7 @@ getSchema accessMode tables = do then P.fold [ tables <&> tableQueryField - , tables <&> tableQueryByPk + , tables & P.mapMaybe tableQueryByPKField ] else [] diff --git a/source/AirGQL/Introspection/Resolver.hs b/source/AirGQL/Introspection/Resolver.hs index ff90ac5..571a703 100644 --- a/source/AirGQL/Introspection/Resolver.hs +++ b/source/AirGQL/Introspection/Resolver.hs @@ -1,4 +1,8 @@ -module AirGQL.Introspection.Resolver (makeType, makeConstField) where +module AirGQL.Introspection.Resolver ( + makeType, + makeConstField, + makeField, +) where import Protolude ( Either (Left), @@ -7,7 +11,6 @@ import Protolude ( MonadReader (ask), Text, fromMaybe, - mempty, pure, show, ($), @@ -29,6 +32,12 @@ import Language.GraphQL.Type.Out qualified as Out type Result = Either Text +{-| 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. + +Lookups for `__Type` objects are memoized, and a maximum depth of 30 is enforced. +-} makeType :: IType.IntrospectionType -> Result (Out.Type IO) makeType = let @@ -91,8 +100,7 @@ makeType = $ Out.NamedObjectType $ Type.ObjectType name - -- ty.description - P.Nothing + ty.description [] $ HashMap.fromList $ ("__typename", typenameResolver) : resolvers @@ -131,13 +139,29 @@ makeType = makeTypeWithDepth 0 +{-| Turns a field descriptor into a graphql field. See the documentation +for `makeType` for details about the behaviour of child resolvers. +-} +makeField :: IType.Field -> Result (Out.Field IO) +makeField field = do + args <- P.for field.args $ \arg -> do + ty <- makeInType arg.type_ + pure (arg.name, In.Argument arg.description ty arg.defaultValue) + ty <- makeType field.type_ + pure $ Out.Field field.description ty $ HashMap.fromList args + + +-- | Create a resolver by calling which always returns a constant value. makeConstField :: IType.Field -> Type.Value -> Result (Out.Resolver IO) makeConstField field value = do - ty <- makeType field.type_ - let gqlField = Out.Field field.description ty mempty + gqlField <- makeField field pure $ Out.ValueResolver gqlField $ pure value +{-| The input-type version of `makeOutType`. No maximum depth is enforced, nor +is any memoization used. This is the case because input types are usually pretty +shallow. +-} makeInType :: IType.IntrospectionType -> Result In.Type makeInType ty = do case ty.kind of