mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
validate logical models custom return type against postgres
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8563 GitOrigin-RevId: 462a608e0e90d1923bc1d735257f4506825f5db1
This commit is contained in:
parent
8a6a8459ab
commit
352d8ff09c
@ -835,13 +835,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BigquerySelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -1674,13 +1667,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CitusSelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -2548,13 +2534,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CockroachSelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -3577,13 +3556,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DataconnectorSelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -5076,13 +5048,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MssqlSelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -5913,13 +5878,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MysqlSelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -7051,13 +7009,6 @@
|
||||
"root_field_name": {
|
||||
"description": "Root field name for the logical model",
|
||||
"type": "string"
|
||||
},
|
||||
"select_permissions": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PostgresSelectPermDef"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
@ -87,6 +87,7 @@ library
|
||||
, uuid
|
||||
, vector
|
||||
, time
|
||||
, QuickCheck
|
||||
|
||||
exposed-modules:
|
||||
Spec
|
||||
@ -98,6 +99,7 @@ library
|
||||
Test.API.Metadata.CustomTypeSpec
|
||||
Test.API.Metadata.InconsistentSpec
|
||||
Test.API.Metadata.LogicalModelsSpec
|
||||
Test.API.Metadata.LogicalModels.TypeCheckingSpec
|
||||
Test.API.Metadata.LogicalModels.ValidationSpec
|
||||
Test.API.Metadata.SuggestRelationshipsSpec
|
||||
Test.API.Metadata.TestConnectionTemplateSpec
|
||||
|
@ -0,0 +1,251 @@
|
||||
{-# LANGUAGE AllowAmbiguousTypes #-}
|
||||
|
||||
-- | Check the typechecking validation of logical model's custom return types.
|
||||
module Test.API.Metadata.LogicalModels.TypeCheckingSpec where
|
||||
|
||||
import Data.List.NonEmpty qualified as NE
|
||||
import Harness.Backend.Citus qualified as Citus
|
||||
import Harness.Backend.Cockroach qualified as Cockroach
|
||||
import Harness.Backend.Postgres qualified as Postgres
|
||||
import Harness.Exceptions (SomeException, catch)
|
||||
import Harness.GraphqlEngine qualified as GraphqlEngine
|
||||
import Harness.Quoter.Yaml (yaml)
|
||||
import Harness.Schema qualified as Schema
|
||||
import Harness.Test.BackendType qualified as BackendType
|
||||
import Harness.Test.Fixture qualified as Fixture
|
||||
import Harness.TestEnvironment (GlobalTestEnvironment, TestEnvironment, getBackendTypeConfig)
|
||||
import Harness.Yaml (shouldAtLeastBe, shouldReturnYaml)
|
||||
import Hasura.Prelude
|
||||
import Hasura.SQL.Backend (PostgresKind (..))
|
||||
import Test.Hspec (SpecWith, describe, it)
|
||||
import Test.QuickCheck
|
||||
|
||||
featureFlagForLogicalModels :: String
|
||||
featureFlagForLogicalModels = "HASURA_FF_LOGICAL_MODEL_INTERFACE"
|
||||
|
||||
spec :: SpecWith GlobalTestEnvironment
|
||||
spec = do
|
||||
Fixture.hgeWithEnv [(featureFlagForLogicalModels, "True")] do
|
||||
Fixture.run
|
||||
( NE.fromList
|
||||
[ (Fixture.fixture $ Fixture.Backend Postgres.backendTypeMetadata)
|
||||
{ Fixture.setupTeardown = \(testEnv, _) ->
|
||||
[ Postgres.setupTablesAction schema testEnv
|
||||
]
|
||||
},
|
||||
(Fixture.fixture $ Fixture.Backend Citus.backendTypeMetadata)
|
||||
{ Fixture.setupTeardown = \(testEnv, _) ->
|
||||
[ Citus.setupTablesAction schema testEnv
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
(tests @'Vanilla)
|
||||
Fixture.run
|
||||
( NE.fromList
|
||||
[ (Fixture.fixture $ Fixture.Backend Cockroach.backendTypeMetadata)
|
||||
{ Fixture.setupTeardown = \(testEnv, _) ->
|
||||
[ Cockroach.setupTablesAction schema testEnv
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
(tests @'Cockroach)
|
||||
|
||||
-- ** Setup and teardown
|
||||
|
||||
customType :: Text -> Schema.ScalarType
|
||||
customType txt =
|
||||
Schema.TCustomType
|
||||
Schema.defaultBackendScalarType
|
||||
{ Schema.bstPostgres = Just txt,
|
||||
Schema.bstCitus = Just txt,
|
||||
Schema.bstCockroach = Just txt
|
||||
}
|
||||
|
||||
schema :: [Schema.Table]
|
||||
schema =
|
||||
[ (Schema.table "stuff")
|
||||
{ Schema.tableColumns =
|
||||
(\t -> Schema.column t (customType t)) <$> types
|
||||
}
|
||||
]
|
||||
<> fmap
|
||||
( \t ->
|
||||
(Schema.table ("stuff_" <> t))
|
||||
{ Schema.tableColumns =
|
||||
[Schema.column t (customType t)]
|
||||
}
|
||||
)
|
||||
types
|
||||
|
||||
allTypesReturnType :: Schema.CustomType
|
||||
allTypesReturnType =
|
||||
(Schema.customType "stuff_type")
|
||||
{ Schema.customTypeColumns =
|
||||
(\t -> Schema.logicalModelColumn t (customType t)) <$> types
|
||||
}
|
||||
|
||||
types :: [Text]
|
||||
types =
|
||||
[ "int2",
|
||||
"smallint",
|
||||
"integer",
|
||||
"bigint",
|
||||
"int8",
|
||||
"real",
|
||||
"float8",
|
||||
"numeric",
|
||||
"bool",
|
||||
"char",
|
||||
"varchar",
|
||||
"text",
|
||||
"date",
|
||||
"timestamp",
|
||||
"timestamptz",
|
||||
"timetz",
|
||||
"json",
|
||||
"jsonb",
|
||||
"uuid"
|
||||
]
|
||||
|
||||
-- ** Tests
|
||||
|
||||
tests :: forall pgKind. GetDiffs pgKind => SpecWith TestEnvironment
|
||||
tests = do
|
||||
describe "Validation succeeds tracking a logical model" do
|
||||
it "for all supported types" $
|
||||
\testEnvironment -> do
|
||||
let backendTypeMetadata = fromMaybe (error "Unknown backend") $ getBackendTypeConfig testEnvironment
|
||||
sourceName = BackendType.backendSourceName backendTypeMetadata
|
||||
|
||||
let simpleQuery :: Text
|
||||
simpleQuery = "SELECT * FROM stuff"
|
||||
|
||||
let logicalModel :: Schema.LogicalModel
|
||||
logicalModel =
|
||||
(Schema.logicalModel "typed_model" simpleQuery "stuff_type")
|
||||
|
||||
Schema.trackCustomType sourceName allTypesReturnType testEnvironment
|
||||
|
||||
shouldReturnYaml
|
||||
testEnvironment
|
||||
( GraphqlEngine.postMetadata
|
||||
testEnvironment
|
||||
(Schema.trackLogicalModelCommand sourceName backendTypeMetadata logicalModel)
|
||||
)
|
||||
[yaml|
|
||||
message: success
|
||||
|]
|
||||
|
||||
describe "Validation fails tracking a logical model" do
|
||||
it "when there's a type mismatch" $ \testEnvironment -> withMaxSuccess (maxSuccesses @pgKind) $ \(TypeForQuickCheck {..} :: TypeForQuickCheck pgKind) -> do
|
||||
let backendTypeMetadata = fromMaybe (error "Unknown backend") $ getBackendTypeConfig testEnvironment
|
||||
sourceName = BackendType.backendSourceName backendTypeMetadata
|
||||
|
||||
let wrongQuery :: Text
|
||||
wrongQuery = "SELECT " <> tableType <> " AS " <> customtypeType <> " FROM stuff_" <> tableType
|
||||
|
||||
let logicalModel :: Schema.LogicalModel
|
||||
logicalModel =
|
||||
(Schema.logicalModel ("typed_model_" <> customtypeType) wrongQuery ("stuff_type_" <> customtypeType))
|
||||
|
||||
-- Possible cleanup after last test that may have tracked this custom type
|
||||
_ <- Schema.untrackLogicalModel sourceName logicalModel testEnvironment `catch` \(_ :: SomeException) -> pure ()
|
||||
_ <- Schema.untrackCustomType sourceName (mkCustomType customtypeType) testEnvironment `catch` \(_ :: SomeException) -> pure ()
|
||||
Schema.trackCustomType sourceName (mkCustomType customtypeType) testEnvironment
|
||||
|
||||
let message :: Text
|
||||
message =
|
||||
"Return column '"
|
||||
<> customtypeType
|
||||
<> "' has a type mismatch. The expected type is '"
|
||||
<> customTypeNameMapping @pgKind customtypeType
|
||||
<> "', but the actual type is '"
|
||||
<> tableTypeNameMapping @pgKind tableType
|
||||
<> "'."
|
||||
expected =
|
||||
[yaml|
|
||||
code: validation-failed
|
||||
error: Failed to validate query
|
||||
internal: *message
|
||||
|]
|
||||
|
||||
actual <-
|
||||
GraphqlEngine.postMetadataWithStatus
|
||||
400
|
||||
testEnvironment
|
||||
(Schema.trackLogicalModelCommand sourceName backendTypeMetadata logicalModel)
|
||||
actual `shouldAtLeastBe` expected
|
||||
|
||||
-- ** Utils
|
||||
|
||||
mkCustomType :: Text -> Schema.CustomType
|
||||
mkCustomType typ =
|
||||
(Schema.customType ("stuff_type_" <> typ))
|
||||
{ Schema.customTypeColumns =
|
||||
[Schema.logicalModelColumn typ (customType typ)]
|
||||
}
|
||||
|
||||
isDifferentTypeThanPg :: Text -> Text -> Bool
|
||||
isDifferentTypeThanPg a b
|
||||
| a == b = False
|
||||
| ["int2", "smallint"] == sort [a, b] = False
|
||||
| ["bigint", "int8"] == sort [a, b] = False
|
||||
| otherwise = True
|
||||
|
||||
isDifferentTypeThanRoach :: Text -> Text -> Bool
|
||||
isDifferentTypeThanRoach a b
|
||||
| a == b = False
|
||||
| sort ["smallint", "int2"] == sort [a, b] = False
|
||||
| sort ["integer", "int8"] == sort [a, b] = False
|
||||
| sort ["bigint", "int8"] == sort [a, b] = False
|
||||
| sort ["bigint", "integer"] == sort [a, b] = False
|
||||
| sort ["json", "jsonb"] == sort [a, b] = False
|
||||
| otherwise = True
|
||||
|
||||
data TypeForQuickCheck pgKind = TypeForQuickCheck {tableType :: Text, customtypeType :: Text}
|
||||
deriving (Show)
|
||||
|
||||
instance GetDiffs pgKind => Arbitrary (TypeForQuickCheck pgKind) where
|
||||
shrink = const []
|
||||
arbitrary =
|
||||
uncurry TypeForQuickCheck
|
||||
<$> suchThat ((,) <$> elements types <*> elements types) (uncurry (isDifferentTypeThan @pgKind))
|
||||
|
||||
class GetDiffs (pgKind :: PostgresKind) where
|
||||
maxSuccesses :: Int
|
||||
maxSuccesses = 100
|
||||
isDifferentTypeThan :: Text -> Text -> Bool
|
||||
isDifferentTypeThan = isDifferentTypeThanPg
|
||||
customTypeNameMapping :: Text -> Text
|
||||
customTypeNameMapping = tableTypeNameMapping @pgKind
|
||||
tableTypeNameMapping :: Text -> Text
|
||||
tableTypeNameMapping = \case
|
||||
"bool" -> "boolean"
|
||||
"char" -> "bpchar"
|
||||
"int2" -> "smallint"
|
||||
"int8" -> "bigint"
|
||||
t -> t
|
||||
|
||||
instance GetDiffs 'Vanilla
|
||||
|
||||
instance GetDiffs 'Citus
|
||||
|
||||
instance GetDiffs 'Cockroach where
|
||||
maxSuccesses = 30
|
||||
isDifferentTypeThan = isDifferentTypeThanRoach
|
||||
customTypeNameMapping = \case
|
||||
"bool" -> "boolean"
|
||||
"char" -> "bpchar"
|
||||
"int2" -> "smallint"
|
||||
"int8" -> "bigint"
|
||||
t -> t
|
||||
tableTypeNameMapping = \case
|
||||
"bool" -> "boolean"
|
||||
"char" -> "bpchar"
|
||||
"int2" -> "smallint"
|
||||
"integer" -> "bigint"
|
||||
"int8" -> "bigint"
|
||||
"json" -> "jsonb"
|
||||
t -> t
|
@ -58,7 +58,7 @@ schema =
|
||||
tests :: SpecWith TestEnvironment
|
||||
tests = do
|
||||
let simpleQuery :: Text
|
||||
simpleQuery = "SELECT thing / 2 AS divided FROM stuff"
|
||||
simpleQuery = "SELECT (thing / 2)::integer AS divided FROM stuff"
|
||||
|
||||
conflictingReturnType :: Schema.CustomType
|
||||
conflictingReturnType =
|
||||
@ -204,7 +204,7 @@ tests = do
|
||||
)
|
||||
[yaml|
|
||||
code: unexpected
|
||||
error: *expectedError
|
||||
error: *expectedError
|
||||
path: $.args
|
||||
|]
|
||||
|
||||
|
@ -100,7 +100,7 @@ dividedStuffReturnType =
|
||||
testAdminAccess :: SpecWith TestEnvironment
|
||||
testAdminAccess = do
|
||||
let query :: Text
|
||||
query = "SELECT thing / {{denominator}} AS divided FROM stuff WHERE date = {{target_date}}"
|
||||
query = "SELECT (thing / {{denominator}})::integer AS divided FROM stuff WHERE date = {{target_date}}"
|
||||
|
||||
describe "Admin access" do
|
||||
let dividedStuffLogicalModel :: Schema.LogicalModel
|
||||
@ -171,7 +171,7 @@ testAdminAccess = do
|
||||
[ ("X-Hasura-Role", "not-admin")
|
||||
]
|
||||
[yaml|
|
||||
type: *getRequestType
|
||||
type: *getRequestType
|
||||
args:
|
||||
source: *sourceName
|
||||
|]
|
||||
@ -189,10 +189,10 @@ testAdminAccess = do
|
||||
testImplementation :: SpecWith TestEnvironment
|
||||
testImplementation = do
|
||||
let simpleQuery :: Text
|
||||
simpleQuery = "SELECT thing / 2 AS divided FROM stuff"
|
||||
simpleQuery = "SELECT (thing / 2)::integer AS divided FROM stuff"
|
||||
|
||||
query :: Text
|
||||
query = "SELECT thing / {{denominator}} AS divided FROM stuff WHERE date = {{target_date}}"
|
||||
let query :: Text
|
||||
query = "SELECT (thing / {{denominator}})::integer AS divided FROM stuff WHERE date = {{target_date}}"
|
||||
|
||||
describe "Implementation" $ do
|
||||
it "Adds a simple logical model of a function with no arguments and returns a 200" $ \testEnvironment -> do
|
||||
@ -260,7 +260,7 @@ testImplementation = do
|
||||
( GraphqlEngine.postMetadata
|
||||
testEnvironment
|
||||
[yaml|
|
||||
type: *getRequestType
|
||||
type: *getRequestType
|
||||
args:
|
||||
source: *sourceName
|
||||
|]
|
||||
@ -321,7 +321,7 @@ testImplementation = do
|
||||
( GraphqlEngine.postMetadata
|
||||
testEnvironment
|
||||
[yaml|
|
||||
type: *getRequestType
|
||||
type: *getRequestType
|
||||
args:
|
||||
source: *sourceName
|
||||
|]
|
||||
|
@ -122,7 +122,7 @@ tests = do
|
||||
let backendTypeMetadata = fromMaybe (error "Unknown backend") $ getBackendTypeConfig testEnvironment
|
||||
sourceName = BackendType.backendSourceName backendTypeMetadata
|
||||
|
||||
nullableQuery = "SELECT thing / 2 AS divided, null as something_nullable FROM stuff"
|
||||
nullableQuery = "SELECT (thing / 2)::integer AS divided, null::text as something_nullable FROM stuff"
|
||||
|
||||
descriptionsAndNullableReturnType :: Schema.CustomType
|
||||
descriptionsAndNullableReturnType =
|
||||
@ -131,7 +131,7 @@ tests = do
|
||||
[ (Schema.logicalModelColumn "divided" Schema.TInt)
|
||||
{ Schema.logicalModelColumnDescription = Just "A divided thing"
|
||||
},
|
||||
(Schema.logicalModelColumn "something_nullable" Schema.TInt)
|
||||
(Schema.logicalModelColumn "something_nullable" Schema.TStr)
|
||||
{ Schema.logicalModelColumnDescription = Just "Something nullable",
|
||||
Schema.logicalModelColumnNullable = True
|
||||
}
|
||||
@ -196,7 +196,7 @@ tests = do
|
||||
"name": "something_nullable",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
|
@ -39,6 +39,8 @@ module Database.PG.Query.Connection
|
||||
lenientDecodeUtf8,
|
||||
PGErrInternal (..),
|
||||
PGStmtErrDetail (..),
|
||||
describePrepared,
|
||||
PreparedDescription (..),
|
||||
)
|
||||
where
|
||||
|
||||
@ -567,3 +569,39 @@ execMulti pgConn (Template t) convF = do
|
||||
withExceptT PGIUnexpected $ convF resOk
|
||||
where
|
||||
PGConn conn _ cancelable _ _ _ _ _ _ = pgConn
|
||||
|
||||
-- | Extract the description of a prepared statement.
|
||||
describePrepared ::
|
||||
PGConn ->
|
||||
ByteString ->
|
||||
ExceptT PGErrInternal IO (PreparedDescription PQ.Oid)
|
||||
describePrepared pgConn name = do
|
||||
resOk <- retryOnConnErr pgConn $ do
|
||||
mRes <-
|
||||
bool lift (cancelOnAsync (pgPQConn pgConn)) (pgCancel pgConn) $
|
||||
PQ.describePrepared (pgPQConn pgConn) name
|
||||
checkResult (pgPQConn pgConn) mRes
|
||||
|
||||
let res = getPQRes resOk
|
||||
lift $ do
|
||||
numberOfParams <- PQ.nparams res
|
||||
numberOfFields <- PQ.nfields res
|
||||
PreparedDescription
|
||||
<$> traverse (PQ.paramtype res) [0 .. (numberOfParams - 1)]
|
||||
<*> traverse
|
||||
( \i ->
|
||||
(,)
|
||||
<$> PQ.fname res i
|
||||
<*> PQ.ftype res i
|
||||
)
|
||||
[0 .. (numberOfFields - 1)]
|
||||
|
||||
-- | The description of a prepared statement.
|
||||
-- See "PQdescribePrepared" in <https://www.postgresql.org/docs/current/libpq-exec.html> for more information.
|
||||
data PreparedDescription typ = PreparedDescription
|
||||
{ -- | input parameters
|
||||
pd_paramtype :: [typ],
|
||||
-- | output columns
|
||||
pd_fname_ftype :: [(Maybe ByteString, typ)]
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
@ -167,6 +167,38 @@ xml = mkOid 142
|
||||
|
||||
-- Array Types
|
||||
|
||||
text_arr = mkOid 1009
|
||||
bool_array = mkOid 1000
|
||||
|
||||
char_array = mkOid 1002
|
||||
|
||||
int2_array = mkOid 1005
|
||||
|
||||
int4_array = mkOid 1007
|
||||
|
||||
text_array = mkOid 1009
|
||||
|
||||
varchar_array = mkOid 1015
|
||||
|
||||
int8_array = mkOid 1016
|
||||
|
||||
float4_array = mkOid 1021
|
||||
|
||||
float8_array = mkOid 1022
|
||||
|
||||
numeric_array = mkOid 1031
|
||||
|
||||
timestamp_array = mkOid 1115
|
||||
|
||||
date_array = mkOid 1182
|
||||
|
||||
time_array = mkOid 1183
|
||||
|
||||
timestamptz_array = mkOid 1185
|
||||
|
||||
timetz_array = mkOid 1270
|
||||
|
||||
json_array = mkOid 199
|
||||
|
||||
jsonb_array = mkOid 3807
|
||||
|
||||
uuid_array = mkOid 2951
|
||||
|
@ -29,6 +29,8 @@ module Database.PG.Query.Transaction
|
||||
fromText,
|
||||
fromBuilder,
|
||||
getQueryText,
|
||||
describePreparedStatement,
|
||||
PreparedDescription (..),
|
||||
)
|
||||
where
|
||||
|
||||
@ -45,6 +47,7 @@ import Control.Monad.Trans.Except (ExceptT, withExceptT)
|
||||
import Control.Monad.Trans.Reader (ReaderT (..))
|
||||
import Data.Aeson (ToJSON (toJSON), object, (.=))
|
||||
import Data.Aeson.Text (encodeToLazyText)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Hashable (Hashable)
|
||||
import Data.String (IsString)
|
||||
import Data.Text (Text)
|
||||
@ -209,6 +212,18 @@ discardQE ef t r p = do
|
||||
Discard () <- withQE ef t r p
|
||||
return ()
|
||||
|
||||
-- | Extract the description of a prepared statement.
|
||||
describePreparedStatement ::
|
||||
(MonadIO m) =>
|
||||
(PGTxErr -> e) ->
|
||||
ByteString ->
|
||||
TxET e m (PreparedDescription PQ.Oid)
|
||||
describePreparedStatement ef name = TxET $
|
||||
ReaderT $ \pgConn ->
|
||||
withExceptT (ef . PGTxErr mempty [] False) $
|
||||
hoist liftIO $
|
||||
describePrepared pgConn name
|
||||
|
||||
serverVersion ::
|
||||
MonadIO m => TxET e m Int
|
||||
serverVersion = do
|
||||
|
@ -168,7 +168,7 @@ createTable testEnv Schema.Table {tableName, tableColumns, tablePrimaryKey = pk,
|
||||
scalarType :: HasCallStack => Schema.ScalarType -> Text
|
||||
scalarType = \case
|
||||
Schema.TInt -> "integer"
|
||||
Schema.TStr -> "varchar"
|
||||
Schema.TStr -> "text"
|
||||
Schema.TUTCTime -> "timestamp"
|
||||
Schema.TBool -> "boolean"
|
||||
Schema.TGeography -> "geography"
|
||||
|
@ -169,7 +169,7 @@ createTable testEnv Schema.Table {tableName, tableColumns, tablePrimaryKey = pk,
|
||||
scalarType :: HasCallStack => Schema.ScalarType -> Text
|
||||
scalarType = \case
|
||||
Schema.TInt -> "integer"
|
||||
Schema.TStr -> "varchar"
|
||||
Schema.TStr -> "text"
|
||||
Schema.TUTCTime -> "timestamp"
|
||||
Schema.TBool -> "boolean"
|
||||
Schema.TGeography -> "geography"
|
||||
|
@ -282,7 +282,7 @@ createUniqueIndexSql (SchemaName schemaName) tableName = \case
|
||||
scalarType :: HasCallStack => Schema.ScalarType -> Text
|
||||
scalarType = \case
|
||||
Schema.TInt -> "integer"
|
||||
Schema.TStr -> "varchar"
|
||||
Schema.TStr -> "text"
|
||||
Schema.TUTCTime -> "timestamp"
|
||||
Schema.TBool -> "boolean"
|
||||
Schema.TGeography -> "geography"
|
||||
|
@ -6,6 +6,8 @@ module Hasura.Backends.Postgres.Instances.LogicalModels
|
||||
where
|
||||
|
||||
import Data.Aeson (toJSON)
|
||||
import Data.Bifunctor
|
||||
import Data.ByteString qualified as BS
|
||||
import Data.Environment qualified as Env
|
||||
import Data.HashMap.Strict qualified as HashMap
|
||||
import Data.HashMap.Strict.InsOrd qualified as InsOrd
|
||||
@ -14,12 +16,15 @@ import Data.Map.Strict qualified as Map
|
||||
import Data.Set (Set)
|
||||
import Data.Set qualified as Set
|
||||
import Data.Text qualified as Text
|
||||
import Data.Text.Encoding qualified as Text
|
||||
import Data.Text.Extended (commaSeparated, toTxt)
|
||||
import Data.Tuple (swap)
|
||||
import Database.PG.Query qualified as PG
|
||||
import Database.PostgreSQL.LibPQ qualified as PQ
|
||||
import Hasura.Backends.Postgres.Connection qualified as PG
|
||||
import Hasura.Backends.Postgres.Connection.Connect (withPostgresDB)
|
||||
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.CustomReturnType.Metadata (CustomReturnTypeMetadata (..))
|
||||
import Hasura.LogicalModel.Metadata
|
||||
@ -36,39 +41,95 @@ import Hasura.SQL.Backend
|
||||
validateLogicalModel ::
|
||||
forall m pgKind.
|
||||
(MonadIO m, MonadError QErr m) =>
|
||||
InsOrd.InsOrdHashMap PGScalarType PQ.Oid ->
|
||||
Env.Environment ->
|
||||
PG.PostgresConnConfiguration ->
|
||||
CustomReturnTypeMetadata ('Postgres pgKind) ->
|
||||
LogicalModelMetadata ('Postgres pgKind) ->
|
||||
m ()
|
||||
validateLogicalModel env connConf customReturnType model = do
|
||||
preparedQuery <- logicalModelToPreparedStatement customReturnType 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)
|
||||
validateLogicalModel pgTypeOidMapping env connConf customReturnType model = do
|
||||
(prepname, preparedQuery) <- logicalModelToPreparedStatement customReturnType model
|
||||
description <- runCheck prepname (PG.fromText preparedQuery)
|
||||
let returnColumns = bimap toTxt nstType <$> InsOrd.toList (_crtmFields customReturnType)
|
||||
for_ (toList returnColumns) (matchTypes description)
|
||||
where
|
||||
runRaw :: PG.Query -> m ()
|
||||
runRaw stmt =
|
||||
-- Run stuff against the database.
|
||||
--
|
||||
-- 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.
|
||||
runCheck :: BS.ByteString -> PG.Query -> m (PG.PreparedDescription PQ.Oid)
|
||||
runCheck prepname stmt =
|
||||
liftEither
|
||||
=<< liftIO
|
||||
( withPostgresDB
|
||||
env
|
||||
connConf
|
||||
( PG.rawQE
|
||||
( \e ->
|
||||
(err400 ValidationFailed "Failed to validate query")
|
||||
{ qeInternal = Just $ ExtraInternal $ toJSON e
|
||||
}
|
||||
)
|
||||
stmt
|
||||
[]
|
||||
False
|
||||
( do
|
||||
-- prepare statement
|
||||
PG.rawQE @_ @()
|
||||
( \e ->
|
||||
(err400 ValidationFailed "Failed to validate query")
|
||||
{ qeInternal = Just $ ExtraInternal $ toJSON e
|
||||
}
|
||||
)
|
||||
stmt
|
||||
[]
|
||||
False
|
||||
-- extract description
|
||||
PG.describePreparedStatement
|
||||
( \e ->
|
||||
(err400 ValidationFailed "Failed to validate query")
|
||||
{ qeInternal = Just $ ExtraInternal $ toJSON e
|
||||
}
|
||||
)
|
||||
prepname
|
||||
)
|
||||
)
|
||||
|
||||
-- Look for the type for a particular column in the prepared statement description
|
||||
-- and compare them.
|
||||
-- fail if not found, try to provide a good error message if you can.
|
||||
matchTypes :: PG.PreparedDescription PQ.Oid -> (Text, PGScalarType) -> m ()
|
||||
matchTypes description (name, expectedType) =
|
||||
case lookup (Just (Text.encodeUtf8 name)) (PG.pd_fname_ftype description) of
|
||||
Nothing ->
|
||||
throwError
|
||||
(err400 ValidationFailed "Failed to validate query")
|
||||
{ qeInternal =
|
||||
Just $
|
||||
ExtraInternal $
|
||||
toJSON @Text $
|
||||
"Column named '" <> toTxt name <> "' is not returned from the query."
|
||||
}
|
||||
Just actualOid
|
||||
| Just expectedOid <- InsOrd.lookup expectedType pgTypeOidMapping,
|
||||
expectedOid /= actualOid ->
|
||||
throwError
|
||||
(err400 ValidationFailed "Failed to validate query")
|
||||
{ qeInternal =
|
||||
Just $
|
||||
ExtraInternal $
|
||||
toJSON @Text $
|
||||
Text.unwords $
|
||||
[ "Return column '" <> name <> "' has a type mismatch.",
|
||||
"The expected type is '" <> toTxt expectedType <> "',"
|
||||
]
|
||||
<> case Map.lookup actualOid (invertPgTypeOidMap pgTypeOidMapping) of
|
||||
Just t ->
|
||||
["but the actual type is '" <> toTxt t <> "'."]
|
||||
Nothing ->
|
||||
[ "and has the " <> tshow expectedOid <> ",",
|
||||
"but the actual type has the " <> tshow actualOid <> "."
|
||||
]
|
||||
}
|
||||
Just {} -> pure ()
|
||||
|
||||
-- | Invert the type/oid mapping.
|
||||
invertPgTypeOidMap :: InsOrdHashMap PGScalarType PQ.Oid -> Map PQ.Oid PGScalarType
|
||||
invertPgTypeOidMap = Map.fromList . map swap . InsOrd.toList
|
||||
|
||||
---------------------------------------
|
||||
|
||||
-- | The environment and fresh-name generator used by 'renameIQ'.
|
||||
@ -121,8 +182,6 @@ renameIQ = runRenaming . fmap InterpolatedQuery . mapM renameII . getInterpolate
|
||||
-- Therefore we invert the map as part of renaming.
|
||||
inverseMap :: Ord b => Map a b -> Map b a
|
||||
inverseMap = Map.fromList . map swap . Map.toList
|
||||
where
|
||||
swap (a, b) = (b, a)
|
||||
|
||||
-- | Pretty print an interpolated query with numbered parameters.
|
||||
renderIQ :: InterpolatedQuery Int -> Text
|
||||
@ -142,7 +201,7 @@ logicalModelToPreparedStatement ::
|
||||
MonadError QErr m =>
|
||||
CustomReturnTypeMetadata ('Postgres pgKind) ->
|
||||
LogicalModelMetadata ('Postgres pgKind) ->
|
||||
m Text
|
||||
m (BS.ByteString, Text)
|
||||
logicalModelToPreparedStatement customReturnType model = do
|
||||
let name = getLogicalModelName $ _lmmRootFieldName model
|
||||
let (preparedIQ, argumentMapping) = renameIQ $ _lmmCode model
|
||||
@ -186,4 +245,4 @@ logicalModelToPreparedStatement customReturnType model = do
|
||||
err400 ValidationFailed $
|
||||
"Undeclared arguments: " <> commaSeparated (map tshow $ Set.toList undeclaredArguments)
|
||||
|
||||
return preparedQuery
|
||||
pure (Text.encodeUtf8 prepname, preparedQuery)
|
||||
|
@ -7,10 +7,14 @@
|
||||
module Hasura.Backends.Postgres.Instances.Metadata () where
|
||||
|
||||
import Data.HashMap.Strict qualified as Map
|
||||
import Data.HashMap.Strict.InsOrd qualified as InsOrd
|
||||
import Data.Text.Extended
|
||||
import Database.PG.Query.PTI qualified as PTI
|
||||
import Database.PostgreSQL.LibPQ qualified as PQ
|
||||
import Hasura.Backends.Postgres.DDL qualified as Postgres
|
||||
import Hasura.Backends.Postgres.Instances.LogicalModels as Postgres (validateLogicalModel)
|
||||
import Hasura.Backends.Postgres.SQL.Types (QualifiedTable)
|
||||
import Hasura.Backends.Postgres.SQL.Types qualified as Postgres
|
||||
import Hasura.Backends.Postgres.Types.CitusExtraTableMetadata
|
||||
import Hasura.Base.Error
|
||||
import Hasura.Prelude
|
||||
@ -35,6 +39,55 @@ class PostgresMetadata (pgKind :: PostgresKind) where
|
||||
Either (ObjRelDef ('Postgres pgKind)) (ArrRelDef ('Postgres pgKind)) ->
|
||||
m ()
|
||||
|
||||
-- | A mapping from pg scalar types with clear oid equivalent to oid.
|
||||
--
|
||||
-- This is a insert order hash map so that when we invert it
|
||||
-- duplicate oids will point to a more "general" type.
|
||||
pgTypeOidMapping :: InsOrd.InsOrdHashMap Postgres.PGScalarType PQ.Oid
|
||||
pgTypeOidMapping =
|
||||
InsOrd.fromList $
|
||||
[ (Postgres.PGSmallInt, PTI.int2),
|
||||
(Postgres.PGSerial, PTI.int4),
|
||||
(Postgres.PGInteger, PTI.int4),
|
||||
(Postgres.PGBigSerial, PTI.int8),
|
||||
(Postgres.PGBigInt, PTI.int8),
|
||||
(Postgres.PGFloat, PTI.float4),
|
||||
(Postgres.PGDouble, PTI.float8),
|
||||
(Postgres.PGMoney, PTI.numeric),
|
||||
(Postgres.PGNumeric, PTI.numeric),
|
||||
(Postgres.PGBoolean, PTI.bool),
|
||||
(Postgres.PGChar, PTI.bpchar),
|
||||
(Postgres.PGVarchar, PTI.varchar),
|
||||
(Postgres.PGText, PTI.text),
|
||||
(Postgres.PGDate, PTI.date),
|
||||
(Postgres.PGTimeStamp, PTI.timestamp),
|
||||
(Postgres.PGTimeStampTZ, PTI.timestamptz),
|
||||
(Postgres.PGTimeTZ, PTI.timetz),
|
||||
(Postgres.PGJSON, PTI.json),
|
||||
(Postgres.PGJSONB, PTI.jsonb),
|
||||
(Postgres.PGUUID, PTI.uuid),
|
||||
(Postgres.PGArray Postgres.PGSmallInt, PTI.int2_array),
|
||||
(Postgres.PGArray Postgres.PGSerial, PTI.int4_array),
|
||||
(Postgres.PGArray Postgres.PGInteger, PTI.int4_array),
|
||||
(Postgres.PGArray Postgres.PGBigSerial, PTI.int8_array),
|
||||
(Postgres.PGArray Postgres.PGBigInt, PTI.int8_array),
|
||||
(Postgres.PGArray Postgres.PGFloat, PTI.float4_array),
|
||||
(Postgres.PGArray Postgres.PGDouble, PTI.float8_array),
|
||||
(Postgres.PGArray Postgres.PGMoney, PTI.numeric_array),
|
||||
(Postgres.PGArray Postgres.PGNumeric, PTI.numeric_array),
|
||||
(Postgres.PGArray Postgres.PGBoolean, PTI.bool_array),
|
||||
(Postgres.PGArray Postgres.PGChar, PTI.char_array),
|
||||
(Postgres.PGArray Postgres.PGVarchar, PTI.varchar_array),
|
||||
(Postgres.PGArray Postgres.PGText, PTI.text_array),
|
||||
(Postgres.PGArray Postgres.PGDate, PTI.date_array),
|
||||
(Postgres.PGArray Postgres.PGTimeStamp, PTI.timestamp_array),
|
||||
(Postgres.PGArray Postgres.PGTimeStampTZ, PTI.timestamptz_array),
|
||||
(Postgres.PGArray Postgres.PGTimeTZ, PTI.timetz_array),
|
||||
(Postgres.PGArray Postgres.PGJSON, PTI.json_array),
|
||||
(Postgres.PGArray Postgres.PGJSON, PTI.jsonb_array),
|
||||
(Postgres.PGArray Postgres.PGUUID, PTI.uuid_array)
|
||||
]
|
||||
|
||||
instance PostgresMetadata 'Vanilla where
|
||||
validateRel _ _ _ = pure ()
|
||||
|
||||
@ -112,6 +165,13 @@ instance PostgresMetadata 'Citus where
|
||||
|
||||
instance PostgresMetadata 'Cockroach where
|
||||
validateRel _ _ _ = pure ()
|
||||
pgTypeOidMapping =
|
||||
InsOrd.fromList
|
||||
[ (Postgres.PGInteger, PTI.int8),
|
||||
(Postgres.PGSerial, PTI.int8),
|
||||
(Postgres.PGJSON, PTI.jsonb)
|
||||
]
|
||||
`InsOrd.union` pgTypeOidMapping @'Vanilla
|
||||
|
||||
----------------------------------------------------------------
|
||||
-- BackendMetadata instance
|
||||
@ -137,5 +197,5 @@ instance
|
||||
postDropSourceHook = Postgres.postDropSourceHook
|
||||
validateRelationship = validateRel @pgKind
|
||||
buildComputedFieldBooleanExp = Postgres.buildComputedFieldBooleanExp
|
||||
validateLogicalModel = Postgres.validateLogicalModel
|
||||
validateLogicalModel = Postgres.validateLogicalModel (pgTypeOidMapping @pgKind)
|
||||
supportsBeingRemoteRelationshipTarget _ = True
|
||||
|
@ -36,6 +36,7 @@ import Hasura.RQL.Types.Metadata.Object
|
||||
import Hasura.RQL.Types.SchemaCache.Build
|
||||
import Hasura.SQL.AnyBackend qualified as AB
|
||||
import Hasura.SQL.Backend
|
||||
import Hasura.SQL.Tag
|
||||
import Hasura.Server.Init.FeatureFlag as FF
|
||||
import Hasura.Server.Types (HasServerConfigCtx (..), ServerConfigCtx (..))
|
||||
|
||||
@ -179,7 +180,15 @@ runTrackLogicalModel env trackLogicalModelRequest = do
|
||||
throwIfFeatureDisabled
|
||||
|
||||
sourceMetadata <-
|
||||
maybe (throw400 NotFound $ "Source " <> sourceNameToText source <> " not found.") pure
|
||||
maybe
|
||||
( throw400 NotFound $
|
||||
"Source '"
|
||||
<> sourceNameToText source
|
||||
<> "' of kind "
|
||||
<> toTxt (reify (backendTag @b))
|
||||
<> " not found."
|
||||
)
|
||||
pure
|
||||
. preview (metaSources . ix source . toSourceMetadata @b)
|
||||
=<< getMetadata
|
||||
let sourceConnConfig = _smConfiguration sourceMetadata
|
||||
|
@ -86,7 +86,7 @@ spec = do
|
||||
|
||||
it "Rejects undeclared variables" do
|
||||
let Right code = parseInterpolatedQuery "SELECT {{hey}}"
|
||||
let actual :: Either QErr Text = runExcept $ logicalModelToPreparedStatement crtm lmm {_lmmCode = code}
|
||||
let actual :: Either QErr Text = fmap snd $ runExcept $ logicalModelToPreparedStatement crtm lmm {_lmmCode = code}
|
||||
|
||||
(first showQErr actual) `shouldSatisfy` isLeft
|
||||
let Left err = actual
|
||||
@ -95,16 +95,17 @@ spec = do
|
||||
it "Handles multiple occurences of variables " do
|
||||
let Right code = parseInterpolatedQuery "SELECT {{hey}}, {{hey}}"
|
||||
let actual :: Either QErr Text =
|
||||
runExcept $
|
||||
logicalModelToPreparedStatement
|
||||
crtm
|
||||
lmm
|
||||
{ _lmmCode = code,
|
||||
_lmmArguments =
|
||||
HM.fromList
|
||||
[ (LogicalModelArgumentName "hey", NullableScalarType PGVarchar False Nothing)
|
||||
]
|
||||
}
|
||||
fmap snd $
|
||||
runExcept $
|
||||
logicalModelToPreparedStatement
|
||||
crtm
|
||||
lmm
|
||||
{ _lmmCode = code,
|
||||
_lmmArguments =
|
||||
HM.fromList
|
||||
[ (LogicalModelArgumentName "hey", NullableScalarType PGVarchar False Nothing)
|
||||
]
|
||||
}
|
||||
|
||||
(first showQErr actual) `shouldSatisfy` isRight
|
||||
let Right rendered = actual
|
||||
@ -114,17 +115,18 @@ spec = do
|
||||
it "Handles multiple variables " do
|
||||
let Right code = parseInterpolatedQuery "SELECT {{hey}}, {{ho}}"
|
||||
let actual :: Either QErr Text =
|
||||
runExcept $
|
||||
logicalModelToPreparedStatement
|
||||
crtm
|
||||
lmm
|
||||
{ _lmmCode = code,
|
||||
_lmmArguments =
|
||||
HM.fromList
|
||||
[ (LogicalModelArgumentName "hey", NullableScalarType PGVarchar False Nothing),
|
||||
(LogicalModelArgumentName "ho", NullableScalarType PGInteger False Nothing)
|
||||
]
|
||||
}
|
||||
fmap snd $
|
||||
runExcept $
|
||||
logicalModelToPreparedStatement
|
||||
crtm
|
||||
lmm
|
||||
{ _lmmCode = code,
|
||||
_lmmArguments =
|
||||
HM.fromList
|
||||
[ (LogicalModelArgumentName "hey", NullableScalarType PGVarchar False Nothing),
|
||||
(LogicalModelArgumentName "ho", NullableScalarType PGInteger False Nothing)
|
||||
]
|
||||
}
|
||||
|
||||
(first showQErr actual) `shouldSatisfy` isRight
|
||||
let Right rendered = actual
|
||||
|
Loading…
Reference in New Issue
Block a user