diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dceac526..66df8ded 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: package_name: - pg-graphql pgrx_version: - - 0.10.1 + - 0.10.2 postgres: [14, 15, 16] box: - { runner: ubuntu-20.04, arch: amd64 } diff --git a/Cargo.lock b/Cargo.lock index 0958e81e..b978485c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,12 +246,12 @@ checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663" [[package]] name = "cargo_toml" -version = "0.15.3" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838" +checksum = "70a1f1117a8ff2f3547295da90f473c392d8d1107c90cea1ea82b1a544a97a4a" dependencies = [ "serde", - "toml 0.7.6", + "toml", ] [[package]] @@ -1113,9 +1113,9 @@ dependencies = [ [[package]] name = "pgrx" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c4fe036f9493e674a7db27f7a06d54acb89735a0c1d4421128c341d3cf240f" +checksum = "dde2cf81d16772f2e75c91edd2e868de1bd67a79d6c45c3d25c62b2ed3851d70" dependencies = [ "atomic-traits", "bitflags 2.4.0", @@ -1138,9 +1138,9 @@ dependencies = [ [[package]] name = "pgrx-macros" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943f9e2f46585b8f4540c72e26b079303b3520f8de0da090f96acaabe3f99e28" +checksum = "1b9c035c16a41b126f8c2b37307f2c717b5ee72ff8e7495ff502ad35471a0b38" dependencies = [ "pgrx-sql-entity-graph", "proc-macro2", @@ -1150,9 +1150,9 @@ dependencies = [ [[package]] name = "pgrx-pg-config" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a48800161320294c5a5ab72d38235e2e97f3bf1849661ee977502c8e64362285" +checksum = "16f9d9b6310ea9f13570d773d173bbcfe47ac844075bf6a3e207e7209786c631" dependencies = [ "cargo_toml", "dirs", @@ -1162,15 +1162,15 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "toml 0.8.0", + "toml", "url", ] [[package]] name = "pgrx-pg-sys" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27dd6c4f2f098f62b5a8f07cf6447f640ce81637d20f269662a043a55195ed9d" +checksum = "f821614646963302a8499b8ac8332cc0e2ae3f8715a0220986984443d8880f74" dependencies = [ "bindgen", "eyre", @@ -1190,9 +1190,9 @@ dependencies = [ [[package]] name = "pgrx-sql-entity-graph" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7514a083c8c062f8bb9f53ced124f120515af3b49b92a57305e749ec736f98c" +checksum = "4743b5b23fd418cded0c2dbe4b1529628f7fa59b8d68426eafdde0cb51541c96" dependencies = [ "convert_case", "eyre", @@ -1205,9 +1205,9 @@ dependencies = [ [[package]] name = "pgrx-tests" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a71327ab5e5ddbc2d43ffad391ce515a358096c3713b360b57c5eec107d1cb7" +checksum = "177bb8f6811bd65180c5a24a33666baed0ed5c08cc584c4bdb78f7fe19304363" dependencies = [ "clap-cargo", "eyre", @@ -1895,18 +1895,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "toml" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17e963a819c331dcacd7ab957d80bc2b9a9c1e71c804826d2f283dd65306542" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit 0.19.12", -] - [[package]] name = "toml" version = "0.8.0" @@ -1916,7 +1904,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.20.0", + "toml_edit", ] [[package]] @@ -1928,19 +1916,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_edit" -version = "0.19.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c500344a19072298cd05a7224b3c0c629348b78692bf48466c5238656e315a78" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow 0.4.9", -] - [[package]] name = "toml_edit" version = "0.20.0" @@ -1951,7 +1926,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "winnow 0.5.15", + "winnow", ] [[package]] @@ -2243,15 +2218,6 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" -[[package]] -name = "winnow" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81a2094c43cc94775293eaa0e499fbc30048a6d824ac82c0351a8c0bf9112529" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index b09c34c2..4331f28e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ pg16 = ["pgrx/pg16", "pgrx-tests/pg16"] pg_test = [] [dependencies] -pgrx = "=0.10.1" +pgrx = "=0.10.2" graphql-parser = "0.4" serde = { version = "1.0", features = ["rc"] } serde_json = "1.0" @@ -28,7 +28,7 @@ lazy_static = "1" bimap = { version = "0.6.3", features = ["serde"] } [dev-dependencies] -pgrx-tests = "=0.10.1" +pgrx-tests = "=0.10.2" [profile.dev] panic = "unwind" diff --git a/dockerfiles/db/Dockerfile b/dockerfiles/db/Dockerfile index 8b4c2c74..9f878787 100644 --- a/dockerfiles/db/Dockerfile +++ b/dockerfiles/db/Dockerfile @@ -28,7 +28,7 @@ RUN \ cargo --version # PGRX -RUN cargo install cargo-pgrx --version 0.10.1 --locked +RUN cargo install cargo-pgrx --version 0.10.2 --locked RUN cargo pgrx init --pg${PG_MAJOR} $(which pg_config) diff --git a/docs/changelog.md b/docs/changelog.md index a7bc7e01..31ccc0c5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -36,10 +36,13 @@ - bugfix: foreign keys on non-null columns produce non-null GraphQL relationships ## 1.3.0 -- rename enum variants with comment directive `@graphql({"mappings": "sql-value": "graphql_value""})` +- feature: rename enum variants with comment directive `@graphql({"mappings": "sql-value": "graphql_value""})` - bugfix: query with more than 50 fields fails - bugfix: @skip and @include directives missing from introspection schema - feature: Support for `and`, `or` and `not` operators in filters - bugfix: queries failed to run if the database was in read-only replica mode ## master +- feature: citext type represented as a GraphQL String +- feature: Support for Postgres 16 +- feature: Support for user defined function diff --git a/docs/functions.md b/docs/functions.md new file mode 100644 index 00000000..f31f50b0 --- /dev/null +++ b/docs/functions.md @@ -0,0 +1,245 @@ +Functions can be exposed by pg_graphql to allow running custom queries or mutations. + +## Query vs Mutation + +For example, a function to add two numbers will be available on the query type as a field: + +=== "Function" + + ```sql + create function "addNums"(a int, b int) + returns int + immutable + language sql + as $$ select a + b; $$; + ``` + +=== "QueryType" + + ```graphql + type Query { + addNums(a: Int, b: Int): Int + } + ``` + + +=== "Query" + + ```graphql + query { + addNums(a: 2, b: 3) + } + ``` + +=== "Response" + + ```json + { + "data": { + "addNums": 5 + } + } + ``` + +Functions marked `immutable` or `stable` are available on the query type. Functions marked with the default `volatile` category are available on the mutation type: + +=== "Function" + + ```sql + create table account( + id serial primary key, + email varchar(255) not null + ); + + create function "addAccount"(email text) + returns int + volatile + language sql + as $$ insert into account (email) values (email) returning id; $$; + ``` + +=== "MutationType" + + ```graphql + type Mutation { + addAccount(email: String): Int + } + ``` + +=== "Query" + + ```graphql + mutation { + addAccount(email: "email@example.com") + } + ``` + +=== "Response" + + ```json + { + "data": { + "addAccount": 1 + } + } + ``` + + +## Supported Return Types + + +Built-in GraphQL scalar types `Int`, `Float`, `String`, `Boolean` and [custom scalar types](/pg_graphql/api/#custom-scalars) are supported as function arguments and return types. Function types returning a table or view are supported as well: + +=== "Function" + + ```sql + create table account( + id serial primary key, + email varchar(255) not null + ); + + insert into account(email) + values + ('a@example.com'), + ('b@example.com'); + + create function "accountById"("accountId" int) + returns account + stable + language sql + as $$ select id, email from account where id = "accountId"; $$; + ``` + +=== "MutationType" + + ```graphql + type Mutation { + addAccount(email: String): Int + } + ``` + +=== "Query" + + ```graphql + query { + accountById(accountId: 1) { + id + email + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "accountById": { + "id": 1, + "email": "a@example.com" + } + } + } + ``` + +Functions returning multiple rows of a table or view are exposed as [collections](/pg_graphql/api/#collections). + +=== "Function" + + ```sql + create table "Account"( + id serial primary key, + email varchar(255) not null + ); + + insert into "Account"(email) + values + ('a@example.com'), + ('a@example.com'), + ('b@example.com'); + + create function "accountsByEmail"("emailToSearch" text) + returns setof "Account" + stable + language sql + as $$ select id, email from "Account" where email = "emailToSearch"; $$; + ``` + +=== "QueryType" + + ```graphql + type Query { + accountsByEmail( + emailToSearch: String + + """Query the first `n` records in the collection""" + first: Int + + """Query the last `n` records in the collection""" + last: Int + + """Query values in the collection before the provided cursor""" + before: Cursor + + """Query values in the collection after the provided cursor""" + after: Cursor + + """Filters to apply to the results set when querying from the collection""" + filter: AccountFilter + + """Sort order to apply to the collection""" + orderBy: [AccountOrderBy!] + ): AccountConnection + } + ``` + +=== "Query" + + ```graphql + query { + accountsByEmail(emailToSearch: "a@example.com", first: 1) { + edges { + node { + id + email + } + } + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "accountsByEmail": { + "edges": [ + { + "node": { + "id": 1, + "email": "a@example.com" + } + } + ] + } + } + } + ``` + +!!! note + + A set returning function with any of its argument names clashing with argument names of a collection (`first`, `last`, `before`, `after`, `filter`, or `orderBy`) will not be exposed. + +## Limitations + +The following features are not yet supported. Any function using these features is not exposed in the API: + +* Functions that return a record type +* Functions that accept a table's tuple type +* Overloaded functions +* Functions with a nameless argument +* Functions with a default argument +* Functions returning void +* Variadic functions +* Function that accept or return an array type diff --git a/mkdocs.yaml b/mkdocs.yaml index 9883c44c..ccbc9e86 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -11,6 +11,7 @@ nav: - SQL Interface: 'sql_interface.md' - API: 'api.md' - Views: 'views.md' + - Functions: 'functions.md' - Computed Fields: 'computed_fields.md' - Security: 'security.md' - Configuration: 'configuration.md' diff --git a/sql/load_sql_context.sql b/sql/load_sql_context.sql index 49fec17e..da5d2c0f 100644 --- a/sql/load_sql_context.sql +++ b/sql/load_sql_context.sql @@ -210,6 +210,7 @@ select 'oid', pc.oid::int, 'name', pc.relname::text, 'relkind', pc.relkind::text, + 'reltype', pc.reltype::int, 'schema', schemas_.name, 'schema_oid', pc.relnamespace::int, 'comment', pg_catalog.obj_description(pc.oid, 'pg_class'), @@ -235,53 +236,6 @@ select from directives d ), - 'functions', coalesce( - ( - select - jsonb_agg( - jsonb_build_object( - 'oid', pp.oid::int, - 'name', pp.proname::text, - 'type_oid', pp.prorettype::oid::int, - 'type_name', pp.prorettype::regtype::text, - 'schema_oid', pronamespace::int, - 'schema_name', pronamespace::regnamespace::text, - -- Functions may be defined as "returns sefof rows 1" - -- those should return a single record, not a connection - -- this is important because set returning functions are inlined - -- and returning a single record isn't. - 'is_set_of', pp.proretset::bool and pp.prorows <> 1, - 'n_rows', pp.prorows::int, - 'comment', pg_catalog.obj_description(pp.oid, 'pg_proc'), - 'directives', ( - with directives(directive) as ( - select graphql.comment_directive(pg_catalog.obj_description(pp.oid, 'pg_proc')) - ) - select - jsonb_build_object( - 'name', d.directive ->> 'name', - 'description', d.directive ->> 'description' - ) - from - directives d - ), - 'permissions', jsonb_build_object( - 'is_executable', pg_catalog.has_function_privilege( - current_user, - pp.oid, - 'EXECUTE' - ) - ) - ) - ) - from - pg_catalog.pg_proc pp - where - pp.pronargs = 1 -- one argument - and pp.proargtypes[0] = pc.reltype -- first argument is table type - ), - jsonb_build_array() - ), 'indexes', coalesce( ( select @@ -411,6 +365,58 @@ select ) ), jsonb_build_object() + ), + 'functions', coalesce( + ( + select + jsonb_agg( + jsonb_build_object( + 'oid', pp.oid::int, + 'name', pp.proname::text, + 'type_oid', pp.prorettype::oid::int, + 'type_name', pp.prorettype::regtype::text, + 'schema_oid', pronamespace::int, + 'schema_name', pronamespace::regnamespace::text, + 'arg_types', proargtypes::int[], + 'arg_names', proargnames::text[], + 'num_args', pronargs, + 'num_default_args', pronargdefaults, + 'arg_type_names', pp.proargtypes::regtype[]::text[], + 'volatility', pp.provolatile, + -- Functions may be defined as "returns sefof rows 1" + -- those should return a single record, not a connection + -- this is important because set returning functions are inlined + -- and returning a single record isn't. + 'is_set_of', pp.proretset::bool and pp.prorows <> 1, + 'n_rows', pp.prorows::int, + 'comment', pg_catalog.obj_description(pp.oid, 'pg_proc'), + 'directives', ( + with directives(directive) as ( + select graphql.comment_directive(pg_catalog.obj_description(pp.oid, 'pg_proc')) + ) + select + jsonb_build_object( + 'name', d.directive ->> 'name', + 'description', d.directive ->> 'description' + ) + from + directives d + ), + 'permissions', jsonb_build_object( + 'is_executable', pg_catalog.has_function_privilege( + current_user, + pp.oid, + 'EXECUTE' + ) + ) + ) + ) + from + pg_catalog.pg_proc pp + join search_path_oids spo + on pp.pronamespace = spo.schema_oid + ), + jsonb_build_array() ) ) diff --git a/src/builder.rs b/src/builder.rs index 1b830df8..a60ab0ac 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -5,6 +5,7 @@ use crate::sql_types::*; use graphql_parser::query::*; use serde::Serialize; use std::collections::HashMap; +use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; @@ -242,7 +243,7 @@ where match &type_ { __Type::InsertResponse(xtype) => { // Raise for disallowed arguments - restrict_allowed_arguments(vec!["objects"], query_field)?; + restrict_allowed_arguments(&["objects"], query_field)?; let objects: Vec = read_argument_objects(field, query_field, variables)?; @@ -269,6 +270,7 @@ where selection_field, fragment_definitions, variables, + &[], ); InsertSelection::Records(node_builder?) } @@ -396,7 +398,7 @@ where match &type_ { __Type::UpdateResponse(xtype) => { // Raise for disallowed arguments - restrict_allowed_arguments(vec!["set", "filter", "atMost"], query_field)?; + restrict_allowed_arguments(&["set", "filter", "atMost"], query_field)?; let set: SetBuilder = read_argument_set(field, query_field, variables)?; let filter: FilterBuilder = read_argument_filter(field, query_field, variables)?; @@ -424,6 +426,7 @@ where selection_field, fragment_definitions, variables, + &[], ); UpdateSelection::Records(node_builder?) } @@ -493,7 +496,7 @@ where match &type_ { __Type::DeleteResponse(xtype) => { // Raise for disallowed arguments - restrict_allowed_arguments(vec!["filter", "atMost"], query_field)?; + restrict_allowed_arguments(&["filter", "atMost"], query_field)?; let filter: FilterBuilder = read_argument_filter(field, query_field, variables)?; let at_most: i64 = read_argument_at_most(field, query_field, variables)?; @@ -520,6 +523,7 @@ where selection_field, fragment_definitions, variables, + &[], ); DeleteSelection::Records(node_builder?) } @@ -546,6 +550,129 @@ where } } +pub struct FunctionCallBuilder { + pub alias: String, + + // metadata + pub function: Arc, + + // args + pub args_builder: FuncCallArgsBuilder, + + pub return_type_builder: FuncCallReturnTypeBuilder, +} + +pub enum FuncCallReturnTypeBuilder { + Scalar, + Node(NodeBuilder), + Connection(ConnectionBuilder), +} + +#[derive(Clone, Debug)] +pub struct FuncCallArgsBuilder { + pub args: Vec<(Option, serde_json::Value)>, +} + +#[derive(Clone, Debug)] +pub struct FuncCallSqlArgName { + pub type_name: String, + pub name: String, +} + +pub fn to_function_call_builder<'a, T>( + field: &__Field, + query_field: &graphql_parser::query::Field<'a, T>, + fragment_definitions: &Vec>, + variables: &serde_json::Value, +) -> Result +where + T: Text<'a> + Eq + AsRef, +{ + let type_ = field.type_().unmodified_type(); + let alias = alias_or_name(query_field); + + match &type_ { + __Type::FuncCallResponse(func_call_resp_type) => { + let args = field.args(); + let allowed_args: Vec<&str> = args.iter().map(|a| a.name_.as_str()).collect(); + restrict_allowed_arguments(&allowed_args, query_field)?; + let args = read_func_call_args(field, query_field, variables, &func_call_resp_type)?; + + let return_type_builder = match func_call_resp_type.return_type.deref() { + __Type::Scalar(_) => FuncCallReturnTypeBuilder::Scalar, + __Type::Node(_) => { + let node_builder = to_node_builder( + field, + query_field, + fragment_definitions, + variables, + &allowed_args, + )?; + FuncCallReturnTypeBuilder::Node(node_builder) + } + __Type::Connection(_) => { + let connection_builder = to_connection_builder( + field, + query_field, + fragment_definitions, + variables, + &allowed_args, + )?; + FuncCallReturnTypeBuilder::Connection(connection_builder) + } + _ => { + return Err(format!( + "unsupported return type: {}", + func_call_resp_type + .return_type + .unmodified_type() + .name() + .ok_or("Encountered type without name in function call builder")? + )) + } + }; + + Ok(FunctionCallBuilder { + alias, + function: Arc::clone(&func_call_resp_type.function), + args_builder: args, + return_type_builder, + }) + } + _ => Err(format!( + "can not build query for non-function type {:?}", + type_.name() + )), + } +} + +fn read_func_call_args<'a, T>( + field: &__Field, + query_field: &graphql_parser::query::Field<'a, T>, + variables: &serde_json::Value, + func_call_resp_type: &FuncCallResponseType, +) -> Result +where + T: Text<'a> + Eq + AsRef, +{ + let inflected_to_sql_args = func_call_resp_type.inflected_to_sql_args(); + let mut args = vec![]; + for arg in field.args() { + let arg_value = read_argument(&arg.name(), field, query_field, variables)?; + if !arg_value.is_absent() { + let func_call_sql_arg_name = match inflected_to_sql_args.get(&arg.name()) { + Some((type_name, name)) => Some(FuncCallSqlArgName { + type_name: type_name.clone(), + name: name.clone(), + }), + None => None, + }; + args.push((func_call_sql_arg_name, gson::gson_to_json(&arg_value)?)); + }; + } + Ok(FuncCallArgsBuilder { args }) +} + #[derive(Clone, Debug)] pub struct ConnectionBuilderSource { pub table: Arc, @@ -817,7 +944,7 @@ pub enum FunctionSelection { } fn restrict_allowed_arguments<'a, T>( - arg_names: Vec<&str>, + arg_names: &[&str], query_field: &graphql_parser::query::Field<'a, T>, ) -> Result<(), String> where @@ -1074,7 +1201,6 @@ where T: Text<'a> + Eq + AsRef, { let validated: gson::Value = read_argument(arg_name, field, query_field, variables)?; - let _: Scalar = match field.get_arg(arg_name).unwrap().type_().unmodified_type() { __Type::Scalar(x) => x, _ => return Err(format!("Could not argument {}", arg_name)), @@ -1098,11 +1224,13 @@ pub fn to_connection_builder<'a, T>( query_field: &graphql_parser::query::Field<'a, T>, fragment_definitions: &Vec>, variables: &serde_json::Value, + extra_allowed_args: &[&str], ) -> Result where T: Text<'a> + Eq + AsRef, { let type_ = field.type_().unmodified_type(); + let type_ = type_.return_type(); let type_name = type_ .name() .ok_or("Encountered type without name in connection builder")?; @@ -1112,10 +1240,9 @@ where match &type_ { __Type::Connection(xtype) => { // Raise for disallowed arguments - restrict_allowed_arguments( - vec!["first", "last", "before", "after", "filter", "orderBy"], - query_field, - )?; + let mut allowed_args = vec!["first", "last", "before", "after", "filter", "orderBy"]; + allowed_args.extend(extra_allowed_args); + restrict_allowed_arguments(&allowed_args, query_field)?; // TODO: only one of first/last, before/after provided let first: gson::Value = read_argument("first", field, query_field, variables)?; @@ -1331,6 +1458,7 @@ where selection_field, fragment_definitions, variables, + &[], )?; EdgeSelection::Node(node_builder) } @@ -1361,6 +1489,7 @@ pub fn to_node_builder<'a, T>( query_field: &graphql_parser::query::Field<'a, T>, fragment_definitions: &Vec>, variables: &serde_json::Value, + extra_allowed_args: &[&str], ) -> Result where T: Text<'a> + Eq + AsRef, @@ -1369,13 +1498,13 @@ where let alias = alias_or_name(query_field); - let xtype: NodeType = match type_ { + let xtype: NodeType = match type_.return_type() { __Type::Node(xtype) => { - restrict_allowed_arguments(vec![], query_field)?; - xtype + restrict_allowed_arguments(extra_allowed_args, query_field)?; + xtype.clone() } __Type::NodeInterface(node_interface) => { - restrict_allowed_arguments(vec!["nodeId"], query_field)?; + restrict_allowed_arguments(&["nodeId"], query_field)?; // The nodeId argument is only valid on the entrypoint field for Node // relationships to "node" e.g. within edges, do not have any arguments let node_id: NodeIdInstance = read_argument_node_id(field, query_field, variables)?; @@ -1413,7 +1542,9 @@ where let field_map = field_map(&__Type::Node(xtype.clone())); let mut builder_fields = vec![]; - restrict_allowed_arguments(vec!["nodeId"], query_field)?; + let mut allowed_args = vec!["nodeId"]; + allowed_args.extend(extra_allowed_args); + restrict_allowed_arguments(&allowed_args, query_field)?; // The nodeId argument is only valid on the entrypoint field for Node // relationships to "node" e.g. within edges, do not have any arguments @@ -1456,6 +1587,7 @@ where selection_field, fragment_definitions, variables, + &[], // TODO need ref to fkey here )?; FunctionSelection::Node(node_builder) @@ -1466,7 +1598,7 @@ where selection_field, fragment_definitions, variables, - // TODO need ref to fkey here + &[], // TODO need ref to fkey here )?; FunctionSelection::Connection(connection_builder) } @@ -1500,6 +1632,7 @@ where selection_field, fragment_definitions, variables, + &[], ); NodeSelection::Connection(con_builder?) } @@ -1509,6 +1642,7 @@ where selection_field, fragment_definitions, variables, + &[], ); NodeSelection::Node(node_builder?) } diff --git a/src/graphql.rs b/src/graphql.rs index 6b68e7be..acec1cd8 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -5,7 +5,8 @@ use itertools::Itertools; use lazy_static::lazy_static; use regex::Regex; use serde::Serialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::ops::Deref; use std::sync::Arc; lazy_static! { @@ -16,26 +17,22 @@ fn is_valid_graphql_name(name: &str) -> bool { GRAPHQL_NAME_RE.is_match(name) } -fn to_base_type_name( - table_name: &str, - name_override: &Option, - inflect_names: bool, -) -> String { +fn to_base_type_name(name: &str, name_override: &Option, inflect_names: bool) -> String { match name_override { Some(name) => return name.to_string(), None => (), }; match inflect_names { - false => table_name.to_string(), + false => name.to_string(), true => { let mut padded = "+".to_string(); - padded.push_str(table_name); + padded.push_str(name); // account_BY_email => Account_By_Email let casing: String = padded .chars() - .zip(table_name.chars()) + .zip(name.chars()) .map(|(prev, cur)| match prev.is_alphanumeric() { true => cur.to_string(), false => cur.to_uppercase().to_string(), @@ -109,6 +106,12 @@ impl __Schema { lowercase_first_letter(&base_type_name) } + fn graphql_function_arg_name(&self, function: &Function, arg_name: &str) -> String { + let base_type_name = + to_base_type_name(&arg_name, &None, self.inflect_names(function.schema_oid)); + lowercase_first_letter(&base_type_name) + } + fn graphql_enum_base_type_name(&self, enum_: &Enum, inflect_names: bool) -> String { to_base_type_name(&enum_.name, &enum_.directives.name, inflect_names) } @@ -517,6 +520,7 @@ pub enum __Type { UpdateInput(UpdateInputType), UpdateResponse(UpdateResponseType), DeleteResponse(DeleteResponseType), + FuncCallResponse(FuncCallResponseType), OrderBy(OrderByType), OrderByEntity(OrderByEntityType), FilterType(FilterTypeType), @@ -596,6 +600,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.kind(), Self::UpdateResponse(x) => x.kind(), Self::DeleteResponse(x) => x.kind(), + Self::FuncCallResponse(x) => x.kind(), Self::FilterType(x) => x.kind(), Self::FilterEntity(x) => x.kind(), Self::OrderBy(x) => x.kind(), @@ -630,6 +635,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.name(), Self::UpdateResponse(x) => x.name(), Self::DeleteResponse(x) => x.name(), + Self::FuncCallResponse(x) => x.name(), Self::FilterType(x) => x.name(), Self::FilterEntity(x) => x.name(), Self::OrderBy(x) => x.name(), @@ -664,6 +670,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.description(), Self::UpdateResponse(x) => x.description(), Self::DeleteResponse(x) => x.description(), + Self::FuncCallResponse(x) => x.description(), Self::FilterType(x) => x.description(), Self::FilterEntity(x) => x.description(), Self::OrderBy(x) => x.description(), @@ -699,6 +706,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.fields(_include_deprecated), Self::UpdateResponse(x) => x.fields(_include_deprecated), Self::DeleteResponse(x) => x.fields(_include_deprecated), + Self::FuncCallResponse(x) => x.fields(_include_deprecated), Self::FilterType(x) => x.fields(_include_deprecated), Self::FilterEntity(x) => x.fields(_include_deprecated), Self::OrderBy(x) => x.fields(_include_deprecated), @@ -734,6 +742,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.interfaces(), Self::UpdateResponse(x) => x.interfaces(), Self::DeleteResponse(x) => x.interfaces(), + Self::FuncCallResponse(x) => x.interfaces(), Self::FilterType(x) => x.interfaces(), Self::FilterEntity(x) => x.interfaces(), Self::OrderBy(x) => x.interfaces(), @@ -778,6 +787,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.enum_values(_include_deprecated), Self::UpdateResponse(x) => x.enum_values(_include_deprecated), Self::DeleteResponse(x) => x.enum_values(_include_deprecated), + Self::FuncCallResponse(x) => x.enum_values(_include_deprecated), Self::FilterType(x) => x.enum_values(_include_deprecated), Self::FilterEntity(x) => x.enum_values(_include_deprecated), Self::OrderBy(x) => x.enum_values(_include_deprecated), @@ -813,6 +823,7 @@ impl ___Type for __Type { Self::UpdateInput(x) => x.input_fields(), Self::UpdateResponse(x) => x.input_fields(), Self::DeleteResponse(x) => x.input_fields(), + Self::FuncCallResponse(x) => x.input_fields(), Self::FilterType(x) => x.input_fields(), Self::FilterEntity(x) => x.input_fields(), Self::OrderBy(x) => x.input_fields(), @@ -858,6 +869,15 @@ impl __Type { _ => self.clone(), } } + + pub fn return_type(&self) -> &Self { + match self { + __Type::FuncCallResponse(func_call_response_type) => { + func_call_response_type.return_type.deref() + } + t => t, + } + } } #[allow(clippy::upper_case_acronyms)] @@ -957,6 +977,34 @@ pub struct DeleteResponseType { pub schema: Arc<__Schema>, } +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct FuncCallResponseType { + pub function: Arc, + pub schema: Arc<__Schema>, + pub return_type: Box<__Type>, +} + +impl FuncCallResponseType { + pub fn inflected_to_sql_args(&self) -> HashMap { + let inflected_name_to_sql_name: HashMap = self + .function + .args() + .filter_map(|(_, arg_type_name, arg_name)| match arg_name { + None => None, + Some(arg_name) => Some((arg_type_name, arg_name)), + }) + .map(|(arg_type_name, arg_name)| { + ( + self.schema + .graphql_function_arg_name(&self.function, arg_name), + (arg_type_name.to_string(), arg_name.to_string()), + ) + }) + .collect(); + inflected_name_to_sql_name + } +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct ForeignKeyReversible { pub fkey: Arc, @@ -1117,8 +1165,7 @@ impl ___Type for QueryType { } fn fields(&self, _include_deprecated: bool) -> Option> { - let mut f = vec![]; - + let mut f = Vec::new(); let single_entrypoint = __Field { name_: "node".to_string(), type_: __Type::NodeInterface(NodeInterfaceType { @@ -1173,6 +1220,19 @@ impl ___Type for QueryType { } } + let existing_fields: HashSet = f.iter().map(|f| f.name()).collect(); + + let function_fields = function_fields( + &self.schema, + &[FunctionVolatility::Immutable, FunctionVolatility::Stable], + ); + + f.extend( + function_fields + .into_iter() + .filter(|ff| !existing_fields.contains(&ff.name())), + ); + // Default fields always preset f.extend(vec![ __Field { @@ -1206,6 +1266,84 @@ impl ___Type for QueryType { } } +fn function_fields(schema: &Arc<__Schema>, volatilities: &[FunctionVolatility]) -> Vec<__Field> { + let sql_types = &schema.context.types; + let function_name_to_count = Function::function_names_to_count(&schema.context.functions); + schema + .context + .functions + .iter() + .filter(|func| func.is_supported(&schema.context, &function_name_to_count)) + .filter(|func| volatilities.contains(&func.volatility)) + .filter_map(|func| match sql_types.get(&func.type_oid) { + None => None, + Some(sql_type) => { + if let Some(return_type) = sql_type.to_graphql_type(None, func.is_set_of, schema) { + let mut gql_args = function_args(schema, func); + if let __Type::Connection(connection_type) = &return_type { + let connection_args = connection_type.get_connection_input_args(); + let connection_arg_names: HashSet = + connection_args.iter().map(|arg| arg.name()).collect(); + for arg in &gql_args { + if connection_arg_names.contains(&arg.name()) { + return None; + } + } + + gql_args.extend(connection_args); + } + + Some(__Field { + name_: schema.graphql_function_field_name(&func), + type_: __Type::FuncCallResponse(FuncCallResponseType { + function: Arc::clone(func), + schema: Arc::clone(schema), + return_type: Box::new(return_type), + }), + args: gql_args, + description: func.directives.description.clone(), + deprecation_reason: None, + sql_type: Some(NodeSQLType::Function(Arc::clone(func))), + }) + } else { + None + } + } + }) + .filter(|x| is_valid_graphql_name(&x.name_)) + .collect() +} + +fn function_args(schema: &Arc<__Schema>, func: &Arc) -> Vec<__InputValue> { + let sql_types = &schema.context.types; + func.args() + .filter(|(_, _, arg_name)| !arg_name.is_none()) + .filter_map(|(arg_type, _, arg_name)| match sql_types.get(&arg_type) { + Some(t) => { + if matches!(t.category, TypeCategory::Pseudo) { + None + } else { + Some((t, arg_name.unwrap())) + } + } + None => None, + }) + .filter_map( + |(arg_type, arg_name)| match arg_type.to_graphql_type(None, false, schema) { + Some(t) => Some((t, arg_name)), + None => None, + }, + ) + .map(|(arg_type, arg_name)| __InputValue { + name_: schema.graphql_function_arg_name(func, arg_name), + type_: arg_type, + description: None, + default_value: None, + sql_type: None, + }) + .collect() +} + impl ___Type for MutationType { fn kind(&self) -> __TypeKind { __TypeKind::OBJECT @@ -1220,7 +1358,7 @@ impl ___Type for MutationType { } fn fields(&self, _include_deprecated: bool) -> Option> { - let mut f = vec![]; + let mut f = Vec::new(); // TODO, filter to types in type map in case any were filtered out for table in self.schema.context.tables.values() { @@ -1351,6 +1489,15 @@ impl ___Type for MutationType { }) } } + let existing_fields: HashSet = f.iter().map(|f| f.name()).collect(); + + let function_fields = function_fields(&self.schema, &[FunctionVolatility::Volatile]); + + f.extend( + function_fields + .into_iter() + .filter(|ff| !existing_fields.contains(&ff.name())), + ); f.sort_by_key(|a| a.name()); Some(f) } @@ -3076,6 +3223,44 @@ impl ___Type for DeleteResponseType { } } +impl ___Type for FuncCallResponseType { + fn kind(&self) -> __TypeKind { + self.return_type.kind() + } + + fn name(&self) -> Option { + self.return_type.name() + } + + fn description(&self) -> Option { + self.return_type.description() + } + + fn enum_values(&self, include_deprecated: bool) -> Option> { + self.return_type.enum_values(include_deprecated) + } + + fn fields(&self, include_deprecated: bool) -> Option> { + self.return_type.fields(include_deprecated) + } + + fn input_fields(&self) -> Option> { + self.return_type.input_fields() + } + + fn interfaces(&self) -> Option> { + self.return_type.interfaces() + } + + fn of_type(&self) -> Option<__Type> { + self.return_type.of_type() + } + + fn possible_types(&self) -> Option> { + self.return_type.possible_types() + } +} + use std::str::FromStr; use std::string::ToString; @@ -3843,16 +4028,15 @@ impl __Schema { } pub fn mutations_exist(&self) -> bool { - self.context - .tables - .values() - .filter(|x| self.graphql_table_select_types_are_valid(x)) - .any(|x| { - x.permissions.is_selectable - && (x.permissions.is_insertable - || x.permissions.is_updatable - || x.permissions.is_deletable) - }) + let mutation = MutationType { + schema: Arc::new(self.clone()), + }; + if let Some(fields) = mutation.fields(true) { + if fields.len() > 0 { + return true; + } + } + return false; } // queryType: __Type! diff --git a/src/gson.rs b/src/gson.rs index 17d505c7..2052f550 100644 --- a/src/gson.rs +++ b/src/gson.rs @@ -17,6 +17,15 @@ pub enum Value { Object(HashMap), } +impl Value { + pub(crate) fn is_absent(&self) -> bool { + match self { + Value::Absent => true, + _ => false, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub enum Number { Integer(i64), diff --git a/src/resolve.rs b/src/resolve.rs index befe6752..7d80162b 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -189,6 +189,7 @@ where selection, &fragment_definitions, variables, + &[], ); match connection_builder { @@ -207,6 +208,7 @@ where selection, &fragment_definitions, variables, + &[], ); match node_builder { @@ -263,9 +265,30 @@ where let now_json = now_jsonb.0; res_data[alias_or_name(selection)] = now_json; } - _ => res_errors.push(ErrorMessage { - message: "unexpected type found on query object".to_string(), - }), + _ => { + let function_call_builder = to_function_call_builder( + field_def, + selection, + &fragment_definitions, + variables, + ); + + match function_call_builder { + Ok(builder) => { + match ::execute( + &builder, + ) { + Ok(d) => { + res_data[alias_or_name(selection)] = d; + } + Err(msg) => { + res_errors.push(ErrorMessage { message: msg }) + } + } + } + Err(msg) => res_errors.push(ErrorMessage { message: msg }), + } + } }, }, } @@ -420,10 +443,26 @@ where serde_json::json!(mutation_type.name()); conn } - _ => Err(format!( - "unexpected type found on mutation object: {}", - field_def.type_.name().unwrap_or_default() - ))?, + _ => { + let builder = match to_function_call_builder( + field_def, + selection, + &fragment_definitions, + variables, + ) { + Ok(builder) => builder, + Err(err) => { + return Err(err); + } + }; + + let (d, conn) = + ::execute( + &builder, conn, + )?; + res_data[alias_or_name(selection)] = d; + conn + } }, }, } diff --git a/src/sql_types.rs b/src/sql_types.rs index 84378792..deda8f1f 100644 --- a/src/sql_types.rs +++ b/src/sql_types.rs @@ -1,6 +1,7 @@ use bimap::BiBTreeMap; use cached::proc_macro::cached; use cached::SizedCache; +use lazy_static::lazy_static; use pgrx::*; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -55,12 +56,28 @@ pub struct FunctionPermissions { pub is_executable: bool, } +#[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] +pub enum FunctionVolatility { + #[serde(rename(deserialize = "v"))] + Volatile, + #[serde(rename(deserialize = "s"))] + Stable, + #[serde(rename(deserialize = "i"))] + Immutable, +} + #[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct Function { pub oid: u32, pub name: String, pub schema_oid: u32, pub schema_name: String, + pub arg_types: Vec, + pub arg_names: Option>, + pub num_args: u32, + pub num_default_args: u32, + pub arg_type_names: Vec, + pub volatility: FunctionVolatility, pub type_oid: u32, pub type_name: String, pub is_set_of: bool, @@ -69,6 +86,117 @@ pub struct Function { pub permissions: FunctionPermissions, } +impl Function { + pub fn args(&self) -> impl Iterator)> { + ArgsIterator { + index: 0, + arg_types: &self.arg_types, + arg_type_names: &self.arg_type_names, + arg_names: &self.arg_names, + } + } + + pub fn function_names_to_count(all_functions: &[Arc]) -> HashMap<&String, u32> { + let mut function_name_to_count = HashMap::new(); + for function_name in all_functions.iter().map(|f| &f.name) { + let entry = function_name_to_count.entry(function_name).or_insert(0u32); + *entry += 1; + } + function_name_to_count + } + + pub fn is_supported( + &self, + context: &Context, + function_name_to_count: &HashMap<&String, u32>, + ) -> bool { + let types = &context.types; + self.return_type_is_supported(types) + && self.arg_types_are_supported(types) + && !self.is_function_overloaded(function_name_to_count) + && !self.has_a_nameless_arg() + && !self.has_a_default_arg() + && self.permissions.is_executable + } + + fn arg_types_are_supported(&self, types: &HashMap>) -> bool { + self.args().all(|(arg_type, _, _)| { + if let Some(return_type) = types.get(&arg_type) { + return_type.category == TypeCategory::Other + } else { + false + } + }) + } + + fn return_type_is_supported(&self, types: &HashMap>) -> bool { + if let Some(return_type) = types.get(&self.type_oid) { + return_type.category != TypeCategory::Pseudo + && return_type.name != "record" + && !self.type_name.ends_with("[]") + } else { + false + } + } + + fn is_function_overloaded(&self, function_name_to_count: &HashMap<&String, u32>) -> bool { + if let Some(&count) = function_name_to_count.get(&self.name) { + count > 1 + } else { + false + } + } + + fn has_a_nameless_arg(&self) -> bool { + self.args().any(|(_, _, arg_name)| arg_name.is_none()) + } + + fn has_a_default_arg(&self) -> bool { + self.num_default_args > 0 + } +} + +struct ArgsIterator<'a> { + index: usize, + arg_types: &'a [u32], + arg_type_names: &'a Vec, + arg_names: &'a Option>, +} + +lazy_static! { + static ref TEXT_TYPE: String = "text".to_string(); +} + +impl<'a> Iterator for ArgsIterator<'a> { + type Item = (u32, &'a str, Option<&'a str>); + + fn next(&mut self) -> Option { + if self.index < self.arg_types.len() { + debug_assert!(self.arg_types.len() == self.arg_type_names.len()); + let arg_name = if let Some(arg_names) = self.arg_names { + debug_assert!(arg_names.len() >= self.arg_types.len()); + let arg_name = arg_names[self.index].as_str(); + if arg_name != "" { + Some(arg_name) + } else { + None + } + } else { + None + }; + let arg_type = self.arg_types[self.index]; + let mut arg_type_name = &self.arg_type_names[self.index]; + if arg_type_name == "character" { + arg_type_name = &TEXT_TYPE; + } + self.index += 1; + Some((arg_type, arg_type_name, arg_name)) + } else { + None + } + } +} + #[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct TablePermissions { pub is_insertable: bool, @@ -239,8 +367,10 @@ pub struct Table { pub columns: Vec>, pub comment: Option, pub relkind: String, // r = table, v = view, m = mat view, f = foreign table + pub reltype: u32, pub permissions: TablePermissions, pub indexes: Vec, + #[serde(default)] pub functions: Vec>, pub directives: TableDirectives, } @@ -340,6 +470,7 @@ pub struct Context { pub types: HashMap>, pub enums: HashMap>, pub composites: Vec>, + pub functions: Vec>, } impl Hash for Context { @@ -668,9 +799,32 @@ pub fn load_sql_context(_config: &Config) -> Result, String> { context } + /// This pass populates functions for tables + fn populate_table_functions(mut context: Context) -> Context { + let mut arg_type_to_func: HashMap>> = HashMap::new(); + for function in context.functions.iter().filter(|f| f.num_args == 1) { + let functions = arg_type_to_func.entry(function.arg_types[0]).or_default(); + functions.push(function); + } + for (_, table) in &mut context.tables { + if let Some(table) = Arc::get_mut(table) { + match arg_type_to_func.get(&table.reltype) { + Some(functions) => { + for function in functions { + table.functions.push(Arc::clone(function)); + } + } + None => {} + } + } + } + context + } + context .map(type_details) .map(column_types) + .map(populate_table_functions) .map(Arc::new) .map_err(|e| { format!( diff --git a/src/transpile.rs b/src/transpile.rs index 23a508aa..dc84c6dc 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -55,10 +55,10 @@ pub trait MutationEntrypoint<'conn> { let res: pgrx::JsonB = match res_q.first().get::(1) { Ok(Some(dat)) => dat, Ok(None) => JsonB(serde_json::Value::Null), - Err(_) => { - return Err( - "Internal Error: Failed to load result from transpiled query".to_string(), - ); + Err(e) => { + return Err(format!( + "Internal Error: Failed to load result from transpiled query: {e}" + )); } }; @@ -542,6 +542,64 @@ impl MutationEntrypoint<'_> for DeleteBuilder { } } +impl FunctionCallBuilder { + fn to_sql(&self, param_context: &mut ParamContext) -> Result { + let mut arg_clauses = vec![]; + for (arg, arg_value) in &self.args_builder.args { + if let Some(arg) = arg { + let arg_clause = param_context.clause_for(arg_value, &arg.type_name)?; + arg_clauses.push(arg_clause); + } + } + + let args_clause = format!("({})", arg_clauses.join(", ")); + + let block_name = &rand_block_name(); + let func_schema = quote_ident(&self.function.schema_name); + let func_name = quote_ident(&self.function.name); + + let query = match &self.return_type_builder { + FuncCallReturnTypeBuilder::Scalar => { + let type_adjustment_clause = apply_suffix_casts(self.function.type_oid); + format!("select to_jsonb({func_schema}.{func_name}{args_clause}{type_adjustment_clause}) {block_name};") + } + FuncCallReturnTypeBuilder::Node(node_builder) => { + let select_clause = node_builder.to_sql(block_name, param_context)?; + let select_clause = if select_clause.is_empty() { + "jsonb_build_object()".to_string() + } else { + select_clause + }; + format!("select coalesce((select {select_clause} from {func_schema}.{func_name}{args_clause} {block_name} where {block_name} is not null), null::jsonb);") + } + FuncCallReturnTypeBuilder::Connection(connection_builder) => { + let from_clause = format!("{func_schema}.{func_name}{args_clause}"); + let select_clause = connection_builder.to_sql( + Some(block_name), + param_context, + None, + Some(from_clause), + )?; + format!("{select_clause}") + } + }; + + Ok(query) + } +} + +impl MutationEntrypoint<'_> for FunctionCallBuilder { + fn to_sql_entrypoint(&self, param_context: &mut ParamContext) -> Result { + self.to_sql(param_context) + } +} + +impl QueryEntrypoint for FunctionCallBuilder { + fn to_sql_entrypoint(&self, param_context: &mut ParamContext) -> Result { + self.to_sql(param_context) + } +} + impl OrderByBuilder { fn to_order_by_clause(&self, block_name: &str) -> String { let mut frags = vec![]; @@ -600,11 +658,7 @@ pub struct ParamContext { impl ParamContext { // Pushes a parameter into the context and returns a SQL clause to reference it //fn clause_for(&mut self, param: (PgOid, Option)) -> String { - fn clause_for( - &mut self, - value: &serde_json::Value, - type_name: &String, - ) -> Result { + fn clause_for(&mut self, value: &serde_json::Value, type_name: &str) -> Result { let type_oid = match type_name.ends_with("[]") { true => PgOid::BuiltIn(PgBuiltInOids::TEXTARRAYOID), false => PgOid::BuiltIn(PgBuiltInOids::TEXTOID), @@ -855,10 +909,14 @@ impl ConnectionBuilder { quoted_parent_block_name: Option<&str>, param_context: &mut ParamContext, from_func: Option, + from_clause: Option, ) -> Result { let quoted_block_name = rand_block_name(); - let from_clause = self.from_clause("ed_block_name, &from_func); + let from_clause = match from_clause { + Some(from_clause) => format!("{from_clause} {quoted_block_name}"), + None => self.from_clause("ed_block_name, &from_func), + }; let where_clause = self.filter @@ -1011,7 +1069,7 @@ impl ConnectionBuilder { impl QueryEntrypoint for ConnectionBuilder { fn to_sql_entrypoint(&self, param_context: &mut ParamContext) -> Result { - self.to_sql(None, param_context, None) + self.to_sql(None, param_context, None, None) } } @@ -1310,7 +1368,7 @@ impl NodeSelection { Self::Connection(builder) => format!( "{}, {}", quote_literal(&builder.alias), - builder.to_sql(Some(block_name), param_context, None)? + builder.to_sql(Some(block_name), param_context, None, None)? ), Self::Node(builder) => format!( "{}, {}", @@ -1439,6 +1497,7 @@ impl FunctionBuilder { input_table: Arc::clone(&self.table), input_block_name: block_name.to_string(), }), + None, )?, }; Ok(sql_frag) diff --git a/test/expected/function_calls.out b/test/expected/function_calls.out new file mode 100644 index 00000000..1ee24697 --- /dev/null +++ b/test/expected/function_calls.out @@ -0,0 +1,1869 @@ +begin; + savepoint a; + -- Only volatilve functions appear on the mutation object + create function add_smallints(a smallint, b smallint) + returns smallint language sql volatile + as $$ select a + b; $$; + comment on function add_smallints is e'@graphql({"description": "adds two smallints"})'; + select jsonb_pretty(graphql.resolve($$ + mutation { + addSmallints(a: 1, b: 2) + } + $$)); + jsonb_pretty +--------------------------- + { + + "data": { + + "addSmallints": 3+ + } + + } +(1 row) + + create function add_ints(a int, b int) + returns int language sql volatile + as $$ select a + b; $$; + comment on function add_ints is e'@graphql({"name": "intsAdd"})'; + select jsonb_pretty(graphql.resolve($$ + mutation { + intsAdd(a: 2, b: 3) + } + $$)); + jsonb_pretty +---------------------- + { + + "data": { + + "intsAdd": 5+ + } + + } +(1 row) + + comment on schema public is e'@graphql({"inflect_names": false})'; + create function add_bigints(a bigint, b bigint) + returns bigint language sql volatile + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + add_bigints(a: 3, b: 4) + } + $$)); + jsonb_pretty +---------------------------- + { + + "data": { + + "add_bigints": "7"+ + } + + } +(1 row) + + comment on schema public is e'@graphql({"inflect_names": true})'; + create function add_reals(a real, b real) + returns real language sql volatile + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + addReals(a: 4.5, b: 5.6) + } + $$)); + jsonb_pretty +-------------------------- + { + + "data": { + + "addReals": 10.1+ + } + + } +(1 row) + + create function add_doubles(a double precision, b double precision) + returns double precision language sql volatile + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + addDoubles(a: 7.8, b: 9.1) + } + $$)); + jsonb_pretty +---------------------------- + { + + "data": { + + "addDoubles": 16.9+ + } + + } +(1 row) + + create function add_numerics(a numeric, b numeric) + returns numeric language sql volatile + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + addNumerics(a: "11.12", b: "13.14") + } + $$)); + jsonb_pretty +-------------------------------- + { + + "data": { + + "addNumerics": "24.26"+ + } + + } +(1 row) + + create function and_bools(a bool, b bool) + returns bool language sql volatile + as $$ select a and b; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + andBools(a: true, b: false) + } + $$)); + jsonb_pretty +--------------------------- + { + + "data": { + + "andBools": false+ + } + + } +(1 row) + + create function uuid_identity(input uuid) + returns uuid language sql volatile + as $$ select input; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + uuidIdentity(input: "d3ef3a8c-2c72-11ee-b094-776acede7221") + } + $$)); + jsonb_pretty +---------------------------------------------------------------- + { + + "data": { + + "uuidIdentity": "d3ef3a8c-2c72-11ee-b094-776acede7221"+ + } + + } +(1 row) + + create function concat_text(a text, b text) + returns text language sql volatile + as $$ select a || b; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + concatText(a: "Hello ", b: "World") + } + $$)); + jsonb_pretty +------------------------------------- + { + + "data": { + + "concatText": "Hello World"+ + } + + } +(1 row) + + create function next_day(d date) + returns date language sql volatile + as $$ select d + interval '1 day'; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + nextDay(d: "2023-07-28") + } + $$)); + jsonb_pretty +--------------------------------- + { + + "data": { + + "nextDay": "2023-07-29"+ + } + + } +(1 row) + + create function next_hour(t time) + returns time language sql volatile + as $$ select t + interval '1 hour'; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + nextHour(t: "10:20") + } + $$)); + jsonb_pretty +-------------------------------- + { + + "data": { + + "nextHour": "11:20:00"+ + } + + } +(1 row) + + set time zone 'Asia/Kolkata'; -- same as IST + create function next_hour_with_timezone(t time with time zone) + returns time with time zone language sql volatile + as $$ select t + interval '1 hour'; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + nextHourWithTimezone(t: "10:20+05:30") + } + $$)); + jsonb_pretty +-------------------------------------------------- + { + + "data": { + + "nextHourWithTimezone": "11:20:00+05:30"+ + } + + } +(1 row) + + create function next_minute(t timestamp) + returns timestamp language sql volatile + as $$ select t + interval '1 minute'; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + nextMinute(t: "2023-07-28 12:39:05") + } + $$)); + jsonb_pretty +--------------------------------------------- + { + + "data": { + + "nextMinute": "2023-07-28T12:40:05"+ + } + + } +(1 row) + + create function next_minute_with_timezone(t timestamptz) + returns timestamptz language sql volatile + as $$ select t + interval '1 minute'; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + nextMinuteWithTimezone(t: "2023-07-28 12:39:05+05:30") + } + $$)); + jsonb_pretty +--------------------------------------------------------------- + { + + "data": { + + "nextMinuteWithTimezone": "2023-07-28T12:40:05+05:30"+ + } + + } +(1 row) + + create function get_json_obj(input json, key text) + returns json language sql volatile + as $$ select input -> key; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + getJsonObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + jsonb_pretty +------------------------------------------ + { + + "data": { + + "getJsonObj": "{\"b\": \"foo\"}"+ + } + + } +(1 row) + + create function get_jsonb_obj(input jsonb, key text) + returns jsonb language sql volatile + as $$ select input -> key; $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + getJsonbObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + jsonb_pretty +------------------------------------------- + { + + "data": { + + "getJsonbObj": "{\"b\": \"foo\"}"+ + } + + } +(1 row) + + create function concat_chars(a char(2), b char(3)) + returns char(5) language sql volatile + as $$ select (a::char(2) || b::char(3))::char(5); $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + concatChars(a: "He", b: "llo") + } + $$)); + jsonb_pretty +-------------------------------- + { + + "data": { + + "concatChars": "Hello"+ + } + + } +(1 row) + + create function concat_varchars(a varchar(2), b varchar(3)) + returns varchar(5) language sql volatile + as $$ select (a::varchar(2) || b::varchar(3))::varchar(5); $$; + select jsonb_pretty(graphql.resolve($$ + mutation { + concatVarchars(a: "He", b: "llo") + } + $$)); + jsonb_pretty +----------------------------------- + { + + "data": { + + "concatVarchars": "Hello"+ + } + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + mutationType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + jsonb_pretty +------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "mutationType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "BigInt" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "BigInt" + + } + + } + + ], + + "name": "addBigints", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Float" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Float" + + } + + } + + ], + + "name": "addDoubles", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "BigFloat" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "BigFloat" + + } + + } + + ], + + "name": "addNumerics", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Float" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Float" + + } + + } + + ], + + "name": "addReals", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Int" + + } + + } + + ], + + "name": "addSmallints", + + "type": { + + "kind": "SCALAR" + + }, + + "description": "adds two smallints"+ + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Boolean" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Boolean" + + } + + } + + ], + + "name": "andBools", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "String" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "concatChars", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "String" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "concatText", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "String" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "concatVarchars", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "input", + + "type": { + + "name": "JSON" + + } + + }, + + { + + "name": "key", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "getJsonObj", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "input", + + "type": { + + "name": "JSON" + + } + + }, + + { + + "name": "key", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "getJsonbObj", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Int" + + } + + } + + ], + + "name": "intsAdd", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "d", + + "type": { + + "name": "Date" + + } + + } + + ], + + "name": "nextDay", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Time" + + } + + } + + ], + + "name": "nextHour", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Opaque" + + } + + } + + ], + + "name": "nextHourWithTimezone", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Datetime" + + } + + } + + ], + + "name": "nextMinute", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Datetime" + + } + + } + + ], + + "name": "nextMinuteWithTimezone", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "input", + + "type": { + + "name": "UUID" + + } + + } + + ], + + "name": "uuidIdentity", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + } + + ] + + } + + } + + } + + } +(1 row) + + rollback to savepoint a; + -- Only stable and immutable functions appear on the query object + create function add_smallints(a smallint, b smallint) + returns smallint language sql stable + as $$ select a + b; $$; + comment on function add_smallints is e'@graphql({"description": "returns a + b"})'; + select jsonb_pretty(graphql.resolve($$ + query { + addSmallints(a: 1, b: 2) + } + $$)); + jsonb_pretty +--------------------------- + { + + "data": { + + "addSmallints": 3+ + } + + } +(1 row) + + create function add_ints(a int, b int) + returns int language sql immutable + as $$ select a + b; $$; + comment on function add_ints is e'@graphql({"name": "intsAdd"})'; + select jsonb_pretty(graphql.resolve($$ + query { + intsAdd(a: 2, b: 3) + } + $$)); + jsonb_pretty +---------------------- + { + + "data": { + + "intsAdd": 5+ + } + + } +(1 row) + + create function add_bigints(a bigint, b bigint) + returns bigint language sql stable + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + query { + addBigints(a: 3, b: 4) + } + $$)); + jsonb_pretty +--------------------------- + { + + "data": { + + "addBigints": "7"+ + } + + } +(1 row) + + create function add_reals(a real, b real) + returns real language sql immutable + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + query { + addReals(a: 4.5, b: 5.6) + } + $$)); + jsonb_pretty +-------------------------- + { + + "data": { + + "addReals": 10.1+ + } + + } +(1 row) + + create function add_doubles(a double precision, b double precision) + returns double precision language sql stable + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + query { + addDoubles(a: 7.8, b: 9.1) + } + $$)); + jsonb_pretty +---------------------------- + { + + "data": { + + "addDoubles": 16.9+ + } + + } +(1 row) + + create function add_numerics(a numeric, b numeric) + returns numeric language sql immutable + as $$ select a + b; $$; + select jsonb_pretty(graphql.resolve($$ + query { + addNumerics(a: "11.12", b: "13.14") + } + $$)); + jsonb_pretty +-------------------------------- + { + + "data": { + + "addNumerics": "24.26"+ + } + + } +(1 row) + + create function and_bools(a bool, b bool) + returns bool language sql stable + as $$ select a and b; $$; + select jsonb_pretty(graphql.resolve($$ + query { + andBools(a: true, b: false) + } + $$)); + jsonb_pretty +--------------------------- + { + + "data": { + + "andBools": false+ + } + + } +(1 row) + + create function uuid_identity(input uuid) + returns uuid language sql immutable + as $$ select input; $$; + select jsonb_pretty(graphql.resolve($$ + query { + uuidIdentity(input: "d3ef3a8c-2c72-11ee-b094-776acede7221") + } + $$)); + jsonb_pretty +---------------------------------------------------------------- + { + + "data": { + + "uuidIdentity": "d3ef3a8c-2c72-11ee-b094-776acede7221"+ + } + + } +(1 row) + + create function concat_text(a text, b text) + returns text language sql stable + as $$ select a || b; $$; + select jsonb_pretty(graphql.resolve($$ + query { + concatText(a: "Hello ", b: "World") + } + $$)); + jsonb_pretty +------------------------------------- + { + + "data": { + + "concatText": "Hello World"+ + } + + } +(1 row) + + create function next_day(d date) + returns date language sql immutable + as $$ select d + interval '1 day'; $$; + select jsonb_pretty(graphql.resolve($$ + query { + nextDay(d: "2023-07-28") + } + $$)); + jsonb_pretty +--------------------------------- + { + + "data": { + + "nextDay": "2023-07-29"+ + } + + } +(1 row) + + create function next_hour(t time) + returns time language sql stable + as $$ select t + interval '1 hour'; $$; + select jsonb_pretty(graphql.resolve($$ + query { + nextHour(t: "10:20") + } + $$)); + jsonb_pretty +-------------------------------- + { + + "data": { + + "nextHour": "11:20:00"+ + } + + } +(1 row) + + set time zone 'Asia/Kolkata'; -- same as IST + create function next_hour_with_timezone(t time with time zone) + returns time with time zone language sql immutable + as $$ select t + interval '1 hour'; $$; + select jsonb_pretty(graphql.resolve($$ + query { + nextHourWithTimezone(t: "10:20+05:30") + } + $$)); + jsonb_pretty +-------------------------------------------------- + { + + "data": { + + "nextHourWithTimezone": "11:20:00+05:30"+ + } + + } +(1 row) + + create function next_minute(t timestamp) + returns timestamp language sql stable + as $$ select t + interval '1 minute'; $$; + select jsonb_pretty(graphql.resolve($$ + query { + nextMinute(t: "2023-07-28 12:39:05") + } + $$)); + jsonb_pretty +--------------------------------------------- + { + + "data": { + + "nextMinute": "2023-07-28T12:40:05"+ + } + + } +(1 row) + + create function next_minute_with_timezone(t timestamptz) + returns timestamptz language sql immutable + as $$ select t + interval '1 minute'; $$; + select jsonb_pretty(graphql.resolve($$ + query { + nextMinuteWithTimezone(t: "2023-07-28 12:39:05+05:30") + } + $$)); + jsonb_pretty +--------------------------------------------------------------- + { + + "data": { + + "nextMinuteWithTimezone": "2023-07-28T12:40:05+05:30"+ + } + + } +(1 row) + + create function get_json_obj(input json, key text) + returns json language sql stable + as $$ select input -> key; $$; + select jsonb_pretty(graphql.resolve($$ + query { + getJsonObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + jsonb_pretty +------------------------------------------ + { + + "data": { + + "getJsonObj": "{\"b\": \"foo\"}"+ + } + + } +(1 row) + + create function get_jsonb_obj(input jsonb, key text) + returns jsonb language sql immutable + as $$ select input -> key; $$; + select jsonb_pretty(graphql.resolve($$ + query { + getJsonbObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + jsonb_pretty +------------------------------------------- + { + + "data": { + + "getJsonbObj": "{\"b\": \"foo\"}"+ + } + + } +(1 row) + + create function concat_chars(a char(2), b char(3)) + returns char(5) language sql stable + as $$ select (a::char(2) || b::char(3))::char(5); $$; + select jsonb_pretty(graphql.resolve($$ + query { + concatChars(a: "He", b: "llo") + } + $$)); + jsonb_pretty +-------------------------------- + { + + "data": { + + "concatChars": "Hello"+ + } + + } +(1 row) + + create function concat_varchars(a varchar(2), b varchar(3)) + returns varchar(5) language sql immutable + as $$ select (a::varchar(2) || b::varchar(3))::varchar(5); $$; + select jsonb_pretty(graphql.resolve($$ + query { + concatVarchars(a: "He", b: "llo") + } + $$)); + jsonb_pretty +----------------------------------- + { + + "data": { + + "concatVarchars": "Hello"+ + } + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + queryType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + jsonb_pretty +------------------------------------------------------------------------ + { + + "data": { + + "__schema": { + + "queryType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "BigInt" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "BigInt" + + } + + } + + ], + + "name": "addBigints", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Float" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Float" + + } + + } + + ], + + "name": "addDoubles", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "BigFloat" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "BigFloat" + + } + + } + + ], + + "name": "addNumerics", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Float" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Float" + + } + + } + + ], + + "name": "addReals", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Int" + + } + + } + + ], + + "name": "addSmallints", + + "type": { + + "kind": "SCALAR" + + }, + + "description": "returns a + b" + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Boolean" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Boolean" + + } + + } + + ], + + "name": "andBools", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "String" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "concatChars", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "String" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "concatText", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "String" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "concatVarchars", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "input", + + "type": { + + "name": "JSON" + + } + + }, + + { + + "name": "key", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "getJsonObj", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "input", + + "type": { + + "name": "JSON" + + } + + }, + + { + + "name": "key", + + "type": { + + "name": "String" + + } + + } + + ], + + "name": "getJsonbObj", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "a", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "b", + + "type": { + + "name": "Int" + + } + + } + + ], + + "name": "intsAdd", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "d", + + "type": { + + "name": "Date" + + } + + } + + ], + + "name": "nextDay", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Time" + + } + + } + + ], + + "name": "nextHour", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Opaque" + + } + + } + + ], + + "name": "nextHourWithTimezone", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Datetime" + + } + + } + + ], + + "name": "nextMinute", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "t", + + "type": { + + "name": "Datetime" + + } + + } + + ], + + "name": "nextMinuteWithTimezone", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "nodeId", + + "type": { + + "name": null + + } + + } + + ], + + "name": "node", + + "type": { + + "kind": "INTERFACE" + + }, + + "description": "Retrieve a record by its `ID`"+ + }, + + { + + "args": [ + + { + + "name": "input", + + "type": { + + "name": "UUID" + + } + + } + + ], + + "name": "uuidIdentity", + + "type": { + + "kind": "SCALAR" + + }, + + "description": null + + } + + ] + + } + + } + + } + + } +(1 row) + + rollback to savepoint a; + create table account( + id serial primary key, + email varchar(255) not null + ); + create function returns_account() + returns account language sql stable + as $$ select id, email from account; $$; + insert into account(email) + values + ('aardvark@x.com'), + ('bat@x.com'), + ('cat@x.com'); + comment on table account is e'@graphql({"totalCount": {"enabled": true}})'; + select jsonb_pretty(graphql.resolve($$ + query { + returnsAccount { + id + email + nodeId + __typename + } + } + $$)); + jsonb_pretty +----------------------------------------------------------- + { + + "data": { + + "returnsAccount": { + + "id": 1, + + "email": "aardvark@x.com", + + "nodeId": "WyJwdWJsaWMiLCAiYWNjb3VudCIsIDFd",+ + "__typename": "Account" + + } + + } + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query { + returnsAccount { + email + nodeId + } + } + $$)); + jsonb_pretty +---------------------------------------------------------- + { + + "data": { + + "returnsAccount": { + + "email": "aardvark@x.com", + + "nodeId": "WyJwdWJsaWMiLCAiYWNjb3VudCIsIDFd"+ + } + + } + + } +(1 row) + + comment on schema public is e'@graphql({"inflect_names": false})'; + create function returns_account_with_id(id_to_search int) + returns account language sql stable + as $$ select id, email from account where id = id_to_search; $$; + select jsonb_pretty(graphql.resolve($$ + query { + returns_account_with_id(id_to_search: 1) { + email + } + } + $$)); + jsonb_pretty +--------------------------------------- + { + + "data": { + + "returns_account_with_id": { + + "email": "aardvark@x.com"+ + } + + } + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query { + returns_account_with_id(id_to_search: 42) { # search a non-existent id + id + email + nodeId + } + } + $$)); + jsonb_pretty +----------------------------------------- + { + + "data": { + + "returns_account_with_id": null+ + } + + } +(1 row) + + comment on schema public is e'@graphql({"inflect_names": true})'; + select jsonb_pretty(graphql.resolve($$ + query { + returnsAccountWithId(idToSearch: 1) { + email + } + } + $$)); + jsonb_pretty +--------------------------------------- + { + + "data": { + + "returnsAccountWithId": { + + "email": "aardvark@x.com"+ + } + + } + + } +(1 row) + + create function returns_setof_account(top int) + returns setof account language sql stable + as $$ select id, email from account limit top; $$; + select jsonb_pretty(graphql.resolve($$ + query { + returnsSetofAccount(top: 2, last: 1) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + cursor + node { + nodeId + id + email + __typename + } + __typename + } + totalCount + __typename + } + } + $$)); + jsonb_pretty +----------------------------------------------------------------------- + { + + "data": { + + "returnsSetofAccount": { + + "edges": [ + + { + + "node": { + + "id": 2, + + "email": "bat@x.com", + + "nodeId": "WyJwdWJsaWMiLCAiYWNjb3VudCIsIDJd",+ + "__typename": "Account" + + }, + + "cursor": "WzJd", + + "__typename": "AccountEdge" + + } + + ], + + "pageInfo": { + + "endCursor": "WzJd", + + "hasNextPage": false, + + "startCursor": "WzJd", + + "hasPreviousPage": true + + }, + + "__typename": "AccountConnection", + + "totalCount": 2 + + } + + } + + } +(1 row) + + -- functions with args named `first`, `last`, `before`, `after`, `filter`, or `orderBy` are not exposed + create function arg_named_first(first int) + returns setof account language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + argNamedFirst { + __typename + } + } + $$)); + jsonb_pretty +------------------------------------------------------------------------ + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"argNamedFirst\" on type Query"+ + } + + ] + + } +(1 row) + + create function arg_named_last(last int) + returns setof account language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + argNamedLast { + __typename + } + } + $$)); + jsonb_pretty +----------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"argNamedLast\" on type Query"+ + } + + ] + + } +(1 row) + + create function arg_named_before(before int) + returns setof account language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + argNamedBefore { + __typename + } + } + $$)); + jsonb_pretty +------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"argNamedBefore\" on type Query"+ + } + + ] + + } +(1 row) + + create function arg_named_after(after int) + returns setof account language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + argNamedAfter { + __typename + } + } + $$)); + jsonb_pretty +------------------------------------------------------------------------ + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"argNamedAfter\" on type Query"+ + } + + ] + + } +(1 row) + + create function arg_named_filter(filter int) + returns setof account language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + argNamedFilter { + __typename + } + } + $$)); + jsonb_pretty +------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"argNamedFilter\" on type Query"+ + } + + ] + + } +(1 row) + + create function "arg_named_orderBy"("orderBy" int) + returns setof account language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + argNamedOrderBy { + __typename + } + } + $$)); + jsonb_pretty +-------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"argNamedOrderBy\" on type Query"+ + } + + ] + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + queryType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + jsonb_pretty +--------------------------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "queryType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "first", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "last", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "before", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "after", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "filter", + + "type": { + + "name": "AccountFilter" + + } + + }, + + { + + "name": "orderBy", + + "type": { + + "name": null + + } + + } + + ], + + "name": "accountCollection", + + "type": { + + "kind": "OBJECT" + + }, + + "description": "A pagable collection of type `Account`"+ + }, + + { + + "args": [ + + { + + "name": "nodeId", + + "type": { + + "name": null + + } + + } + + ], + + "name": "node", + + "type": { + + "kind": "INTERFACE" + + }, + + "description": "Retrieve a record by its `ID`" + + }, + + { + + "args": [ + + ], + + "name": "returnsAccount", + + "type": { + + "kind": "OBJECT" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "idToSearch", + + "type": { + + "name": "Int" + + } + + } + + ], + + "name": "returnsAccountWithId", + + "type": { + + "kind": "OBJECT" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "top", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "first", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "last", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "before", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "after", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "filter", + + "type": { + + "name": "AccountFilter" + + } + + }, + + { + + "name": "orderBy", + + "type": { + + "name": null + + } + + } + + ], + + "name": "returnsSetofAccount", + + "type": { + + "kind": "OBJECT" + + }, + + "description": null + + } + + ] + + } + + } + + } + + } +(1 row) + +rollback; diff --git a/test/expected/function_calls_unsupported.out b/test/expected/function_calls_unsupported.out new file mode 100644 index 00000000..1e68dd35 --- /dev/null +++ b/test/expected/function_calls_unsupported.out @@ -0,0 +1,418 @@ +begin; + -- functions in this file are not supported yet + create table account( + id serial primary key, + email varchar(255) not null + ); + insert into public.account(email) + values + ('aardvark@x.com'), + ('bat@x.com'), + ('cat@x.com'); + -- functions which return a record + create function returns_record() + returns record language sql stable + as $$ select id, email from account; $$; + select jsonb_pretty(graphql.resolve($$ + query { + returnsRecord { + id + email + nodeId + __typename + } + } + $$)); + jsonb_pretty +------------------------------------------------------------------------ + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"returnsRecord\" on type Query"+ + } + + ] + + } +(1 row) + + -- functions which accept a table tuple type + create function accepts_table_tuple_type(rec public.account) + returns int + immutable + language sql + as $$ + select 1; + $$; + select jsonb_pretty(graphql.resolve($$ + query { + acceptsTableTupleType + } + $$)); + jsonb_pretty +-------------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"acceptsTableTupleType\" on type Query"+ + } + + ] + + } +(1 row) + + -- overloaded functions + create function an_overloaded_function() + returns int language sql stable + as $$ select 1; $$; + create function an_overloaded_function(a int) + returns int language sql stable + as $$ select 2; $$; + create function an_overloaded_function(a text) + returns int language sql stable + as $$ select 2; $$; + select jsonb_pretty(graphql.resolve($$ + query { + anOverloadedFunction + } + $$)); + jsonb_pretty +------------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"anOverloadedFunction\" on type Query"+ + } + + ] + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query { + anOverloadedFunction (a: 1) + } + $$)); + jsonb_pretty +------------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"anOverloadedFunction\" on type Query"+ + } + + ] + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query { + anOverloadedFunction (a: "some text") + } + $$)); + jsonb_pretty +------------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"anOverloadedFunction\" on type Query"+ + } + + ] + + } +(1 row) + + -- functions without arg names + create function no_arg_name(int) + returns int language sql immutable + as $$ select 42; $$; + select jsonb_pretty(graphql.resolve($$ + query { + noArgName + } + $$)); + jsonb_pretty +-------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"noArgName\" on type Query"+ + } + + ] + + } +(1 row) + + -- variadic functions + create function variadic_func(variadic int[]) + returns int language sql immutable + as $$ select 42; $$; + select jsonb_pretty(graphql.resolve($$ + query { + variadicFunc + } + $$)); + jsonb_pretty +----------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"variadicFunc\" on type Query"+ + } + + ] + + } +(1 row) + + -- functions returning void + create function void_returning_func(variadic int[]) + returns void language sql immutable + as $$ $$; + select jsonb_pretty(graphql.resolve($$ + query { + voidReturningFunc + } + $$)); + jsonb_pretty +---------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"voidReturningFunc\" on type Query"+ + } + + ] + + } +(1 row) + + -- functions with a default value + create function func_with_a_default_int(a int default 42) + returns int language sql immutable + as $$ select a; $$; + select jsonb_pretty(graphql.resolve($$ + query { + funcWithADefaultInt + } + $$)); + jsonb_pretty +------------------------------------------------------------------------------ + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"funcWithADefaultInt\" on type Query"+ + } + + ] + + } +(1 row) + + create function func_with_a_default_null_text(a text default null) + returns text language sql immutable + as $$ select a; $$; + select jsonb_pretty(graphql.resolve($$ + query { + funcWithADefaultNullText + } + $$)); + jsonb_pretty +----------------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"funcWithADefaultNullText\" on type Query"+ + } + + ] + + } +(1 row) + + create function func_accepting_array(a int[]) + returns int language sql immutable + as $$ select 0; $$; + select jsonb_pretty(graphql.resolve($$ + query { + funcAcceptingArray(a: [1, 2, 3]) + } + $$)); + jsonb_pretty +----------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"funcAcceptingArray\" on type Query"+ + } + + ] + + } +(1 row) + + create function func_returning_array() + returns int[] language sql immutable + as $$ select array[1, 2, 3]; $$; + select jsonb_pretty(graphql.resolve($$ + query { + funcReturningArray + } + $$)); + jsonb_pretty +----------------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"funcReturningArray\" on type Query"+ + } + + ] + + } +(1 row) + + -- function returning type not on search path + create schema dev; + create table dev.book( + id int primary key + ); + insert into dev.book values (1); + create function "returnsBook"() + returns dev.book + stable + language sql + as $$ + select db from dev.book db limit 1; + $$; + select jsonb_pretty(graphql.resolve($$ + query { + returnsBook + } + $$)); + jsonb_pretty +---------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"returnsBook\" on type Query"+ + } + + ] + + } +(1 row) + + -- function accepting type not on search path + create type dev.invisible as enum ('ONLY'); + create function "badInputArg"(val dev.invisible) + returns int + stable + language sql + as $$ + select 1; + $$; + select jsonb_pretty(graphql.resolve($$ + query { + badInputArg + } + $$)); + jsonb_pretty +---------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"badInputArg\" on type Query"+ + } + + ] + + } +(1 row) + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + queryType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + jsonb_pretty +--------------------------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "queryType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "first", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "last", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "before", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "after", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "filter", + + "type": { + + "name": "AccountFilter" + + } + + }, + + { + + "name": "orderBy", + + "type": { + + "name": null + + } + + } + + ], + + "name": "accountCollection", + + "type": { + + "kind": "OBJECT" + + }, + + "description": "A pagable collection of type `Account`"+ + }, + + { + + "args": [ + + { + + "name": "nodeId", + + "type": { + + "name": null + + } + + } + + ], + + "name": "node", + + "type": { + + "kind": "INTERFACE" + + }, + + "description": "Retrieve a record by its `ID`" + + } + + ] + + } + + } + + } + + } +(1 row) + +rollback; diff --git a/test/expected/permissions_functions.out b/test/expected/permissions_functions.out new file mode 100644 index 00000000..1e9fbc2a --- /dev/null +++ b/test/expected/permissions_functions.out @@ -0,0 +1,139 @@ +begin; + -- Create a new non-superuser role to manipulate permissions + create role api; + grant usage on schema graphql to api; + -- Create minimal Query function + create function public.get_one() + returns int + language sql + immutable + as + $$ + select 1; + $$; + savepoint a; + -- Use api role + set role api; + -- Confirm that getOne is visible to the api role + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Query") { + fields { + name + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "getOne"+ + }, + + { + + "name": "node" + + } + + ] + + } + + } + + } +(1 row) + + -- Execute + select jsonb_pretty( + graphql.resolve($$ + { getOne } + $$) + ); + jsonb_pretty +--------------------- + { + + "data": { + + "getOne": 1+ + } + + } +(1 row) + + -- revert to superuser + rollback to savepoint a; + select current_user; + current_user +-------------- + postgres +(1 row) + + -- revoke default access from the public role for new functions + -- this is not actually necessary for this test, but including here + -- as a best practice in case this test is used as a reference + alter default privileges revoke execute on functions from public; + -- explicitly revoke execute from api role + revoke execute on function public.get_one from api; + -- api inherits from the public role, so we need to revoke from public too + revoke execute on function public.get_one from public; + -- Use api role w/o execute permission on get_one + set role api; + -- confirm we're using the api role + select current_user; + current_user +-------------- + api +(1 row) + + -- confirm that api can not execute get_one() + select pg_catalog.has_function_privilege('api', 'get_one()', 'execute'); + has_function_privilege +------------------------ + f +(1 row) + + -- Confirm getOne is not visible in the Query type + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Query") { + fields { + name + } + } + } + $$) + ); + jsonb_pretty +------------------------------------ + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "node"+ + } + + ] + + } + + } + + } +(1 row) + + -- Confirm getOne can not be executed / is not found during resolution + select jsonb_pretty( + graphql.resolve($$ + { getOne } + $$) + ); + jsonb_pretty +----------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"getOne\" on type Query"+ + } + + ] + + } +(1 row) + +rollback; diff --git a/test/sql/function_calls.sql b/test/sql/function_calls.sql new file mode 100644 index 00000000..8f3e19c6 --- /dev/null +++ b/test/sql/function_calls.sql @@ -0,0 +1,625 @@ +begin; + + savepoint a; + -- Only volatilve functions appear on the mutation object + + create function add_smallints(a smallint, b smallint) + returns smallint language sql volatile + as $$ select a + b; $$; + + comment on function add_smallints is e'@graphql({"description": "adds two smallints"})'; + + select jsonb_pretty(graphql.resolve($$ + mutation { + addSmallints(a: 1, b: 2) + } + $$)); + + create function add_ints(a int, b int) + returns int language sql volatile + as $$ select a + b; $$; + + comment on function add_ints is e'@graphql({"name": "intsAdd"})'; + + select jsonb_pretty(graphql.resolve($$ + mutation { + intsAdd(a: 2, b: 3) + } + $$)); + + comment on schema public is e'@graphql({"inflect_names": false})'; + + create function add_bigints(a bigint, b bigint) + returns bigint language sql volatile + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + add_bigints(a: 3, b: 4) + } + $$)); + + comment on schema public is e'@graphql({"inflect_names": true})'; + + create function add_reals(a real, b real) + returns real language sql volatile + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + addReals(a: 4.5, b: 5.6) + } + $$)); + + create function add_doubles(a double precision, b double precision) + returns double precision language sql volatile + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + addDoubles(a: 7.8, b: 9.1) + } + $$)); + + create function add_numerics(a numeric, b numeric) + returns numeric language sql volatile + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + addNumerics(a: "11.12", b: "13.14") + } + $$)); + + create function and_bools(a bool, b bool) + returns bool language sql volatile + as $$ select a and b; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + andBools(a: true, b: false) + } + $$)); + + create function uuid_identity(input uuid) + returns uuid language sql volatile + as $$ select input; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + uuidIdentity(input: "d3ef3a8c-2c72-11ee-b094-776acede7221") + } + $$)); + + create function concat_text(a text, b text) + returns text language sql volatile + as $$ select a || b; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + concatText(a: "Hello ", b: "World") + } + $$)); + + create function next_day(d date) + returns date language sql volatile + as $$ select d + interval '1 day'; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + nextDay(d: "2023-07-28") + } + $$)); + + create function next_hour(t time) + returns time language sql volatile + as $$ select t + interval '1 hour'; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + nextHour(t: "10:20") + } + $$)); + + set time zone 'Asia/Kolkata'; -- same as IST + + create function next_hour_with_timezone(t time with time zone) + returns time with time zone language sql volatile + as $$ select t + interval '1 hour'; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + nextHourWithTimezone(t: "10:20+05:30") + } + $$)); + + create function next_minute(t timestamp) + returns timestamp language sql volatile + as $$ select t + interval '1 minute'; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + nextMinute(t: "2023-07-28 12:39:05") + } + $$)); + + create function next_minute_with_timezone(t timestamptz) + returns timestamptz language sql volatile + as $$ select t + interval '1 minute'; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + nextMinuteWithTimezone(t: "2023-07-28 12:39:05+05:30") + } + $$)); + + create function get_json_obj(input json, key text) + returns json language sql volatile + as $$ select input -> key; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + getJsonObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + + create function get_jsonb_obj(input jsonb, key text) + returns jsonb language sql volatile + as $$ select input -> key; $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + getJsonbObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + + create function concat_chars(a char(2), b char(3)) + returns char(5) language sql volatile + as $$ select (a::char(2) || b::char(3))::char(5); $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + concatChars(a: "He", b: "llo") + } + $$)); + + create function concat_varchars(a varchar(2), b varchar(3)) + returns varchar(5) language sql volatile + as $$ select (a::varchar(2) || b::varchar(3))::varchar(5); $$; + + select jsonb_pretty(graphql.resolve($$ + mutation { + concatVarchars(a: "He", b: "llo") + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + mutationType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + + rollback to savepoint a; + + -- Only stable and immutable functions appear on the query object + + create function add_smallints(a smallint, b smallint) + returns smallint language sql stable + as $$ select a + b; $$; + + comment on function add_smallints is e'@graphql({"description": "returns a + b"})'; + + select jsonb_pretty(graphql.resolve($$ + query { + addSmallints(a: 1, b: 2) + } + $$)); + + create function add_ints(a int, b int) + returns int language sql immutable + as $$ select a + b; $$; + + comment on function add_ints is e'@graphql({"name": "intsAdd"})'; + + select jsonb_pretty(graphql.resolve($$ + query { + intsAdd(a: 2, b: 3) + } + $$)); + + create function add_bigints(a bigint, b bigint) + returns bigint language sql stable + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + addBigints(a: 3, b: 4) + } + $$)); + + create function add_reals(a real, b real) + returns real language sql immutable + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + addReals(a: 4.5, b: 5.6) + } + $$)); + + create function add_doubles(a double precision, b double precision) + returns double precision language sql stable + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + addDoubles(a: 7.8, b: 9.1) + } + $$)); + + create function add_numerics(a numeric, b numeric) + returns numeric language sql immutable + as $$ select a + b; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + addNumerics(a: "11.12", b: "13.14") + } + $$)); + + create function and_bools(a bool, b bool) + returns bool language sql stable + as $$ select a and b; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + andBools(a: true, b: false) + } + $$)); + + create function uuid_identity(input uuid) + returns uuid language sql immutable + as $$ select input; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + uuidIdentity(input: "d3ef3a8c-2c72-11ee-b094-776acede7221") + } + $$)); + + create function concat_text(a text, b text) + returns text language sql stable + as $$ select a || b; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + concatText(a: "Hello ", b: "World") + } + $$)); + + create function next_day(d date) + returns date language sql immutable + as $$ select d + interval '1 day'; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + nextDay(d: "2023-07-28") + } + $$)); + + create function next_hour(t time) + returns time language sql stable + as $$ select t + interval '1 hour'; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + nextHour(t: "10:20") + } + $$)); + + set time zone 'Asia/Kolkata'; -- same as IST + + create function next_hour_with_timezone(t time with time zone) + returns time with time zone language sql immutable + as $$ select t + interval '1 hour'; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + nextHourWithTimezone(t: "10:20+05:30") + } + $$)); + + create function next_minute(t timestamp) + returns timestamp language sql stable + as $$ select t + interval '1 minute'; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + nextMinute(t: "2023-07-28 12:39:05") + } + $$)); + + create function next_minute_with_timezone(t timestamptz) + returns timestamptz language sql immutable + as $$ select t + interval '1 minute'; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + nextMinuteWithTimezone(t: "2023-07-28 12:39:05+05:30") + } + $$)); + + create function get_json_obj(input json, key text) + returns json language sql stable + as $$ select input -> key; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + getJsonObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + + create function get_jsonb_obj(input jsonb, key text) + returns jsonb language sql immutable + as $$ select input -> key; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + getJsonbObj(input: "{\"a\": {\"b\": \"foo\"}}", key: "a") + } + $$)); + + create function concat_chars(a char(2), b char(3)) + returns char(5) language sql stable + as $$ select (a::char(2) || b::char(3))::char(5); $$; + + select jsonb_pretty(graphql.resolve($$ + query { + concatChars(a: "He", b: "llo") + } + $$)); + + create function concat_varchars(a varchar(2), b varchar(3)) + returns varchar(5) language sql immutable + as $$ select (a::varchar(2) || b::varchar(3))::varchar(5); $$; + + select jsonb_pretty(graphql.resolve($$ + query { + concatVarchars(a: "He", b: "llo") + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + queryType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + + + rollback to savepoint a; + + create table account( + id serial primary key, + email varchar(255) not null + ); + + create function returns_account() + returns account language sql stable + as $$ select id, email from account; $$; + + insert into account(email) + values + ('aardvark@x.com'), + ('bat@x.com'), + ('cat@x.com'); + + comment on table account is e'@graphql({"totalCount": {"enabled": true}})'; + + select jsonb_pretty(graphql.resolve($$ + query { + returnsAccount { + id + email + nodeId + __typename + } + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query { + returnsAccount { + email + nodeId + } + } + $$)); + + comment on schema public is e'@graphql({"inflect_names": false})'; + + create function returns_account_with_id(id_to_search int) + returns account language sql stable + as $$ select id, email from account where id = id_to_search; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + returns_account_with_id(id_to_search: 1) { + email + } + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query { + returns_account_with_id(id_to_search: 42) { # search a non-existent id + id + email + nodeId + } + } + $$)); + + comment on schema public is e'@graphql({"inflect_names": true})'; + + select jsonb_pretty(graphql.resolve($$ + query { + returnsAccountWithId(idToSearch: 1) { + email + } + } + $$)); + + create function returns_setof_account(top int) + returns setof account language sql stable + as $$ select id, email from account limit top; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + returnsSetofAccount(top: 2, last: 1) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + cursor + node { + nodeId + id + email + __typename + } + __typename + } + totalCount + __typename + } + } + $$)); + + -- functions with args named `first`, `last`, `before`, `after`, `filter`, or `orderBy` are not exposed + create function arg_named_first(first int) + returns setof account language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + argNamedFirst { + __typename + } + } + $$)); + + create function arg_named_last(last int) + returns setof account language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + argNamedLast { + __typename + } + } + $$)); + + create function arg_named_before(before int) + returns setof account language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + argNamedBefore { + __typename + } + } + $$)); + + create function arg_named_after(after int) + returns setof account language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + argNamedAfter { + __typename + } + } + $$)); + + create function arg_named_filter(filter int) + returns setof account language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + argNamedFilter { + __typename + } + } + $$)); + + create function "arg_named_orderBy"("orderBy" int) + returns setof account language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + argNamedOrderBy { + __typename + } + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + queryType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); + +rollback; diff --git a/test/sql/function_calls_unsupported.sql b/test/sql/function_calls_unsupported.sql new file mode 100644 index 00000000..51a619f7 --- /dev/null +++ b/test/sql/function_calls_unsupported.sql @@ -0,0 +1,209 @@ +begin; + -- functions in this file are not supported yet + + create table account( + id serial primary key, + email varchar(255) not null + ); + + insert into public.account(email) + values + ('aardvark@x.com'), + ('bat@x.com'), + ('cat@x.com'); + + -- functions which return a record + create function returns_record() + returns record language sql stable + as $$ select id, email from account; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + returnsRecord { + id + email + nodeId + __typename + } + } + $$)); + + -- functions which accept a table tuple type + create function accepts_table_tuple_type(rec public.account) + returns int + immutable + language sql + as $$ + select 1; + $$; + + select jsonb_pretty(graphql.resolve($$ + query { + acceptsTableTupleType + } + $$)); + + -- overloaded functions + create function an_overloaded_function() + returns int language sql stable + as $$ select 1; $$; + + create function an_overloaded_function(a int) + returns int language sql stable + as $$ select 2; $$; + + create function an_overloaded_function(a text) + returns int language sql stable + as $$ select 2; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + anOverloadedFunction + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query { + anOverloadedFunction (a: 1) + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query { + anOverloadedFunction (a: "some text") + } + $$)); + + -- functions without arg names + create function no_arg_name(int) + returns int language sql immutable + as $$ select 42; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + noArgName + } + $$)); + + -- variadic functions + create function variadic_func(variadic int[]) + returns int language sql immutable + as $$ select 42; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + variadicFunc + } + $$)); + + -- functions returning void + create function void_returning_func(variadic int[]) + returns void language sql immutable + as $$ $$; + + select jsonb_pretty(graphql.resolve($$ + query { + voidReturningFunc + } + $$)); + + -- functions with a default value + create function func_with_a_default_int(a int default 42) + returns int language sql immutable + as $$ select a; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + funcWithADefaultInt + } + $$)); + + create function func_with_a_default_null_text(a text default null) + returns text language sql immutable + as $$ select a; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + funcWithADefaultNullText + } + $$)); + + create function func_accepting_array(a int[]) + returns int language sql immutable + as $$ select 0; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + funcAcceptingArray(a: [1, 2, 3]) + } + $$)); + + create function func_returning_array() + returns int[] language sql immutable + as $$ select array[1, 2, 3]; $$; + + select jsonb_pretty(graphql.resolve($$ + query { + funcReturningArray + } + $$)); + + -- function returning type not on search path + create schema dev; + create table dev.book( + id int primary key + ); + insert into dev.book values (1); + + create function "returnsBook"() + returns dev.book + stable + language sql + as $$ + select db from dev.book db limit 1; + $$; + + select jsonb_pretty(graphql.resolve($$ + query { + returnsBook + } + $$)); + + -- function accepting type not on search path + create type dev.invisible as enum ('ONLY'); + + create function "badInputArg"(val dev.invisible) + returns int + stable + language sql + as $$ + select 1; + $$; + + select jsonb_pretty(graphql.resolve($$ + query { + badInputArg + } + $$)); + + select jsonb_pretty(graphql.resolve($$ + query IntrospectionQuery { + __schema { + queryType { + fields { + name + description + type { + kind + } + args { + name + type { + name + } + } + } + } + } + } $$)); +rollback; diff --git a/test/sql/permissions_functions.sql b/test/sql/permissions_functions.sql new file mode 100644 index 00000000..27bde6a7 --- /dev/null +++ b/test/sql/permissions_functions.sql @@ -0,0 +1,84 @@ +begin; + + -- Create a new non-superuser role to manipulate permissions + create role api; + grant usage on schema graphql to api; + + -- Create minimal Query function + create function public.get_one() + returns int + language sql + immutable + as + $$ + select 1; + $$; + + savepoint a; + + -- Use api role + set role api; + + -- Confirm that getOne is visible to the api role + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Query") { + fields { + name + } + } + } + $$) + ); + + -- Execute + select jsonb_pretty( + graphql.resolve($$ + { getOne } + $$) + ); + + -- revert to superuser + rollback to savepoint a; + select current_user; + + -- revoke default access from the public role for new functions + -- this is not actually necessary for this test, but including here + -- as a best practice in case this test is used as a reference + alter default privileges revoke execute on functions from public; + -- explicitly revoke execute from api role + revoke execute on function public.get_one from api; + -- api inherits from the public role, so we need to revoke from public too + revoke execute on function public.get_one from public; + + -- Use api role w/o execute permission on get_one + set role api; + + -- confirm we're using the api role + select current_user; + + -- confirm that api can not execute get_one() + select pg_catalog.has_function_privilege('api', 'get_one()', 'execute'); + + -- Confirm getOne is not visible in the Query type + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Query") { + fields { + name + } + } + } + $$) + ); + + -- Confirm getOne can not be executed / is not found during resolution + select jsonb_pretty( + graphql.resolve($$ + { getOne } + $$) + ); + +rollback;