diff --git a/source/AirGQL/GraphQL.hs b/source/AirGQL/GraphQL.hs index 92b01aa..cc7d4e2 100644 --- a/source/AirGQL/GraphQL.hs +++ b/source/AirGQL/GraphQL.hs @@ -47,6 +47,7 @@ import Protolude qualified as P import Control.Exception (throw) import Control.Monad.Catch (catchAll) import Data.Aeson (object, (.=)) +import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict qualified as HashMap import Data.List (nub) import Data.Ord (Ord (min)) @@ -91,7 +92,6 @@ import AirGQL.Lib ( canRead, canWrite, column_name_gql, - getColumns, ) import AirGQL.Types.OutObjectType ( OutObjectType (OutObjectType, descriptionMb, fields, interfaceTypes, name), @@ -105,6 +105,7 @@ import AirGQL.Types.Utils (encodeToText) import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText) import Data.Either.Extra qualified as Either import Data.List qualified as List +import Language.GraphQL.Class (FromGraphQL (fromGraphQL)) -- | Prevent numbers of being shown with exponents (0.01 instead of 1e-2) @@ -268,6 +269,25 @@ getWhereClause filterElements = ) +-- The gql lib does not offer a way to know what properties have +-- been requested at the moment, so we always return every column +getReturningClause :: TableEntry -> Text +getReturningClause table = + "RETURNING " + <> ( table.columns + <&> column_name + <&> quoteKeyword + & intercalate ", " + ) + + +-- | Converts an argument map of (pk, value) pairs into a list of filters +getByPKFilterElements :: HashMap Text Value -> [(Text, Value)] +getByPKFilterElements args = do + (key, value) <- HashMap.toList args + pure (key, Object $ HashMap.singleton "eq" value) + + setCaseInsensitive :: Connection -> [(Text, Value)] -> IO () setCaseInsensitive connection filterElements = do when @@ -365,12 +385,12 @@ gqlValueToSQLData = \case Object obj -> SQLText $ show obj -rowToGraphQL :: Text -> Text -> [ColumnEntry] -> [SQLData] -> Either [(Text, Text)] Value -rowToGraphQL dbId tableName columnEntries row = +rowToGraphQL :: Text -> TableEntry -> [SQLData] -> Either [(Text, Text)] Value +rowToGraphQL dbId table row = let buildMetadataJson :: Text -> Text -> Text buildMetadataJson colName rowid = - object ["url" .= colToFileUrl dbId tableName colName rowid] + object ["url" .= colToFileUrl dbId table.name colName rowid] & encodeToText parseSqlData :: (ColumnEntry, SQLData) -> Either (Text, Text) (Text, Value) @@ -405,7 +425,7 @@ rowToGraphQL dbId tableName columnEntries row = ) in -- => [(ColumnEntry, SQLData)] - P.zip columnEntries row + P.zip table.columns row -- => [Either (Text, Text) (Text, Value)] <&> parseSqlData -- => Either [(Text, Text)] (Text, Value) @@ -418,14 +438,13 @@ rowToGraphQL dbId tableName columnEntries row = rowsToGraphQL :: Text - -> Text - -> [ColumnEntry] + -> TableEntry -> [[SQLData]] -> Either [(Text, Text)] Value -rowsToGraphQL dbId tableName columnEntries updatedRows = +rowsToGraphQL dbId table updatedRows = updatedRows -- => [Either [(Text, Text)] Value] - <&> rowToGraphQL dbId tableName columnEntries + <&> rowToGraphQL dbId table -- => Either [[(Text, Text)]] [Value] & collectErrorList -- => Either [(Text, Text)] [Value] @@ -450,100 +469,106 @@ colErrorsToUserError = \case "Multiple errors occurred:\n" <> P.unlines errorLines -executeSqlMutation +tryGetArg + :: forall m a + . (FromGraphQL a) + => (MonadIO m) + => Text + -> HashMap Text Value + -> m (Maybe a) +tryGetArg name args = do + case HashMap.lookup name args of + Nothing -> pure Nothing + Just value -> + case fromGraphQL value of + Just decoded -> pure $ Just decoded + Nothing -> + P.throwIO $ + userError $ + "Argument " <> T.unpack name <> " has invalid format" + + +getArg + :: forall m a + . (FromGraphQL a) + => (MonadIO m) + => Text + -> HashMap Text Value + -> m a +getArg name args = do + result <- tryGetArg name args + case result of + Just value -> pure value + Nothing -> + P.throwIO $ + userError $ + "Argument " <> T.unpack name <> " not found" + + +getArgWithDefault + :: forall m a + . (FromGraphQL a) + => (MonadIO m) + => Text + -> HashMap Text Value + -> a + -> m a +getArgWithDefault name args def = + tryGetArg name args <&> P.fromMaybe def + + +executeUpdateMutation :: Connection - -> Text - -> HashMap.HashMap Text Value - -> [ColumnEntry] + -> TableEntry + -> HashMap Text Value -> [(Text, Value)] -> IO (Int, [[SQLData]]) -executeSqlMutation connection tableName args columnEntries filterElements = do +executeUpdateMutation connection table args filterElements = do + pairsToSet :: HashMap Text Value <- getArg "set" args let - colNamesToUpdateRaw :: [Text] - colNamesToUpdateRaw = - case HashMap.lookup "set" args of - Just (Object dataObj) -> HashMap.keys dataObj - _ -> [] + columnsToSet :: [(ColumnEntry, Value)] + columnsToSet = + table.columns + & P.mapMaybe + ( \col -> case HashMap.lookup col.column_name_gql pairsToSet of + Just value -> Just (col, value) + _ -> Nothing + ) - colNamesToUpdate :: [Text] - colNamesToUpdate = - columnEntries - <&> column_name - <&> ( \columnName -> - if doubleXEncodeGql columnName `P.elem` colNamesToUpdateRaw - then Just columnName - else Nothing - ) - & P.catMaybes - - columnNamesText :: Text - columnNamesText = - columnEntries - <&> column_name - <&> quoteKeyword + columnsToSetText :: Text + columnsToSetText = + columnsToSet + <&> (\(col, _) -> quoteKeyword col.column_name <> " = ?") & intercalate ", " - setText :: Text - setText = - colNamesToUpdate - <&> (\columnName -> quoteKeyword columnName <> " = ?") - & intercalate ", " - - valuesToSet :: [SQLData] - valuesToSet = - case HashMap.lookup "set" args of - Just (Object dataObj) -> - columnEntries - <&> column_name - <&> ( \columnName -> - HashMap.lookup - (doubleXEncodeGql columnName) - dataObj - ) - & P.catMaybes - <&> gqlValueToSQLData - _ -> [] - updatedRows :: [[SQLData]] <- - if setText == "" + if P.null columnsToSet then pure [] - else + else liftIO $ do let sqlQuery = Query $ "UPDATE " - <> quoteKeyword tableName + <> quoteKeyword table.name <> "\n" <> "SET " - <> setText + <> columnsToSetText <> "\n" <> getWhereClause filterElements <> "\n" - <> "RETURNING " - <> columnNamesText - - colTypesToUpdate :: [Text] - colTypesToUpdate = - columnEntries - <&> ( \colEntry -> - if doubleXEncodeGql colEntry.column_name - `P.elem` colNamesToUpdateRaw - then Just colEntry.datatype - else Nothing - ) - & P.catMaybes + <> getReturningClause table valuesToSetNorm = - P.zip valuesToSet colTypesToUpdate - <&> \(val, datatype) -> - if (val == SQLText "{}") - P.&& ("BLOB" `T.isPrefixOf` T.toUpper datatype) + columnsToSet + <&> \(col, gqlValue) -> do + let sqlValue = gqlValueToSQLData gqlValue + if (sqlValue == SQLText "{}") + P.&& ("BLOB" `T.isPrefixOf` T.toUpper col.datatype) then SQLBlob "" - else val - in - liftIO $ do - setCaseInsensitive connection filterElements - query connection sqlQuery valuesToSetNorm + else sqlValue + + setCaseInsensitive connection filterElements + query connection sqlQuery valuesToSetNorm liftIO $ changes connection @@ -706,17 +731,12 @@ queryType connection accessMode dbId tables = do orderElements paginationMb - colErrorsToUserError $ rowsToGraphQL dbId table.name table.columns rows + colErrorsToUserError $ rowsToGraphQL dbId table rows 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) + let Arguments args = context.arguments -- This query can return at most one row, so we don't worry checking for -- COUNT() and asserting it's within the set limits. @@ -726,7 +746,7 @@ queryType connection accessMode dbId tables = do connection tableEntry.name tableEntry.columns - filterElements + (getByPKFilterElements args) [] Nothing @@ -736,8 +756,7 @@ queryType connection accessMode dbId tables = do colErrorsToUserError $ rowToGraphQL dbId - tableEntry.name - tableEntry.columns + tableEntry row getResolvers :: IO (HashMap.HashMap Text (Resolver IO)) @@ -806,105 +825,102 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do getColValue rowObj columnName = HashMap.findWithDefault Null (doubleXEncodeGql columnName) rowObj - executeDbInserts :: Text -> ReaderT Out.Context IO Value - executeDbInserts tableName = do - columnEntries <- liftIO $ getColumns dbId connection tableName + mutationResponse :: TableEntry -> Int -> [[SQLData]] -> IO Value + mutationResponse table numChanges rows = do + returning <- + colErrorsToUserError $ + rowsToGraphQL dbId table rows + pure $ + Object $ + HashMap.fromList + [ ("affected_rows", Int $ fromIntegral numChanges) + , ("returning", returning) + ] + + 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 + + pure $ + Object $ + HashMap.fromList + [ ("affected_rows", Int $ fromIntegral numChanges) + , ("returning", returning) + ] + + executeDbInserts :: TableEntry -> ReaderT Out.Context IO Value + executeDbInserts table = do context <- ask let - columnNames :: [Text] - columnNames = - columnEntries <&> column_name - - columnNamesText :: Text - columnNamesText = - columnNames - <&> quoteKeyword - & intercalate ", " - insertInDb :: Arguments -> ReaderT Out.Context IO (Int, [[SQLData]]) insertInDb (Arguments argMap) = do + -- Yields for example: + -- [ { name: "John", email: "john@example.com" } + -- , { name: "Eve", email: "eve@example.com" } + -- ] + values :: [HashMap Text Value] <- getArg "objects" argMap let - -- Yields for example: - -- [ { name: "John", email: "john@example.com" } - -- , { name: "Eve", email: "eve@example.com" } - -- ] - entries = - HashMap.findWithDefault - (List []) - "objects" - argMap - -- All colums that are contained in the entries containedColumns :: [Text] containedColumns = - case entries of - List values -> - ( values - <&> \case - Object rowObj -> - HashMap.keys rowObj - _ -> [] - ) - & P.concat - & nub - <&> doubleXDecode - _ -> [] + values + <&> HashMap.keys + & P.concat + & nub + <&> doubleXDecode boundVariableNames :: [Text] boundVariableNames = containedColumns <&> (\name -> ":" <> doubleXEncodeGql name) - onConflictArg = - case HashMap.lookup "on_conflict" argMap of - Just (List values) -> values - _ -> [] + onConflictArg :: [HashMap Text Value] <- + getArgWithDefault "on_conflict" argMap [] - onConflictClauses <- P.for onConflictArg $ \case - Object fields -> do - let - getColumnList fieldName = do - case HashMap.lookup fieldName fields of - Just (List elements) -> do - element <- elements - case element of - Enum columnName -> pure columnName - _ -> [] - _ -> [] + onConflictClauses <- P.for onConflictArg $ \fields -> do + let + getColumnList fieldName = + getArgWithDefault fieldName fields [] + <&> P.mapMaybe + ( \case + Enum columnName -> Just columnName + _ -> Nothing + ) - constraint = getColumnList "constraint" - update = getColumnList "update_columns" + constraint <- getColumnList "constraint" + update <- getColumnList "update_columns" - updateClauses <- P.for update $ \column -> do - when (column `notElem` containedColumns) $ do - P.throwIO $ - userError $ - "Column " - <> T.unpack column - <> " cannot be set on conflicts without being explicitly provided" - - pure $ - quoteKeyword column - <> " = :" - <> doubleXEncodeGql column - - let - filterElements = case HashMap.lookup "where" fields of - Just (Object filterObj) -> HashMap.toList filterObj - _ -> [] + updateClauses <- P.for update $ \column -> do + when (column `notElem` containedColumns) $ do + P.throwIO $ + userError $ + "Column " + <> T.unpack column + <> " cannot be set on conflicts without \ + \ being explicitly provided" pure $ - "ON CONFLICT (" - <> ( constraint - <&> quoteKeyword - & intercalate "<>" - ) - <> ")\n DO UPDATE SET \n" - <> intercalate ",\n" updateClauses - <> "\n" - <> getWhereClause filterElements - _ -> pure "" + quoteKeyword column + <> " = :" + <> doubleXEncodeGql column + + filterElements <- getArgWithDefault "where" fields mempty + + pure $ + "ON CONFLICT (" + <> ( constraint + <&> quoteKeyword + & intercalate ", " + ) + <> ")\n DO UPDATE SET \n" + <> intercalate ",\n" updateClauses + <> "\n" + <> getWhereClause (HashMap.toList filterElements) let columnList = @@ -926,28 +942,21 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do <> ")" sqlQuery = Query $ - "INSERT INTO " - <> quoteKeyword tableName - <> columnList - <> insertedValues - <> "\n" - <> P.unlines onConflictClauses - <> "RETURNING " - <> - -- TODO: Only return the actually requested values - columnNamesText + P.unlines + [ "INSERT INTO " + <> quoteKeyword table.name + <> columnList + , insertedValues + , P.unlines onConflictClauses + , getReturningClause table + ] sqlDataRows :: [[SQLData]] sqlDataRows = - case entries of - List values -> - values <&> \case - Object rowObj -> - containedColumns - <&> getColValue rowObj - <&> gqlValueToSQLData - _ -> [] - _ -> [] + values <&> \rowObj -> + containedColumns + <&> getColValue rowObj + <&> gqlValueToSQLData returnedRows <- liftIO $ P.forM sqlDataRows $ \sqlDataRow -> do @@ -956,7 +965,7 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do connection $ Query $ "SELECT COUNT() FROM " - <> quoteKeyword tableName + <> quoteKeyword table.name case numRowsRes of [[numRows]] -> do @@ -980,106 +989,82 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do pure (P.length sqlDataRows, returnedRows & P.concat) (numOfChanges, returnedRows) <- insertInDb context.arguments - returning <- - colErrorsToUserError $ - rowsToGraphQL dbId tableName columnEntries returnedRows - - pure $ - Object $ - HashMap.fromList - [ ("affected_rows", Int $ fromIntegral numOfChanges) - , ("returning", returning) - ] + liftIO $ mutationResponse table numOfChanges returnedRows -- Execute SQL query to update selected entries - executeDbUpdates :: Text -> ReaderT Out.Context IO Value - executeDbUpdates tableName = do - columnEntries <- liftIO $ getColumns dbId connection tableName - + executeDbUpdates :: TableEntry -> ReaderT Out.Context IO Value + executeDbUpdates table = do context <- ask - let Arguments args = context.arguments - - (numOfChanges, updatedRows) <- case HashMap.lookup "filter" args of - Just (Object filterObj) -> case HashMap.toList filterObj of + liftIO $ do + filterObj <- getArg "filter" args + (numOfChanges, updatedRows) <- case HashMap.toList filterObj of [] -> P.throwIO $ userError "Error: Filter must not be empty" filterElements -> - liftIO $ - executeSqlMutation - connection - tableName - args - columnEntries - filterElements - _ -> pure (0, []) + executeUpdateMutation + connection + table + args + filterElements - returning <- - colErrorsToUserError $ - rowsToGraphQL dbId tableName columnEntries updatedRows + mutationResponse table numOfChanges updatedRows - pure $ - Object $ - HashMap.fromList - [ ("affected_rows", Int $ fromIntegral (numOfChanges :: Int)) - , ("returning", returning) - ] + executeDbUpdatesByPK :: TableEntry -> ReaderT Out.Context IO Value + executeDbUpdatesByPK table = do + context <- ask + let Arguments args = context.arguments + let filterElements = + args + & HashMap.delete "set" + & getByPKFilterElements + + liftIO $ do + (numOfChanges, updatedRows) <- + executeUpdateMutation + connection + table + args + filterElements + + mutationByPKResponse table numOfChanges $ P.head updatedRows -- Execute SQL query to delete selected entries - executeDbDeletions :: Text -> ReaderT Out.Context IO Value - executeDbDeletions tableName = do - columnEntries <- liftIO $ getColumns dbId connection tableName + executeDbDeletions :: TableEntry -> ReaderT Out.Context IO Value + executeDbDeletions table = do context <- ask + let Arguments args = context.arguments - let - columnNamesText :: Text - columnNamesText = - columnEntries - <&> column_name - <&> quoteKeyword - & intercalate ", " + liftIO $ do + filterElements <- getArg "filter" args + let sqlQuery = + Query $ + P.unlines + [ "DELETE FROM " <> quoteKeyword table.name + , getWhereClause $ HashMap.toList filterElements + , getReturningClause table + ] - deleteEntry columnName value = do - let sqlQuery = - Query $ - "DELETE FROM " - <> quoteKeyword tableName - <> " \ - \WHERE " - <> quoteKeyword columnName - <> " = ?\n" - <> "RETURNING " - <> columnNamesText - deletedRows :: [[SQLData]] <- - liftIO $ SS.query connection sqlQuery [value] - numChanges <- liftIO $ changes connection + deletedRows :: [[SQLData]] <- SS.query_ connection sqlQuery + numOfChanges <- SS.changes connection + mutationResponse table numOfChanges deletedRows - pure (numChanges, deletedRows) + executeDbDeletionsByPK :: TableEntry -> ReaderT Out.Context IO Value + executeDbDeletionsByPK table = do + context <- ask + let Arguments args = context.arguments - (numOfChanges, deletedRows) <- case context.arguments of - Arguments args -> case HashMap.lookup "filter" args of - Just colToFilter -> case colToFilter of - Object filterObj -> case HashMap.toList filterObj of - [(columnName, Object operatorAndValue)] -> do - case HashMap.toList operatorAndValue of - [("eq", String value)] -> - deleteEntry columnName value - [("eq", Int value)] -> - deleteEntry columnName $ show value - _ -> pure (0, []) - _ -> pure (0, []) - _ -> pure (0, []) - Nothing -> pure (0, []) + liftIO $ do + let sqlQuery = + Query $ + P.unlines + [ "DELETE FROM " <> quoteKeyword table.name + , getWhereClause $ getByPKFilterElements args + , getReturningClause table + ] - returning <- - colErrorsToUserError $ - rowsToGraphQL dbId tableName columnEntries deletedRows - - pure $ - Object $ - HashMap.fromList - [ ("affected_rows", Int $ fromIntegral numOfChanges) - , ("returning", returning) - ] + deletedRows :: [[SQLData]] <- SS.query_ connection sqlQuery + numOfChanges <- SS.changes connection + mutationByPKResponse table numOfChanges $ P.head deletedRows getMutationResolvers :: IO (HashMap.HashMap Text (Resolver IO)) getMutationResolvers = do @@ -1088,19 +1073,29 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do getInsertTableTuple table = makeResolver (Introspection.tableInsertField accessMode table) - (executeDbInserts table.name) + (executeDbInserts table) getUpdateTableTuple :: TableEntry -> IO (Text, Resolver IO) getUpdateTableTuple table = makeResolver (Introspection.tableUpdateField accessMode table) - (executeDbUpdates table.name) + (executeDbUpdates table) + + getUpdateByPKTableTuple :: TableEntry -> IO (Maybe (Text, Resolver IO)) + getUpdateByPKTableTuple table = + P.for (Introspection.tableUpdateFieldByPk accessMode table) $ \field -> + makeResolver field (executeDbUpdatesByPK table) getDeleteTableTuple :: TableEntry -> IO (Text, Resolver IO) getDeleteTableTuple table = makeResolver (Introspection.tableDeleteField accessMode table) - (executeDbDeletions table.name) + (executeDbDeletions table) + + getDeleteByPKTableTuple :: TableEntry -> IO (Maybe (Text, Resolver IO)) + getDeleteByPKTableTuple table = + P.for (Introspection.tableDeleteFieldByPK accessMode table) $ \field -> + makeResolver field (executeDbDeletionsByPK table) getTableTuples :: IO [(Text, Resolver IO)] getTableTuples = @@ -1110,9 +1105,15 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do (\table -> table.object_type == Table) tables in - P.for tablesWithoutViews getInsertTableTuple - <> P.for tablesWithoutViews getUpdateTableTuple - <> P.for tablesWithoutViews getDeleteTableTuple + P.fold + [ P.for tablesWithoutViews getInsertTableTuple + , P.for tablesWithoutViews getUpdateTableTuple + , P.for tablesWithoutViews getDeleteTableTuple + , P.for tablesWithoutViews getUpdateByPKTableTuple + <&> P.catMaybes + , P.for tablesWithoutViews getDeleteByPKTableTuple + <&> P.catMaybes + ] getTableTuples <&> HashMap.fromList diff --git a/source/AirGQL/Introspection.hs b/source/AirGQL/Introspection.hs index e3cafc0..62fb63e 100644 --- a/source/AirGQL/Introspection.hs +++ b/source/AirGQL/Introspection.hs @@ -5,7 +5,9 @@ module AirGQL.Introspection ( tableQueryByPKField, tableInsertField, tableUpdateField, + tableUpdateFieldByPk, tableDeleteField, + tableDeleteFieldByPK, ) where @@ -212,7 +214,7 @@ tableQueryByPKField table = do mutationResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType mutationResponseType accessMode table = do let tableName = doubleXEncodeGql table.name - let readFields = + let readonlyFields = if canRead accessMode then pure @@ -228,7 +230,7 @@ mutationResponseType accessMode table = do (tableName <> "_mutation_response") ( [ Type.field "affected_rows" (Type.nonNull Type.typeInt) ] - <> readFields + <> readonlyFields ) & Type.withDescription ("Mutation response for " <> table.name) @@ -236,21 +238,19 @@ mutationResponseType accessMode table = do mutationByPkResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType mutationByPkResponseType accessMode table = do let tableName = doubleXEncodeGql table.name - let readFields = + let readonlyFields = if canRead accessMode then - pure - $ Type.field - "returning" - $ Type.nonNull - $ tableRowType table + pure $ + Type.field "returning" $ + tableRowType table else [] Type.object (tableName <> "_mutation_by_pk_response") ( [ Type.field "affected_rows" (Type.nonNull Type.typeInt) ] - <> readFields + <> readonlyFields ) & Type.withDescription ("Mutation response for " <> table.name)