From 42cd2e69c0a090239496ea03453ddaf72469db04 Mon Sep 17 00:00:00 2001 From: Gil Mizrahi Date: Thu, 7 Oct 2021 16:02:19 +0300 Subject: [PATCH] Add support for customising function root field names PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2468 Co-authored-by: Philip Lykke Carlsen <358550+plcplc@users.noreply.github.com> GitOrigin-RevId: 5ff85bb02e4e651376a40914b7ae0aabc8524a05 --- CHANGELOG.md | 11 +- .../metadata-api/custom-functions.rst | 58 ++- .../core/api-reference/metadata-api/index.rst | 5 + .../core/api-reference/syntax-defs.rst | 31 ++ .../Hasura/Backends/Postgres/DDL/Function.hs | 8 +- .../Backends/Postgres/Instances/Schema.hs | 4 +- server/src-lib/Hasura/GraphQL/Schema/Build.hs | 15 +- .../src-lib/Hasura/GraphQL/Schema/Select.hs | 60 +-- .../src-lib/Hasura/RQL/DDL/Schema/Function.hs | 36 +- server/src-lib/Hasura/RQL/Types.hs | 17 + .../src-lib/Hasura/RQL/Types/ComputedField.hs | 14 +- server/src-lib/Hasura/RQL/Types/Function.hs | 137 ++++++- server/src-lib/Hasura/RQL/Types/Metadata.hs | 10 + server/src-lib/Hasura/Server/API/Backend.hs | 3 +- server/src-lib/Hasura/Server/API/Metadata.hs | 2 + .../Hasura/Server/API/Metadata.hs-boot | 1 + .../functions/track_customised_names.yaml | 349 ++++++++++++++++++ server/tests-py/test_graphql_queries.py | 3 + 18 files changed, 704 insertions(+), 60 deletions(-) create mode 100644 server/tests-py/queries/graphql_query/functions/track_customised_names.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de2f8baa9c..dc66543b18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,17 @@ ## Next release (Add entries below in the order of server, console, cli, docs, others) -- server: add support for openapi json of REST Endpoints +### Function field names customization (#7405) +It is now possible to specify the GraphQL names of tracked SQL functions in +Postgres sources, and different names may be given to the `_aggregate` and +suffix-less versions. Aliases may be set by both +`/v1/metadata/pg_track_function` and the new API endpoint +`/v1/metadata/pg_set_function_customization.` + +### Bug fixes and improvements + +- server: add support for openapi json of REST Endpoints - server: enable inherited roles by default in the graphql-engine - server: support MSSQL insert mutations - console: fix v2 metadata imports diff --git a/docs/graphql/core/api-reference/metadata-api/custom-functions.rst b/docs/graphql/core/api-reference/metadata-api/custom-functions.rst index 3bbdf228ba0..8a13e9072a7 100644 --- a/docs/graphql/core/api-reference/metadata-api/custom-functions.rst +++ b/docs/graphql/core/api-reference/metadata-api/custom-functions.rst @@ -125,7 +125,7 @@ Args syntax * - comment - false - String - - Comment for the function. This comment would replace the auto-generated + - Comment for the function. This comment would replace the auto-generated comment for the function field in the GraphQL schema. .. _pg_untrack_function: @@ -173,6 +173,62 @@ Args syntax - :ref:`SourceName ` - Name of the source database of the function (default: ``default``) +.. _pg_set_function_customization: + +pg_set_function_customization +----------------------------- + +``pg_set_function_customization`` allows you to customize any given function with +a custom name and custom root fields of an already tracked +function. This will **replace** the already present customization. + +Set the configuration for a function called ``search_articles``: + +.. code-block:: http + + POST /v1/metadata HTTP/1.1 + Content-Type: application/json + X-Hasura-Role: admin + + { + "type": "pg_set_function_customization", + "args": { + "function": "search_articles", + "source": "default", + "configuration": { + "custom_root_fields": { + "function": "FindArticles", + "function_aggregate": "FindArticlesAgg" + } + } + } + } + +.. _pg_set_function_customization_syntax: + +Args syntax +^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + + * - Key + - Required + - Schema + - Description + * - function + - true + - :ref:`FunctionName ` + - Name of the function + * - configuration + - false + - :ref:`Function Configuration ` + - Configuration for the function + * - source + - false + - :ref:`SourceName ` + - Name of the source database of the function (default: ``default``) + .. _pg_create_function_permission: pg_create_function_permission diff --git a/docs/graphql/core/api-reference/metadata-api/index.rst b/docs/graphql/core/api-reference/metadata-api/index.rst index 78176b15691..bef45144f4b 100644 --- a/docs/graphql/core/api-reference/metadata-api/index.rst +++ b/docs/graphql/core/api-reference/metadata-api/index.rst @@ -137,6 +137,11 @@ The various types of queries are listed in the following table: - 1 - Remove a Postgres SQL function + * - :ref:`pg_set_function_customization ` + - :ref:`pg_set_function_customization_args ` + - 1 + - Set function customization of an already tracked Postgres function + * - :ref:`pg_create_function_permission` - :ref:`pg_create_function_permission_args ` - 1 diff --git a/docs/graphql/core/api-reference/syntax-defs.rst b/docs/graphql/core/api-reference/syntax-defs.rst index a3da604be17..143b8f0edc7 100644 --- a/docs/graphql/core/api-reference/syntax-defs.rst +++ b/docs/graphql/core/api-reference/syntax-defs.rst @@ -558,6 +558,27 @@ Custom Root Fields - ``String`` - Customise the ``delete__by_pk`` root field +.. _custom_function_root_fields: + +Custom Function Root Fields +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + + * - Key + - Required + - Schema + - Description + * - function + - false + - ``String`` + - Customise the ```` root field + * - function_aggregate + - false + - ``String`` + - Customise the ``_aggregete`` root field + .. _InsertPermission: InsertPermission @@ -1593,6 +1614,16 @@ Function Configuration - Required - Schema - Description + * - custom_name + - false + - ``String`` + - Customise the ```` with the provided custom name value. + The GraphQL nodes for the function will be generated according to the custom name. + * - custom_root_fields + - false + - :ref:`Custom Function Root Fields ` + - Customise the root fields + * - session_argument - false - `String` diff --git a/server/src-lib/Hasura/Backends/Postgres/DDL/Function.hs b/server/src-lib/Hasura/Backends/Postgres/DDL/Function.hs index d7b434f6fd8..4e69ebcc8d0 100644 --- a/server/src-lib/Hasura/Backends/Postgres/DDL/Function.hs +++ b/server/src-lib/Hasura/Backends/Postgres/DDL/Function.hs @@ -61,7 +61,7 @@ buildFunctionInfo :: RawFunctionInfo ('Postgres pgKind) -> Maybe Text -> m (FunctionInfo ('Postgres pgKind), SchemaDependency) -buildFunctionInfo source qf systemDefined FunctionConfig {..} permissions rawFuncInfo comment = +buildFunctionInfo source qf systemDefined fc@FunctionConfig {..} permissions rawFuncInfo comment = either (throw400 NotSupported . showErrors) pure =<< MV.runValidateT validateFunction where @@ -112,11 +112,17 @@ buildFunctionInfo source qf systemDefined FunctionConfig {..} permissions rawFun inputArguments <- makeInputArguments + funcGivenName <- functionGraphQLName @('Postgres pgKind) qf `onLeft` throwError + let retTable = typeToTable returnType retJsonAggSelect = bool JASSingleObject JASMultipleRows retSet + functionInfo = FunctionInfo qf + (getFunctionGQLName funcGivenName fc) + (getFunctionArgsGQLName funcGivenName fc) + (getFunctionAggregateGQLName funcGivenName fc) systemDefined funVol exposeAs diff --git a/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs b/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs index 79772bc9dc1..267cede6a62 100644 --- a/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs +++ b/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs @@ -197,17 +197,15 @@ buildFunctionRelayQueryFields :: SelPermInfo ('Postgres pgKind) -> m [FieldParser n (QueryRootField UnpreparedValue)] buildFunctionRelayQueryFields sourceName sourceInfo queryTagsConfig functionName functionInfo tableName pkeyColumns selPerms = do - funcName <- functionGraphQLName @('Postgres pgKind) functionName `onLeft` throwError let mkRF = RFDB sourceName . AB.mkAnyBackend . SourceConfigWith sourceInfo queryTagsConfig . QDBR - fieldName = funcName <> $$(G.litName "_connection") fieldDesc = Just $ G.Description $ "execute function " <> functionName <<> " which returns " <>> tableName fmap afold $ optionalFieldParser (mkRF . QDBConnection) $ - selectFunctionConnection sourceName functionInfo fieldName fieldDesc pkeyColumns selPerms + selectFunctionConnection sourceName functionInfo fieldDesc pkeyColumns selPerms ---------------------------------------------------------------- -- Individual components diff --git a/server/src-lib/Hasura/GraphQL/Schema/Build.hs b/server/src-lib/Hasura/GraphQL/Schema/Build.hs index 99ed8cd7c46..5ab978cedfd 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Build.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Build.hs @@ -177,27 +177,29 @@ buildFunctionQueryFields :: SelPermInfo b -> m [FieldParser n (QueryRootField UnpreparedValue)] buildFunctionQueryFields sourceName sourceInfo queryTagsConfig functionName functionInfo tableName selPerms = do - funcName <- functionGraphQLName @b functionName `onLeft` throwError let mkRF = RFDB sourceName . AB.mkAnyBackend . SourceConfigWith sourceInfo queryTagsConfig . QDBR + -- select function funcDesc = Just . G.Description $ flip fromMaybe (_fiComment functionInfo) $ "execute function " <> functionName <<> " which returns " <>> tableName -- select function agg - funcAggName = funcName <> $$(G.litName "_aggregate") + funcAggDesc = Just $ G.Description $ "execute function " <> functionName <<> " and query aggregates on result of table type " <>> tableName + queryResultType = case _fiJsonAggSelect functionInfo of JASMultipleRows -> QDBMultipleRows JASSingleObject -> QDBSingleRow + catMaybes <$> sequenceA - [ requiredFieldParser (mkRF . queryResultType) $ selectFunction sourceName functionInfo funcName funcDesc selPerms, - optionalFieldParser (mkRF . QDBAggregation) $ selectFunctionAggregate sourceName functionInfo funcAggName funcAggDesc selPerms + [ requiredFieldParser (mkRF . queryResultType) $ selectFunction sourceName functionInfo funcDesc selPerms, + optionalFieldParser (mkRF . QDBAggregation) $ selectFunctionAggregate sourceName functionInfo funcAggDesc selPerms ] buildFunctionMutationFields :: @@ -212,16 +214,17 @@ buildFunctionMutationFields :: SelPermInfo b -> m [FieldParser n (MutationRootField UnpreparedValue)] buildFunctionMutationFields sourceName sourceInfo queryTagsConfig functionName functionInfo tableName selPerms = do - funcName <- functionGraphQLName @b functionName `onLeft` throwError let mkRF = RFDB sourceName . AB.mkAnyBackend . SourceConfigWith sourceInfo queryTagsConfig . MDBR + funcDesc = Just $ G.Description $ "execute VOLATILE function " <> functionName <<> " which returns " <>> tableName + jsonAggSelect = _fiJsonAggSelect functionInfo catMaybes <$> sequenceA - [ requiredFieldParser (mkRF . MDBFunction jsonAggSelect) $ selectFunction sourceName functionInfo funcName funcDesc selPerms + [ requiredFieldParser (mkRF . MDBFunction jsonAggSelect) $ selectFunction sourceName functionInfo funcDesc selPerms -- TODO: do we want aggregate mutation functions? ] diff --git a/server/src-lib/Hasura/GraphQL/Schema/Select.hs b/server/src-lib/Hasura/GraphQL/Schema/Select.hs index 77c14ab021a..84daeb7b8a3 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Select.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Select.hs @@ -536,34 +536,31 @@ selectFunction :: SourceName -> -- | SQL function info FunctionInfo b -> - -- | field display name - G.Name -> -- | field description, if any Maybe G.Description -> -- | select permissions of the target table SelPermInfo b -> m (FieldParser n (SelectExp b)) -selectFunction sourceName function fieldName description selectPermissions = do +selectFunction sourceName fi@FunctionInfo {..} description selectPermissions = do stringifyNum <- asks $ qcStringifyNum . getter - let tableName = _fiReturnType function - tableInfo <- askTableInfo sourceName tableName + tableInfo <- askTableInfo sourceName _fiReturnType tableArgsParser <- tableArguments sourceName tableInfo selectPermissions - functionArgsParser <- customSQLFunctionArgs function selectionSetParser <- returnFunctionParser sourceName tableInfo selectPermissions + functionArgsParser <- customSQLFunctionArgs fi _fiGQLName _fiGQLArgsName let argsParser = liftA2 (,) functionArgsParser tableArgsParser pure $ - P.subselection fieldName description argsParser selectionSetParser + P.subselection _fiGQLName description argsParser selectionSetParser <&> \((funcArgs, tableArgs'), fields) -> IR.AnnSelectG { IR._asnFields = fields, - IR._asnFrom = IR.FromFunction (_fiName function) funcArgs Nothing, + IR._asnFrom = IR.FromFunction _fiSQLName funcArgs Nothing, IR._asnPerm = tablePermissionsInfo selectPermissions, IR._asnArgs = tableArgs', IR._asnStrfyNum = stringifyNum } where returnFunctionParser = - case _fiJsonAggSelect function of + case _fiJsonAggSelect of JASSingleObject -> tableSelectionSet JASMultipleRows -> tableSelectionList @@ -574,22 +571,19 @@ selectFunctionAggregate :: SourceName -> -- | SQL function info FunctionInfo b -> - -- | field display name - G.Name -> -- | field description, if any Maybe G.Description -> -- | select permissions of the target table SelPermInfo b -> m (Maybe (FieldParser n (AggSelectExp b))) -selectFunctionAggregate sourceName function fieldName description selectPermissions = runMaybeT do - let tableName = _fiReturnType function +selectFunctionAggregate sourceName fi@FunctionInfo {..} description selectPermissions = runMaybeT do guard $ spiAllowAgg selectPermissions xNodesAgg <- hoistMaybe $ nodesAggExtension @b - tableInfo <- askTableInfo sourceName tableName + tableInfo <- askTableInfo sourceName _fiReturnType stringifyNum <- asks $ qcStringifyNum . getter tableGQLName <- getTableGQLName tableInfo tableArgsParser <- lift $ tableArguments sourceName tableInfo selectPermissions - functionArgsParser <- lift $ customSQLFunctionArgs function + functionArgsParser <- lift $ customSQLFunctionArgs fi _fiGQLAggregateName _fiGQLArgsName aggregateParser <- lift $ tableAggregationFields sourceName tableInfo selectPermissions selectionName <- lift $ pure tableGQLName <&> (<> $$(G.litName "_aggregate")) nodesParser <- lift $ tableSelectionList sourceName tableInfo selectPermissions @@ -604,11 +598,11 @@ selectFunctionAggregate sourceName function fieldName description selectPermissi IR.TAFAgg <$> P.subselection_ $$(G.litName "aggregate") Nothing aggregateParser ] pure $ - P.subselection fieldName description argsParser aggregationParser + P.subselection _fiGQLAggregateName description argsParser aggregationParser <&> \((funcArgs, tableArgs'), fields) -> IR.AnnSelectG { IR._asnFields = fields, - IR._asnFrom = IR.FromFunction (_fiName function) funcArgs Nothing, + IR._asnFrom = IR.FromFunction _fiSQLName funcArgs Nothing, IR._asnPerm = tablePermissionsInfo selectPermissions, IR._asnArgs = tableArgs', IR._asnStrfyNum = stringifyNum @@ -621,8 +615,6 @@ selectFunctionConnection :: SourceName -> -- | SQL function info FunctionInfo ('Postgres pgKind) -> - -- | field display name - G.Name -> -- | field description, if any Maybe G.Description -> -- | primary key columns of the target table @@ -630,13 +622,13 @@ selectFunctionConnection :: -- | select permissions of the target table SelPermInfo ('Postgres pgKind) -> m (Maybe (FieldParser n (ConnectionSelectExp ('Postgres pgKind)))) -selectFunctionConnection sourceName function fieldName description pkeyColumns selectPermissions = +selectFunctionConnection sourceName fi@FunctionInfo {..} description pkeyColumns selectPermissions = do + let fieldName = _fiGQLName <> $$(G.litName "_connection") for (relayExtension @('Postgres pgKind)) \xRelayInfo -> do stringifyNum <- asks $ qcStringifyNum . getter - let tableName = _fiReturnType function - tableInfo <- askTableInfo sourceName tableName + tableInfo <- askTableInfo sourceName _fiReturnType tableConnectionArgsParser <- tableConnectionArgs pkeyColumns sourceName tableInfo selectPermissions - functionArgsParser <- customSQLFunctionArgs function + functionArgsParser <- customSQLFunctionArgs fi _fiGQLName _fiGQLArgsName selectionSetParser <- tableConnectionSelectionSet sourceName tableInfo selectPermissions let argsParser = liftA2 (,) functionArgsParser tableConnectionArgsParser pure $ @@ -650,7 +642,7 @@ selectFunctionConnection sourceName function fieldName description pkeyColumns s IR._csSelect = IR.AnnSelectG { IR._asnFields = fields, - IR._asnFrom = IR.FromFunction (_fiName function) funcArgs Nothing, + IR._asnFrom = IR.FromFunction _fiSQLName funcArgs Nothing, IR._asnPerm = tablePermissionsInfo selectPermissions, IR._asnArgs = args, IR._asnStrfyNum = stringifyNum @@ -1412,8 +1404,18 @@ remoteRelationshipField remoteFieldInfo = runMaybeT do customSQLFunctionArgs :: (BackendSchema b, MonadSchema n m, MonadTableInfo r m) => FunctionInfo b -> + G.Name -> + G.Name -> m (InputFieldsParser n (IR.FunctionArgsExpTableRow b (UnpreparedValue b))) -customSQLFunctionArgs FunctionInfo {..} = functionArgs (FTACustomFunction _fiName) _fiInputArgs +customSQLFunctionArgs FunctionInfo {..} functionName functionArgsName = + functionArgs + ( FTACustomFunction $ + CustomFunctionNames + { cfnFunctionName = functionName, + cfnArgsName = functionArgsName + } + ) + _fiInputArgs -- | Parses the arguments to the underlying sql function of a computed field or -- a custom function. All arguments to the underlying sql function are parsed @@ -1463,8 +1465,8 @@ functionArgs functionTrackedAs (toList -> inputArgs) = do computedFieldGQLName <- textToName $ computedFieldNameToText computedFieldName tableGQLName <- getTableGQLName @b tableInfo pure $ computedFieldGQLName <> $$(G.litName "_") <> tableGQLName <> $$(G.litName "_args") - FTACustomFunction functionName -> - fmap (<> $$(G.litName "_args")) $ functionGraphQLName @b functionName `onLeft` throwError + FTACustomFunction (CustomFunctionNames {cfnArgsName}) -> + pure cfnArgsName let fieldName = $$(G.litName "args") fieldDesc = case functionTrackedAs of @@ -1472,8 +1474,8 @@ functionArgs functionTrackedAs (toList -> inputArgs) = do G.Description $ "input parameters for computed field " <> computedFieldName <<> " defined on table " <>> tableName - FTACustomFunction functionName -> - G.Description $ "input parameters for function " <>> functionName + FTACustomFunction (CustomFunctionNames {cfnFunctionName}) -> + G.Description $ "input parameters for function " <>> cfnFunctionName objectParser = P.object objectName Nothing (sequenceA argumentParsers) `P.bind` \arguments -> do -- After successfully parsing, we create a dictionary of the parsed fields diff --git a/server/src-lib/Hasura/RQL/DDL/Schema/Function.hs b/server/src-lib/Hasura/RQL/DDL/Schema/Function.hs index 99ec4733f9f..5bf54f89aba 100644 --- a/server/src-lib/Hasura/RQL/DDL/Schema/Function.hs +++ b/server/src-lib/Hasura/RQL/DDL/Schema/Function.hs @@ -2,7 +2,7 @@ -- Description: Create/delete SQL functions to/from Hasura metadata. module Hasura.RQL.DDL.Schema.Function where -import Control.Lens ((^.)) +import Control.Lens ((.~), (^.)) import Data.Aeson import Data.HashMap.Strict qualified as Map import Data.HashMap.Strict.InsOrd qualified as OMap @@ -289,3 +289,37 @@ runDropFunctionPermission (FunctionPermissionArgument functionName source role) ) $ dropFunctionPermissionInMetadata @b source functionName role pure successMsg + +-- | Represents the payload of the API command 'pg_set_function_customization'. +-- +-- See the Hasura API reference for a detailed description. +data SetFunctionCustomization b = SetFunctionCustomization + { _sfcSource :: SourceName, + _sfcFunction :: FunctionName b, + _sfcConfiguration :: FunctionConfig + } + +deriving instance Backend b => Show (SetFunctionCustomization b) + +deriving instance Backend b => Eq (SetFunctionCustomization b) + +instance (Backend b) => FromJSON (SetFunctionCustomization b) where + parseJSON = withObject "set function customization" $ \o -> + SetFunctionCustomization + <$> o .:? "source" .!= defaultSource + <*> o .: "function" + <*> o .: "configuration" + +-- | Changes the custom names of a function. Used in the API command 'pg_set_function_customization'. +runSetFunctionCustomization :: + forall b m. + (QErrM m, CacheRWM m, MetadataM m, Backend b, BackendMetadata b) => + SetFunctionCustomization b -> + m EncJSON +runSetFunctionCustomization (SetFunctionCustomization source function config) = do + void $ askFunInfo @b source function + buildSchemaCacheFor + (MOSourceObjId source $ AB.mkAnyBackend $ SMOFunction @b function) + $ MetadataModifier $ + ((functionMetadataSetter @b source function) . fmConfiguration) .~ config + return successMsg diff --git a/server/src-lib/Hasura/RQL/Types.hs b/server/src-lib/Hasura/RQL/Types.hs index afba6cd2a97..4a34e205112 100644 --- a/server/src-lib/Hasura/RQL/Types.hs +++ b/server/src-lib/Hasura/RQL/Types.hs @@ -16,6 +16,7 @@ module Hasura.RQL.Types askTabInfoSource, askTableCoreInfo, askTableCoreInfoSource, + askFunInfo, askFieldInfoMap, askFieldInfoMapSource, assertColumnExists, @@ -117,6 +118,22 @@ askTabInfo sourceName tableName = do where errMsg = "table " <> tableName <<> " does not exist in source: " <> sourceNameToText sourceName +-- | Tries to extract the function information from the metadata. +-- +-- If the function does not exist, will throw a 'not-exists' error. +askFunInfo :: + forall b m. + (QErrM m, CacheRM m, Backend b) => + SourceName -> + FunctionName b -> + m (FunctionInfo b) +askFunInfo sourceName functionName = do + rawSchemaCache <- askSchemaCache + unsafeFunctionInfo sourceName functionName (scSources rawSchemaCache) + `onNothing` throw400 NotExists errMsg + where + errMsg = "function " <> functionName <<> " does not exist in source: " <> sourceNameToText sourceName + askTabInfoSource :: forall b m. (QErrM m, TableInfoRM b m, Backend b) => diff --git a/server/src-lib/Hasura/RQL/Types/ComputedField.hs b/server/src-lib/Hasura/RQL/Types/ComputedField.hs index 0390f88c471..5c926f2b39d 100644 --- a/server/src-lib/Hasura/RQL/Types/ComputedField.hs +++ b/server/src-lib/Hasura/RQL/Types/ComputedField.hs @@ -16,6 +16,7 @@ import Hasura.RQL.Types.Backend import Hasura.RQL.Types.Common import Hasura.RQL.Types.Function import Hasura.SQL.Backend +import Language.GraphQL.Draft.Syntax (Name) newtype ComputedFieldName = ComputedFieldName {unComputedFieldName :: NonEmptyText} deriving (Show, Eq, Ord, NFData, FromJSON, ToJSON, ToJSONKey, Q.ToPrepArg, ToTxt, Hashable, Q.FromCol, Generic, Cacheable) @@ -90,10 +91,19 @@ instance ToJSON FunctionSessionArgument where toJSON (FunctionSessionArgument argName _) = toJSON argName data FunctionTrackedAs (b :: BackendType) - = FTAComputedField !ComputedFieldName !SourceName !(TableName b) - | FTACustomFunction !(FunctionName b) + = FTAComputedField ComputedFieldName SourceName (TableName b) + | FTACustomFunction CustomFunctionNames deriving (Generic) +-- | The function name and input arguments name for the "args" field parser. +-- +-- > function_name(args: args_name) +data CustomFunctionNames = CustomFunctionNames + { cfnFunctionName :: Name, + cfnArgsName :: Name + } + deriving (Show, Eq, Generic) + deriving instance Backend b => Show (FunctionTrackedAs b) deriving instance Backend b => Eq (FunctionTrackedAs b) diff --git a/server/src-lib/Hasura/RQL/Types/Function.hs b/server/src-lib/Hasura/RQL/Types/Function.hs index af178e83159..e0362fb2f55 100644 --- a/server/src-lib/Hasura/RQL/Types/Function.hs +++ b/server/src-lib/Hasura/RQL/Types/Function.hs @@ -5,6 +5,7 @@ import Data.Aeson import Data.Aeson.Casing import Data.Aeson.TH import Data.Char (toLower) +import Data.List.Extended as LE import Data.Sequence qualified as Seq import Data.Text qualified as T import Data.Text.Extended @@ -14,6 +15,7 @@ import Hasura.RQL.Types.Backend import Hasura.RQL.Types.Common import Hasura.SQL.Backend import Hasura.Session +import Language.GraphQL.Draft.Syntax qualified as G -- | https://www.postgresql.org/docs/current/xfunc-volatility.html data FunctionVolatility @@ -104,26 +106,70 @@ $(deriveJSON hasuraJSON ''FunctionPermissionInfo) type FunctionPermissionsMap = HashMap RoleName FunctionPermissionInfo +-- | Custom root fields for functions. When set, will be the names exposed +-- to the user in the schema. +-- +-- See rfcs/function-root-field-customisation.md for more information. +data FunctionCustomRootFields = FunctionCustomRootFields + { _fcrfFunction :: Maybe G.Name, + _fcrfFunctionAggregate :: Maybe G.Name + } + deriving (Show, Eq, Generic) + +instance NFData FunctionCustomRootFields + +instance Cacheable FunctionCustomRootFields + +$(deriveToJSON hasuraJSON {omitNothingFields = True} ''FunctionCustomRootFields) + +instance FromJSON FunctionCustomRootFields where + parseJSON = withObject "Object" $ \obj -> do + function <- obj .:? "function" + functionAggregate <- obj .:? "function_aggregate" + + case (function, functionAggregate) of + (Just f, Just fa) + | f == fa -> + fail $ + T.unpack $ + "the following custom root field names are duplicated: " + <> toTxt f <<> " and " <>> toTxt fa + _ -> + pure () + + pure $ FunctionCustomRootFields function functionAggregate + +-- | A function custom root fields without custom names set. This is the default. +emptyFunctionCustomRootFields :: FunctionCustomRootFields +emptyFunctionCustomRootFields = + FunctionCustomRootFields + { _fcrfFunction = Nothing, + _fcrfFunctionAggregate = Nothing + } + -- | Tracked SQL function metadata. See 'buildFunctionInfo'. data FunctionInfo (b :: BackendType) = FunctionInfo - { _fiName :: !(FunctionName b), - _fiSystemDefined :: !SystemDefined, - _fiVolatility :: !FunctionVolatility, + { _fiSQLName :: FunctionName b, + _fiGQLName :: G.Name, + _fiGQLArgsName :: G.Name, + _fiGQLAggregateName :: G.Name, + _fiSystemDefined :: SystemDefined, + _fiVolatility :: FunctionVolatility, -- | In which part of the schema should this function be exposed? -- -- See 'mkFunctionInfo' and '_fcExposedAs'. - _fiExposedAs :: !FunctionExposedAs, - _fiInputArgs :: !(Seq.Seq (FunctionInputArgument b)), + _fiExposedAs :: FunctionExposedAs, + _fiInputArgs :: Seq.Seq (FunctionInputArgument b), -- | NOTE: when a table is created, a new composite type of the same name is -- automatically created; so strictly speaking this field means "the function -- returns the composite type corresponding to this table". - _fiReturnType :: !(TableName b), + _fiReturnType :: TableName b, -- | this field represents the description of the function as present on the database - _fiDescription :: !(Maybe Text), - _fiPermissions :: !FunctionPermissionsMap, + _fiDescription :: Maybe Text, -- | Roles to which the function is accessible - _fiJsonAggSelect :: !JsonAggSelect, - _fiComment :: !(Maybe Text) + _fiPermissions :: FunctionPermissionsMap, + _fiJsonAggSelect :: JsonAggSelect, + _fiComment :: Maybe Text } deriving (Generic) @@ -136,6 +182,56 @@ instance (Backend b) => ToJSON (FunctionInfo b) where $(makeLenses ''FunctionInfo) +-- | Apply function name customization to function arguments, as detailed in +-- 'rfcs/function-root-field-customisation.md'. We want the different +-- variations of a function (i.e. basic, aggregate) to share the same type name +-- for their arguments. +getFunctionArgsGQLName :: + -- | The GQL version of the DB name of the function + G.Name -> + FunctionConfig -> + G.Name +getFunctionArgsGQLName + funcGivenName + FunctionConfig {..} = + fromMaybe funcGivenName _fcCustomName <> $$(G.litName "_args") + +-- | Apply function name customization to the basic function variation, as +-- detailed in 'rfcs/function-root-field-customisation.md'. +getFunctionGQLName :: + G.Name -> + FunctionConfig -> + G.Name +getFunctionGQLName + funcGivenName + FunctionConfig + { _fcCustomRootFields = FunctionCustomRootFields {..}, + .. + } = + choice + [ _fcrfFunction, + _fcCustomName + ] + & fromMaybe funcGivenName + +-- | Apply function name customization to the aggregate function variation, as +-- detailed in 'rfcs/function-root-field-customisation.md'. +getFunctionAggregateGQLName :: + G.Name -> + FunctionConfig -> + G.Name +getFunctionAggregateGQLName + funcGivenName + FunctionConfig + { _fcCustomRootFields = FunctionCustomRootFields {..}, + .. + } = + choice + [ _fcrfFunctionAggregate, + _fcCustomName <&> (<> $$(G.litName "_aggregate")) + ] + & fromMaybe (funcGivenName <> $$(G.litName "_aggregate")) + getInputArgs :: FunctionInfo b -> Seq.Seq (FunctionArg b) getInputArgs = Seq.fromList . mapMaybe (^? _IAUserProvided) . toList . _fiInputArgs @@ -144,15 +240,18 @@ type FunctionCache b = HashMap (FunctionName b) (FunctionInfo b) -- info of all -- Metadata requests related types --- | Tracked function configuration, and payload of the 'track_function' API call. +-- | Tracked function configuration, and payload of the 'pg_track_function' and +-- 'pg_set_function_customization' API calls. data FunctionConfig = FunctionConfig - { _fcSessionArgument :: !(Maybe FunctionArgName), + { _fcSessionArgument :: Maybe FunctionArgName, -- | In which top-level field should we expose this function? -- -- The user might omit this, in which case we'll infer the location from the -- SQL functions volatility. See 'mkFunctionInfo' or the @track_function@ API -- docs for details of validation, etc. - _fcExposedAs :: !(Maybe FunctionExposedAs) + _fcExposedAs :: Maybe FunctionExposedAs, + _fcCustomRootFields :: FunctionCustomRootFields, + _fcCustomName :: Maybe G.Name } deriving (Show, Eq, Generic) @@ -160,11 +259,19 @@ instance NFData FunctionConfig instance Cacheable FunctionConfig -$(deriveJSON hasuraJSON {omitNothingFields = True} ''FunctionConfig) +instance FromJSON FunctionConfig where + parseJSON = withObject "FunctionConfig" $ \obj -> + FunctionConfig + <$> obj .:? "session_argument" + <*> obj .:? "exposed_as" + <*> obj .:? "custom_root_fields" .!= emptyFunctionCustomRootFields + <*> obj .:? "custom_name" + +$(deriveToJSON hasuraJSON {omitNothingFields = True} ''FunctionConfig) -- | The default function config; v1 of the API implies this. emptyFunctionConfig :: FunctionConfig -emptyFunctionConfig = FunctionConfig Nothing Nothing +emptyFunctionConfig = FunctionConfig Nothing Nothing emptyFunctionCustomRootFields Nothing -- Lists are used to model overloaded functions. type DBFunctionsMetadata b = HashMap (FunctionName b) [RawFunctionInfo b] diff --git a/server/src-lib/Hasura/RQL/Types/Metadata.hs b/server/src-lib/Hasura/RQL/Types/Metadata.hs index 7d425ada3e5..c79b3bc168a 100644 --- a/server/src-lib/Hasura/RQL/Types/Metadata.hs +++ b/server/src-lib/Hasura/RQL/Types/Metadata.hs @@ -489,6 +489,16 @@ tableMetadataSetter :: tableMetadataSetter source table = metaSources . ix source . toSourceMetadata . smTables . ix table +-- | A lens setter for the metadata of a specific function as identified by +-- the source name and function name. +functionMetadataSetter :: + (BackendMetadata b) => + SourceName -> + FunctionName b -> + ASetter' Metadata (FunctionMetadata b) +functionMetadataSetter source function = + metaSources . ix source . toSourceMetadata . smFunctions . ix function + data MetadataNoSources = MetadataNoSources { _mnsTables :: !(Tables ('Postgres 'Vanilla)), _mnsFunctions :: !(Functions ('Postgres 'Vanilla)), diff --git a/server/src-lib/Hasura/Server/API/Backend.hs b/server/src-lib/Hasura/Server/API/Backend.hs index 2c409163317..7b18cc411b7 100644 --- a/server/src-lib/Hasura/Server/API/Backend.hs +++ b/server/src-lib/Hasura/Server/API/Backend.hs @@ -86,7 +86,8 @@ tablePermissionsCommands = ] functionCommands = [ commandParser "track_function" $ RMTrackFunction . mkAnyBackend @b, - commandParser "untrack_function" $ RMUntrackFunction . mkAnyBackend @b + commandParser "untrack_function" $ RMUntrackFunction . mkAnyBackend @b, + commandParser "set_function_customization" $ RMSetFunctionCustomization . mkAnyBackend @b ] functionPermissionsCommands = [ commandParser "create_function_permission" $ RMCreateFunctionPermission . mkAnyBackend @b, diff --git a/server/src-lib/Hasura/Server/API/Metadata.hs b/server/src-lib/Hasura/Server/API/Metadata.hs index 5a0479b57e3..b1c88b169f0 100644 --- a/server/src-lib/Hasura/Server/API/Metadata.hs +++ b/server/src-lib/Hasura/Server/API/Metadata.hs @@ -85,6 +85,7 @@ data RQLMetadataV1 | -- Functions RMTrackFunction !(AnyBackend TrackFunctionV2) | RMUntrackFunction !(AnyBackend UnTrackFunction) + | RMSetFunctionCustomization (AnyBackend SetFunctionCustomization) | -- Functions permissions RMCreateFunctionPermission !(AnyBackend FunctionPermissionArgument) | RMDropFunctionPermission !(AnyBackend FunctionPermissionArgument) @@ -395,6 +396,7 @@ runMetadataQueryV1M env currentResourceVersion = \case RMRenameSource q -> runRenameSource q RMTrackTable q -> dispatchMetadata runTrackTableV2Q q RMUntrackTable q -> dispatchMetadata runUntrackTableQ q + RMSetFunctionCustomization q -> dispatchMetadata runSetFunctionCustomization q RMSetTableCustomization q -> dispatchMetadata runSetTableCustomization q RMPgSetTableIsEnum q -> runSetExistingTableIsEnumQ q RMCreateInsertPermission q -> dispatchMetadata runCreatePerm q diff --git a/server/src-lib/Hasura/Server/API/Metadata.hs-boot b/server/src-lib/Hasura/Server/API/Metadata.hs-boot index c08ba9603fa..d8ad45e031d 100644 --- a/server/src-lib/Hasura/Server/API/Metadata.hs-boot +++ b/server/src-lib/Hasura/Server/API/Metadata.hs-boot @@ -49,6 +49,7 @@ data RQLMetadataV1 | -- Functions RMTrackFunction !(AnyBackend TrackFunctionV2) | RMUntrackFunction !(AnyBackend UnTrackFunction) + | RMSetFunctionCustomization (AnyBackend SetFunctionCustomization) | -- Functions permissions RMCreateFunctionPermission !(AnyBackend FunctionPermissionArgument) | RMDropFunctionPermission !(AnyBackend FunctionPermissionArgument) diff --git a/server/tests-py/queries/graphql_query/functions/track_customised_names.yaml b/server/tests-py/queries/graphql_query/functions/track_customised_names.yaml new file mode 100644 index 00000000000..a84136dd399 --- /dev/null +++ b/server/tests-py/queries/graphql_query/functions/track_customised_names.yaml @@ -0,0 +1,349 @@ +# # Overview +# In this file, we are testing that: +# +# * We can supply custom names to `pg_track_function` and have them applied in the schema +# * Name aliases are applied correctly for all the possible specified cases, see https://github.com/hasura/graphql-engine-mono/pull/2419 +# * We can omit custom names from `pg_track_function` +# * We can set and unset custom names with `set_function_customization` +# * We test that we don't introduce name clashes + + +- description: Define the SQL function the tests will be using + url: /v2/query + status: 200 + query: + type: run_sql + args: + sql: | + create function unaliased_function(t test) + returns setof post as $$ + select * + from post + where + title ilike ('%' || t.name || '%') + $$ language sql stable; + + +# Test: We can supply (all) aliases via `pg_track_function`, and the names are applied. + +- description: Test that we supply all custom names to `pg_track_function`. + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_track_function + args: + function: unaliased_function + configuration: + custom_root_fields: + function: nameBase + function_aggregate: nameAggregate + +- description: Execute the function without the alias, which should fail + url: /v1/graphql + status: 200 + query: + query: | + query { + unaliased_function (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + title + content + } + } + response: + "errors": [ + { + "message": "field \"unaliased_function\" not found in type: 'query_root'", + "extensions": { + "code": "validation-failed", + "path": "$.selectionSet.unaliased_function" + } + } + ] + +- description: Execute the function by its alias, which should succeed + url: /v1/graphql + status: 200 + query: + query: | + query { + nameBase (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + title + content + } + } + response: + data: + nameBase: + - title: post by hasura + content: content for post + +- description: Execute the function with aggregation by its aggregation-alias, which should succeed + url: /v1/graphql + status: 200 + query: + query: | + query { + nameAggregate (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + aggregate { + count + } + } + } + response: + data: + nameAggregate: + aggregate: + count: 1 + +- description: Teardown the test of fully given aliases + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_untrack_function + args: + name: unaliased_function + + +# Test: We can supply just the base alias via `pg_track_function`, and the names are applied. + +- description: Test that we supply all custom names to `pg_track_function`. + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_track_function + args: + function: unaliased_function + configuration: + custom_name: aliased + +- description: Execute the function without the alias, which should fail + url: /v1/graphql + status: 200 + query: + query: | + query { + unaliased_function (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + title + content + } + } + response: + "errors": [ + { + "message": "field \"unaliased_function\" not found in type: 'query_root'", + "extensions": { + "code": "validation-failed", + "path": "$.selectionSet.unaliased_function" + } + } + ] + +- description: Execute the function by its alias, which should succeed + url: /v1/graphql + status: 200 + query: + query: | + query { + aliased (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + title + content + } + } + response: + data: + aliased: + - title: post by hasura + content: content for post + +- description: Execute the function with aggregation by its aggregation-alias, which should succeed + url: /v1/graphql + status: 200 + query: + query: | + query { + aliased_aggregate (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + aggregate { + count + } + } + } + response: + data: + aliased_aggregate: + aggregate: + count: 1 + + +# Test: Using set_function_customization + +- description: Test that we fail on non-existing functions + url: /v1/metadata + status: 400 + response: + code: not-exists + error: 'function "non_existing_function" does not exist in source: default' + path: $.args + query: + type: pg_set_function_customization + args: + function: non_existing_function + configuration: { } + +- description: Test that we fail on duplicate custom root fields + url: /v1/metadata + status: 400 + response: + code: parse-failed + error: "Error when parsing command set_function_customization.\n\ + See our documentation at https://hasura.io/docs/latest/graphql/core/api-reference/metadata-api/index.html#metadata-apis.\n\ + Internal error message: the following custom root field names are duplicated: \"duplicate_name\" and \"duplicate_name\"" + path: $.args.configuration.custom_root_fields + query: + type: pg_set_function_customization + args: + function: unaliased_function + configuration: + custom_root_fields: + function: duplicate_name + function_aggregate: duplicate_name + +- description: Test that we can clear the customized names + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_set_function_customization + args: + function: unaliased_function + configuration: { } + +- descripion: Execute the function without the alias, which should now work + url: /v1/graphql + status: 200 + query: + query: | + query { + unaliased_function (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + title + content + } + } + response: + data: + unaliased_function: + - title: post by hasura + content: content for post + +- description: Test that we can set customized names using `set_function_customization` + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_set_function_customization + args: + function: unaliased_function + configuration: + custom_root_fields: + function: nameBaseOther + +- description: Execute the function by its alias + url: /v1/graphql + status: 200 + query: + query: | + query { + nameBaseOther (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + title + content + } + } + response: + data: + nameBaseOther: + - title: post by hasura + content: content for post + +- description: Execute the function with aggregation (unaliased), which should succeed. + url: /v1/graphql + status: 200 + query: + query: | + query { + unaliased_function_aggregate (args: {t: "(1, hasura,311cf381-71e7-449b-bac5-86cd6deafd5b)"}) { + aggregate { + count + } + } + } + response: + data: + unaliased_function_aggregate: + aggregate: + count: 1 + +# Test: We reject name clashes: A function is aliased to an existing (unaliased) function. +# Note: We are not going to go to great lengths to ensure all the different +# conceivable ways collisions could be introduced are avoided, because there is +# a general mechanism in the code that builds the schema cache which ensures no +# collisions appear in the final, generated schema. +# (issue https://github.com/hasura/graphql-engine-mono/issues/1868 has a bit +# more info on this subject) + +- description: "Setup: Define a separate function to cause clashes" + url: /v2/query + status: 200 + query: + type: run_sql + args: + sql: | + create function unaliased_function2(t test) + returns setof post as $$ + select * + from post + where + title ilike ('%' || t.name || '%') + $$ language sql stable; + +- description: "Setup: Track the new function" + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_track_function + args: + function: unaliased_function2 + +- description: "Setup: clear up aliases of unaliased_function" + url: /v1/metadata + status: 200 + response: + message: success + query: + type: pg_set_function_customization + args: + function: unaliased_function + configuration: {} + +- description: Test that we cannot alias `unaliased_function2` to `unaliased_function` + url: /v1/metadata + status: 500 + response: + code: unexpected + error: 'found duplicate fields in selection set: unaliased_function_aggregate, unaliased_function' + path: $.args + + query: + type: pg_set_function_customization + args: + function: unaliased_function2 + configuration: + custom_name: unaliased_function diff --git a/server/tests-py/test_graphql_queries.py b/server/tests-py/test_graphql_queries.py index b50158a8a07..b206cfeb4f6 100644 --- a/server/tests-py/test_graphql_queries.py +++ b/server/tests-py/test_graphql_queries.py @@ -1019,6 +1019,9 @@ class TestGraphQLQueryFunctions: def test_tracking_function_with_composite_type_argument(self, hge_ctx): check_query_f(hge_ctx, self.dir() + '/track_non_base_function_arg_type.yaml') + def test_tracking_function_with_customized_names(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + '/track_customised_names.yaml') + @classmethod def dir(cls): return 'queries/graphql_query/functions'