Logical models: check that specified named columns are returned from query

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8312
GitOrigin-RevId: fc7c7a14115c0b228abc2f48d3cfac51d2852277
This commit is contained in:
Gil Mizrahi 2023-03-16 12:44:14 +02:00 committed by hasura-bot
parent 9d30b7f5e9
commit 021e769235
3 changed files with 104 additions and 42 deletions

View File

@ -650,6 +650,39 @@ testValidation opts = do
actual `shouldAtLeastBe` expected actual `shouldAtLeastBe` expected
it "where the column names specified are not returned from the query" $
\testEnv -> do
let expected =
[yaml|
code: validation-failed
error: Failed to validate query
internal:
error:
message: column "text" does not exist
|]
actual <-
GraphqlEngine.postMetadataWithStatus
400
testEnv
[yaml|
type: pg_track_logical_model
args:
type: query
source: postgres
root_field_name: text_failing
code: |
SELECT {{text}} AS not_text
arguments:
text:
type: text
returns:
columns:
text:
type: text
|]
actual `shouldAtLeastBe` expected
it "that uses undeclared arguments" $ it "that uses undeclared arguments" $
\testEnvironment -> do \testEnvironment -> do
let backendTypeMetadata = fromMaybe (error "Unknown backend") $ getBackendTypeConfig testEnvironment let backendTypeMetadata = fromMaybe (error "Unknown backend") $ getBackendTypeConfig testEnvironment

View File

@ -12,6 +12,7 @@ import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map import Data.Map.Strict qualified as Map
import Data.Set (Set) import Data.Set (Set)
import Data.Set qualified as Set import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Extended (commaSeparated, toTxt) import Data.Text.Extended (commaSeparated, toTxt)
import Database.PG.Query qualified as PG import Database.PG.Query qualified as PG
import Hasura.Backends.Postgres.Connection qualified as PG import Hasura.Backends.Postgres.Connection qualified as PG
@ -19,6 +20,7 @@ import Hasura.Backends.Postgres.Connection.Connect (withPostgresDB)
import Hasura.Backends.Postgres.Instances.Types () import Hasura.Backends.Postgres.Instances.Types ()
import Hasura.Backends.Postgres.SQL.Types (PGScalarType, pgScalarTypeToText) import Hasura.Backends.Postgres.SQL.Types (PGScalarType, pgScalarTypeToText)
import Hasura.Base.Error import Hasura.Base.Error
import Hasura.CustomReturnType
import Hasura.LogicalModel.Metadata import Hasura.LogicalModel.Metadata
( InterpolatedItem (..), ( InterpolatedItem (..),
InterpolatedQuery (..), InterpolatedQuery (..),
@ -29,6 +31,44 @@ import Hasura.LogicalModel.Types (NullableScalarType (nstType), getLogicalModelN
import Hasura.Prelude import Hasura.Prelude
import Hasura.SQL.Backend import Hasura.SQL.Backend
-- | Prepare a logical model query against a postgres-like database to validate it.
validateLogicalModel ::
forall m pgKind.
(MonadIO m, MonadError QErr m) =>
Env.Environment ->
PG.PostgresConnConfiguration ->
LogicalModelMetadata ('Postgres pgKind) ->
m ()
validateLogicalModel env connConf model = do
preparedQuery <- logicalModelToPreparedStatement model
-- We don't need to deallocate the prepared statement because 'withPostgresDB'
-- opens a new connection, runs a statement, and then closes the connection.
-- Since a prepared statement only lasts for the duration of the session, once
-- the session closes, the prepared statement is deallocated as well.
runRaw (PG.fromText $ preparedQuery)
where
runRaw :: PG.Query -> m ()
runRaw stmt =
liftEither
=<< liftIO
( withPostgresDB
env
connConf
( PG.rawQE
( \e ->
(err400 ValidationFailed "Failed to validate query")
{ qeInternal = Just $ ExtraInternal $ toJSON e
}
)
stmt
[]
False
)
)
---------------------------------------
-- | The environment and fresh-name generator used by 'renameIQ'. -- | The environment and fresh-name generator used by 'renameIQ'.
data RenamingState = RenamingState data RenamingState = RenamingState
{ rsNextFree :: Int, { rsNextFree :: Int,
@ -82,13 +122,19 @@ renameIQ = runRenaming . fmap InterpolatedQuery . mapM renameII . getInterpolate
where where
swap (a, b) = (b, a) swap (a, b) = (b, a)
-- | Pretty print an interpolated query with numbered parameters.
renderIQ :: InterpolatedQuery Int -> Text renderIQ :: InterpolatedQuery Int -> Text
renderIQ (InterpolatedQuery items) = mconcat (map printItem items) renderIQ (InterpolatedQuery items) = foldMap printItem items
where where
printItem :: InterpolatedItem Int -> Text printItem :: InterpolatedItem Int -> Text
printItem (IIText t) = t printItem (IIText t) = t
printItem (IIVariable i) = "$" <> tshow i printItem (IIVariable i) = "$" <> tshow i
-----------------------------------------
-- | Convert a logical model to a prepared statement to be validate.
--
-- Used by 'validateLogicalModel'. Exported for testing.
logicalModelToPreparedStatement :: logicalModelToPreparedStatement ::
forall m pgKind. forall m pgKind.
MonadError QErr m => MonadError QErr m =>
@ -97,8 +143,8 @@ logicalModelToPreparedStatement ::
logicalModelToPreparedStatement model = do logicalModelToPreparedStatement model = do
let name = getLogicalModelName $ _lmmRootFieldName model let name = getLogicalModelName $ _lmmRootFieldName model
let (preparedIQ, argumentMapping) = renameIQ $ _lmmCode model let (preparedIQ, argumentMapping) = renameIQ $ _lmmCode model
code :: Text logimoCode :: Text
code = renderIQ preparedIQ logimoCode = renderIQ preparedIQ
prepname = "_logimo_vali_" <> toTxt name prepname = "_logimo_vali_" <> toTxt name
occurringArguments, declaredArguments, undeclaredArguments :: Set LogicalModelArgumentName occurringArguments, declaredArguments, undeclaredArguments :: Set LogicalModelArgumentName
@ -113,7 +159,24 @@ logicalModelToPreparedStatement model = do
| argumentTypes /= mempty = "(" <> commaSeparated (pgScalarTypeToText <$> Map.elems argumentTypes) <> ")" | argumentTypes /= mempty = "(" <> commaSeparated (pgScalarTypeToText <$> Map.elems argumentTypes) <> ")"
| otherwise = "" | otherwise = ""
preparedQuery = "PREPARE " <> prepname <> argumentSignature <> " AS " <> code returnedColumnNames :: Text
returnedColumnNames =
commaSeparated $ HashMap.keys (crtColumns (_lmmReturns model))
wrapInCTE :: Text -> Text
wrapInCTE query =
Text.intercalate
"\n"
[ "WITH " <> ctename <> " AS (",
query,
")",
"SELECT " <> returnedColumnNames,
"FROM " <> ctename
]
where
ctename = "_cte" <> prepname
preparedQuery = "PREPARE " <> prepname <> argumentSignature <> " AS " <> wrapInCTE logimoCode
when (Set.empty /= undeclaredArguments) $ when (Set.empty /= undeclaredArguments) $
throwError $ throwError $
@ -121,39 +184,3 @@ logicalModelToPreparedStatement model = do
"Undeclared arguments: " <> commaSeparated (map tshow $ Set.toList undeclaredArguments) "Undeclared arguments: " <> commaSeparated (map tshow $ Set.toList undeclaredArguments)
return preparedQuery return preparedQuery
-- | Prepare a logical model query against a postgres-like database to validate it.
validateLogicalModel ::
forall m pgKind.
(MonadIO m, MonadError QErr m) =>
Env.Environment ->
PG.PostgresConnConfiguration ->
LogicalModelMetadata ('Postgres pgKind) ->
m ()
validateLogicalModel env connConf model = do
preparedQuery <- logicalModelToPreparedStatement model
-- We don't need to deallocate the prepared statement because 'withPostgresDB'
-- opens a new connection, runs a statement, and then closes the connection.
-- Since a prepared statement only lasts for the duration of the session, once
-- the session closes, the prepared statement is deallocated as well.
runRaw (PG.fromText $ preparedQuery)
where
runRaw :: PG.Query -> m ()
runRaw stmt =
liftEither
=<< liftIO
( withPostgresDB
env
connConf
( PG.rawQE
( \e ->
(err400 ValidationFailed "Failed to validate query")
{ qeInternal = Just $ ExtraInternal $ toJSON e
}
)
stmt
[]
False
)
)

View File

@ -100,7 +100,8 @@ spec = do
(first showQErr actual) `shouldSatisfy` isRight (first showQErr actual) `shouldSatisfy` isRight
let Right rendered = actual let Right rendered = actual
rendered `shouldBe` "PREPARE _logimo_vali_root_field_name(varchar) AS SELECT $1, $1" rendered
`shouldBe` "PREPARE _logimo_vali_root_field_name(varchar) AS WITH _cte_logimo_vali_root_field_name AS (\nSELECT $1, $1\n)\nSELECT \nFROM _cte_logimo_vali_root_field_name"
it "Handles multiple variables " do it "Handles multiple variables " do
let Right code = parseInterpolatedQuery "SELECT {{hey}}, {{ho}}" let Right code = parseInterpolatedQuery "SELECT {{hey}}, {{ho}}"
@ -118,4 +119,5 @@ spec = do
(first showQErr actual) `shouldSatisfy` isRight (first showQErr actual) `shouldSatisfy` isRight
let Right rendered = actual let Right rendered = actual
rendered `shouldBe` "PREPARE _logimo_vali_root_field_name(varchar, integer) AS SELECT $1, $2" rendered
`shouldBe` "PREPARE _logimo_vali_root_field_name(varchar, integer) AS WITH _cte_logimo_vali_root_field_name AS (\nSELECT $1, $2\n)\nSELECT \nFROM _cte_logimo_vali_root_field_name"