diff --git a/docs/changelog.md b/docs/changelog.md index 638eee89..0e8f908b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,3 +24,4 @@ ## master - feature: `String` type filters support `regex`, `iregex` +- feature: computed relationships via functions returning setof diff --git a/docs/computed_fields.md b/docs/computed_fields.md index f3ccbae7..98e24220 100644 --- a/docs/computed_fields.md +++ b/docs/computed_fields.md @@ -1,4 +1,6 @@ -## PostgreSQL Builtin (Preferred) +## Computed Values + +### PostgreSQL Builtin (Preferred) PostgreSQL has a builtin method for adding [generated columns](https://www.postgresql.org/docs/14/ddl-generated-columns.html) to tables. Generated columns are reflected identically to non-generated columns. This is the recommended approach to adding computed fields when your computation meets the restrictions. Namely: @@ -11,13 +13,243 @@ For example: ``` -## Extending Types with Functions +### Extending Types with Functions For arbitrary computations that do not meet the requirements for [generated columns](https://www.postgresql.org/docs/14/ddl-generated-columns.html), a table's reflected GraphQL type can be extended by creating a function that: -- accepts a single parameter of the table's tuple type -- has a name starting with an underscore +- accepts a single argument of the table's tuple type ```sql --8<-- "test/expected/extend_type_with_function.out" ``` + + +## Computed Relationships + +Computed relations can be helpful to express relationships: + +- between entities that don't support foreign keys +- too complex to be expressed via a foreign key + +If the relationship is simple, but involves an entity that does not support foreign keys e.g. Foreign Data Wrappers / Views, defining a comment directive is the easiest solution. See the [view doc](/pg_graphql/views) for a complete example. Note that for entities that do not support a primary key, like views, you must define one using a [comment directive](/pg_graphql/configuration/#comment-directives) to use them in a computed relationship. + +Alternatively, if the relationship is complex, or you need compatibility with PostgREST, you can define a relationship using set returning functions. + + +### To-One + +To One relationships can be defined using a function that returns `setof rows 1` + +For example +```sql +create table "Person" ( + id int primary key, + name text +); + +create table "Address"( + id int primary key, + "isPrimary" bool not null default false, + "personId" int references "Person"(id), + address text +); + +-- Example computed relation +create function "primaryAddress"("Person") + returns setof "Address" rows 1 + language sql + as +$$ + select addr + from "Address" addr + where $1.id = addr."personId" + and addr."isPrimary" + limit 1 +$$; + +insert into "Person"(id, name) +values (1, 'Foo Barington'); + +insert into "Address"(id, "isPrimary", "personId", address) +values (4, true, 1, '1 Main St.'); +``` + +results in the GraphQL type + +=== "Person" + ```graphql + type Person implements Node { + """Globally Unique Record Identifier""" + nodeId: ID! + ... + primaryAddress: Address + } + ``` + +and can be queried like a natively enforced relationship + +=== "Query" + + ```graphql + { + personCollection { + edges { + node { + id + name + primaryAddress { + address + } + } + } + + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "personCollection": { + "edges": [ + { + "node": { + "id": 1, + "name": "Foo Barington", + "primaryAddress": { + "address": "1 Main St." + } + } + } + ] + } + } + } + ``` + + + +### To-Many + +To-many relationships can be defined using a function that returns a `setof ` + + +For example: +```sql +create table "Person" ( + id int primary key, + name text +); + +create table "Address"( + id int primary key, + address text +); + +create table "PersonAtAddress"( + id int primary key, + "personId" int not null, + "addressId" int not null +); + + +-- Computed relation to bypass "PersonAtAddress" table for cleaner API +create function "addresses"("Person") + returns setof "Address" + language sql + as +$$ + select + addr + from + "PersonAtAddress" pa + join "Address" addr + on pa."addressId" = "addr".id + where + pa."personId" = $1.id +$$; + +insert into "Person"(id, name) +values (1, 'Foo Barington'); + +insert into "Address"(id, address) +values (4, '1 Main St.'); + +insert into "PersonAtAddress"(id, "personId", "addressId") +values (2, 1, 4); +``` + +results in the GraphQL type + +=== "Person" + ```graphql + type Person implements Node { + """Globally Unique Record Identifier""" + nodeId: ID! + ... + addresses( + first: Int + last: Int + before: Cursor + after: Cursor + filter: AddressFilter + orderBy: [AddressOrderBy!] + ): AddressConnection + } + ``` + +and can be queried like a natively enforced relationship + +=== "Query" + + ```graphql + { + personCollection { + edges { + node { + id + name + addresses { + edges { + node { + id + address + } + } + } + } + } + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "personCollection": { + "edges": [ + { + "node": { + "id": 1, + "name": "Foo Barington", + "addresses": { + "edges": [ + { + "node": { + "id": 4, + "address": "1 Main St." + } + } + ] + } + } + } + ] + } + } + } + ``` diff --git a/sql/load_sql_context.sql b/sql/load_sql_context.sql index 02e2861e..86fbe41e 100644 --- a/sql/load_sql_context.sql +++ b/sql/load_sql_context.sql @@ -53,8 +53,6 @@ select pg_enum pe join pg_type pt on pt.oid = pe.enumtypid - join schemas_ spo - on pt.typnamespace = spo.oid group by pt.oid ) @@ -74,14 +72,18 @@ select 'oid', pt.oid::int, 'schema_oid', pt.typnamespace::int, 'name', pt.typname, - -- if type is an array, points at the underlying element type 'category', case when pt.typcategory = 'A' then 'Array' when pt.typcategory = 'E' then 'Enum' - when pt.typcategory = 'C' then 'Composite' + when pt.typcategory = 'C' + and tabs.relkind in ('r', 't', 'v', 'm', 'f', 'p') then 'Table' + when pt.typcategory = 'C' and tabs.relkind = 'c' then 'Composite' else 'Other' end, + -- if category is 'Array', points at the underlying element type 'array_element_type_oid', nullif(pt.typelem::int, 0), + -- if category is 'Table' points to the table oid + 'table_oid', tabs.oid::int, 'comment', pg_catalog.obj_description(pt.oid, 'pg_type'), 'directives', jsonb_build_object( 'name', graphql.comment_directive(pg_catalog.obj_description(pt.oid, 'pg_type')) ->> 'name' @@ -93,8 +95,8 @@ select ) from pg_type pt - join schemas_ spo - on pt.typnamespace = spo.oid + left join pg_class tabs + on pt.typrelid = tabs.oid ), jsonb_build_object() ), @@ -109,10 +111,12 @@ select ) from pg_type pt - join schemas_ spo - on pt.typnamespace = spo.oid + join pg_class tabs + on pt.typrelid = tabs.oid where - pt.typtype = 'c' + pt.typcategory = 'C' + and tabs.relkind = 'c' + ), jsonb_build_array() ), @@ -244,6 +248,12 @@ select '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 ( @@ -271,8 +281,6 @@ select where pp.pronargs = 1 -- one argument and pp.proargtypes[0] = pc.reltype -- first argument is table type - and pp.proname like '\_%' -- starts with underscore - and not pp.proretset -- disallow set returning functions (for now) ), jsonb_build_array() ), diff --git a/src/builder.rs b/src/builder.rs index 1e6769dd..82f9ef7a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -546,6 +546,16 @@ where } } + + + +#[derive(Clone, Debug)] +pub struct ConnectionBuilderSource { + pub table: Arc, + pub fkey: Option +} + + #[derive(Clone, Debug)] pub struct ConnectionBuilder { pub alias: String, @@ -559,9 +569,7 @@ pub struct ConnectionBuilder { pub order_by: OrderByBuilder, // metadata - pub table: Arc
, - pub fkey: Option>, - pub reverse_reference: Option, + pub source: ConnectionBuilderSource, //fields pub selections: Vec, @@ -794,6 +802,14 @@ pub struct FunctionBuilder { pub alias: String, pub function: Arc, pub table: Arc
, + pub selection: FunctionSelection, +} + +#[derive(Clone, Debug)] +pub enum FunctionSelection { + ScalarSelf, + Connection(ConnectionBuilder), + Node(NodeBuilder), } fn restrict_allowed_arguments<'a, T>( @@ -1120,9 +1136,10 @@ where } Ok(ConnectionBuilder { alias, - table: Arc::clone(&xtype.table), - fkey: xtype.fkey.clone(), - reverse_reference: xtype.reverse_reference, + source: ConnectionBuilderSource { + table: Arc::clone(&xtype.table), + fkey: xtype.fkey.clone() + }, first, last, before, @@ -1352,67 +1369,98 @@ where )) } Some(f) => { - match f.type_().unmodified_type() { - __Type::Connection(_) => { - let con_builder = to_connection_builder( - f, - selection_field, - fragment_definitions, - variables, - // TODO need ref to fkey here - ); - builder_fields.push(NodeSelection::Connection(con_builder?)); - } - __Type::Node(_) => { - let node_builder = to_node_builder( - f, - selection_field, - fragment_definitions, - variables, - // TODO need ref to fkey here - ); - builder_fields.push(NodeSelection::Node(node_builder?)); - } - _ => { - let alias = alias_or_name(selection_field); - let node_selection = match &f.sql_type { - Some(node_sql_type) => match node_sql_type { - NodeSQLType::Column(col) => NodeSelection::Column(ColumnBuilder { - alias, - column: Arc::clone(col), - }), - NodeSQLType::Function(func) => { - NodeSelection::Function(FunctionBuilder { - alias, - function: Arc::clone(func), - table: Arc::clone(&xtype.table), - }) + let alias = alias_or_name(selection_field); + + let node_selection = match &f.sql_type { + Some(node_sql_type) => match node_sql_type { + NodeSQLType::Column(col) => NodeSelection::Column(ColumnBuilder { + alias, + column: Arc::clone(col), + }), + NodeSQLType::Function(func) => { + let function_selection = match &f.type_() { + __Type::Scalar(_) => FunctionSelection::ScalarSelf, + __Type::Node(_) => { + let node_builder = to_node_builder( + f, + selection_field, + fragment_definitions, + variables, + // TODO need ref to fkey here + )?; + FunctionSelection::Node(node_builder) + } + __Type::Connection(_) => { + let connection_builder = to_connection_builder( + f, + selection_field, + fragment_definitions, + variables, + // TODO need ref to fkey here + )?; + FunctionSelection::Connection(connection_builder) } - NodeSQLType::NodeId(pkey_columns) => { - NodeSelection::NodeId(NodeIdBuilder { - alias, - columns: pkey_columns.clone(), // interior is arc - table_name: xtype.table.name.clone(), - schema_name: xtype.table.schema.clone(), - }) + _ => { + return Err(format!( + "invalid return type from function" + )) + } + }; + NodeSelection::Function(FunctionBuilder { + alias, + function: Arc::clone(func), + table: Arc::clone(&xtype.table), + selection: function_selection, + }) + } + NodeSQLType::NodeId(pkey_columns) => { + NodeSelection::NodeId(NodeIdBuilder { + alias, + columns: pkey_columns.clone(), // interior is arc + table_name: xtype.table.name.clone(), + schema_name: xtype.table.schema.clone(), + }) + } + }, + _ => match f.name().as_ref() { + "__typename" => NodeSelection::Typename { + alias: alias_or_name(selection_field), + typename: xtype.name().unwrap(), + }, + _ => { + match f.type_().unmodified_type() { + __Type::Connection(_) => { + let con_builder = to_connection_builder( + f, + selection_field, + fragment_definitions, + variables, + ); + NodeSelection::Connection(con_builder?) + } + __Type::Node(_) => { + let node_builder = to_node_builder( + f, + selection_field, + fragment_definitions, + variables, + ); + NodeSelection::Node(node_builder?) } - }, - _ => match f.name().as_ref() { - "__typename" => NodeSelection::Typename { - alias: alias_or_name(selection_field), - typename: xtype.name().unwrap(), - }, _ => { return Err(format!( "unexpected field type on node {}", f.name() - )) + )); } - }, - }; - builder_fields.push(node_selection); - } - } + } + + } + }, + }; + builder_fields.push(node_selection); + + } } } diff --git a/src/graphql.rs b/src/graphql.rs index 5b5ea189..8533a158 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -825,23 +825,6 @@ pub struct __DirectiveLocationType; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct __DirectiveType; -/* - * TODO(or): - * - Revert schema from Arc to Rc - * - Update the __Type enum to be - * __Type(Rc, SomeInnerType> - * for all implementations - * SCRATCH THAT: Arc is needed so __Schema can be cached. - * - * - Update __Type::field() to call into a cached function - * - * - Add a pub fn cache_key(&self) to __Type - * so that it can be reused for all field_maps - * - * fn field_map(type_: __Type). - * since the schema will be availble, at __ - */ - #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct ListType { pub type_: Box<__Type>, @@ -897,18 +880,87 @@ pub struct DeleteResponseType { pub schema: Arc<__Schema>, } +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct ForeignKeyReversible { + pub fkey: Arc, + pub reverse_reference: bool, +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct ConnectionType { pub table: Arc
, - - // If one is present, both should be present - // could be improved - pub fkey: Option>, - pub reverse_reference: Option, + pub fkey: Option, pub schema: Arc<__Schema>, } +impl ConnectionType { + // default arguments for all connections + fn get_connection_input_args(&self) -> Vec<__InputValue> { + vec![ + __InputValue { + name_: "first".to_string(), + type_: __Type::Scalar(Scalar::Int), + description: Some("Query the first `n` records in the collection".to_string()), + default_value: None, + sql_type: None, + }, + __InputValue { + name_: "last".to_string(), + type_: __Type::Scalar(Scalar::Int), + description: Some("Query the last `n` records in the collection".to_string()), + default_value: None, + sql_type: None, + }, + __InputValue { + name_: "before".to_string(), + type_: __Type::Scalar(Scalar::Cursor), + description: Some( + "Query values in the collection before the provided cursor".to_string(), + ), + default_value: None, + sql_type: None, + }, + __InputValue { + name_: "after".to_string(), + type_: __Type::Scalar(Scalar::Cursor), + description: Some( + "Query values in the collection after the provided cursor".to_string(), + ), + default_value: None, + sql_type: None, + }, + __InputValue { + name_: "filter".to_string(), + type_: __Type::FilterEntity(FilterEntityType { + table: Arc::clone(&self.table), + schema: self.schema.clone(), + }), + description: Some( + "Filters to apply to the results set when querying from the collection" + .to_string(), + ), + default_value: None, + sql_type: None, + }, + __InputValue { + name_: "orderBy".to_string(), + type_: __Type::List(ListType { + type_: Box::new(__Type::NonNull(NonNullType { + type_: Box::new(__Type::OrderByEntity(OrderByEntityType { + table: Arc::clone(&self.table), + schema: self.schema.clone(), + })), + })), + }), + description: Some("Sort order to apply to the collection".to_string()), + default_value: None, + sql_type: None, + }, + ] + } +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub enum EnumSource { Enum(Arc), @@ -1020,73 +1072,18 @@ impl ___Type for QueryType { { let table_base_type_name = &self.schema.graphql_table_base_type_name(&table); + let connection_type = ConnectionType { + table: Arc::clone(table), + fkey: None, + schema: Arc::clone(&self.schema), + }; + + let connection_args = connection_type.get_connection_input_args(); + let collection_entrypoint = __Field { - name_: format!( - "{}Collection", - lowercase_first_letter(table_base_type_name) - ), - type_: __Type::Connection(ConnectionType { - table: Arc::clone(table), - fkey: None, - reverse_reference: None, - schema: Arc::clone(&self.schema), - }), - args: vec![ - __InputValue { - name_: "first".to_string(), - type_: __Type::Scalar(Scalar::Int), - description: Some("Query the first `n` records in the collection".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "last".to_string(), - type_: __Type::Scalar(Scalar::Int), - description: Some("Query the last `n` records in the collection".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "before".to_string(), - type_: __Type::Scalar(Scalar::Cursor), - description: Some("Query values in the collection before the provided cursor".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "after".to_string(), - type_: __Type::Scalar(Scalar::Cursor), - description: Some("Query values in the collection after the provided cursor".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "filter".to_string(), - type_: __Type::FilterEntity(FilterEntityType { - table: Arc::clone(table), - schema: self.schema.clone(), - }), - description: Some("Filters to apply to the results set when querying from the collection".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "orderBy".to_string(), - type_: __Type::List(ListType { - type_: Box::new(__Type::NonNull(NonNullType { - type_: Box::new(__Type::OrderByEntity( - OrderByEntityType { - table: Arc::clone(table), - schema: self.schema.clone(), - }, - )), - })), - }), - description: Some("Sort order to apply to the collection".to_string()), - default_value: None, - sql_type: None, - }, - ], + name_: format!("{}Collection", lowercase_first_letter(table_base_type_name)), + type_: __Type::Connection(connection_type), + args: connection_args, description: Some(format!( "A pagable collection of type `{}`", table_base_type_name @@ -1540,136 +1537,126 @@ impl ___Type for EdgeType { } } -pub fn sql_type_to_graphql_type( - type_oid: u32, - type_name: &str, - max_characters: Option, - schema: &Arc<__Schema>, -) -> __Type { - let mut type_w_list_mod = match type_oid { - 20 => __Type::Scalar(Scalar::BigInt), // bigint - 16 => __Type::Scalar(Scalar::Boolean), // boolean - 1082 => __Type::Scalar(Scalar::Date), // date - 1184 => __Type::Scalar(Scalar::Datetime), // timestamp with time zone - 1114 => __Type::Scalar(Scalar::Datetime), // timestamp without time zone - 701 => __Type::Scalar(Scalar::Float), // double precision - 23 => __Type::Scalar(Scalar::Int), // integer - 21 => __Type::Scalar(Scalar::Int), // smallint - 700 => __Type::Scalar(Scalar::Float), // real - 3802 => __Type::Scalar(Scalar::JSON), // jsonb - 114 => __Type::Scalar(Scalar::JSON), // json - 1083 => __Type::Scalar(Scalar::Time), // time without time zone - 2950 => __Type::Scalar(Scalar::UUID), // uuid - 1700 => __Type::Scalar(Scalar::BigFloat), // numeric - 25 => __Type::Scalar(Scalar::String(None)), // text - // char, bpchar, varchar - 18 | 1042 | 1043 => __Type::Scalar(Scalar::String(max_characters)), - 1009 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::String(None))), - }), // text[] - 1016 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::BigInt)), - }), // bigint[] - 1000 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Boolean)), - }), // boolean[] - 1182 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Date)), - }), // date[] - 1115 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Datetime)), - }), // timestamp without time zone[] - 1185 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Datetime)), - }), // timestamp with time zone[] - 1022 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Float)), - }), // double precision[] - 1021 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Float)), - }), // real[] - 1005 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Int)), - }), // smallint[] - 1007 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Int)), - }), // integer[] - 199 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::JSON)), - }), // json[] - 3807 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::JSON)), - }), // jsonb[] - 1183 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Time)), - }), // time without time zone[] - 2951 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::UUID)), - }), // uuid[] - 1231 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::BigFloat)), - }), // numeric[] - // char[], bpchar[], varchar[] - 1002 | 1014 | 1015 => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::String(max_characters))), - }), // char[] or char(n)[] - _ => match type_name.ends_with("[]") { - true => __Type::List(ListType { - type_: Box::new(__Type::Scalar(Scalar::Opaque)), - }), - false => __Type::Scalar(Scalar::Opaque), - }, - }; +impl Type { + fn to_graphql_type( + &self, + max_characters: Option, + is_set_of: bool, + schema: &Arc<__Schema>, + ) -> Option<__Type> { + if is_set_of && !(self.category == TypeCategory::Table) { + // If a function returns a pseudotype with a single column + // e.g. table( id int ) + // postgres records that in pg_catalog as returning a setof int + // we don't support pseudo type returns, but this was sneaking through + // because it looks like a concrete type + return None; + } - if let Some(exact_type) = schema.context.types.get(&type_oid) { - if exact_type.permissions.is_usable { - match exact_type.category { - TypeCategory::Enum => match schema.context.enums.get(&exact_type.oid) { - Some(enum_) => { - type_w_list_mod = __Type::Enum(EnumType { - enum_: EnumSource::Enum(Arc::clone(enum_)), - schema: schema.clone(), - }) - } - None => {} - }, - TypeCategory::Array => { - match schema - .context - .enums - .get(&exact_type.array_element_type_oid.unwrap()) - { - Some(base_enum) => { - type_w_list_mod = __Type::List(ListType { - type_: Box::new(__Type::Enum(EnumType { - enum_: EnumSource::Enum(Arc::clone(base_enum)), - schema: Arc::clone(schema), - })), - }) - } - None => {} - } + match self.category { + TypeCategory::Other => { + Some(match self.oid { + 20 => __Type::Scalar(Scalar::BigInt), // bigint + 16 => __Type::Scalar(Scalar::Boolean), // boolean + 1082 => __Type::Scalar(Scalar::Date), // date + 1184 => __Type::Scalar(Scalar::Datetime), // timestamp with time zone + 1114 => __Type::Scalar(Scalar::Datetime), // timestamp without time zone + 701 => __Type::Scalar(Scalar::Float), // double precision + 23 => __Type::Scalar(Scalar::Int), // integer + 21 => __Type::Scalar(Scalar::Int), // smallint + 700 => __Type::Scalar(Scalar::Float), // real + 3802 => __Type::Scalar(Scalar::JSON), // jsonb + 114 => __Type::Scalar(Scalar::JSON), // json + 1083 => __Type::Scalar(Scalar::Time), // time without time zone + 2950 => __Type::Scalar(Scalar::UUID), // uuid + 1700 => __Type::Scalar(Scalar::BigFloat), // numeric + 25 => __Type::Scalar(Scalar::String(None)), // text + // char, bpchar, varchar + 18 | 1042 | 1043 => __Type::Scalar(Scalar::String(max_characters)), + _ => __Type::Scalar(Scalar::Opaque), + }) + } + TypeCategory::Array => match self.array_element_type_oid { + Some(array_element_type_oid) => { + let sql_types = &schema.context.types; + let element_sql_type: Option<&Arc> = + sql_types.get(&array_element_type_oid); + + let inner_graphql_type: __Type = match element_sql_type { + Some(sql_type) => match sql_type.permissions.is_usable { + true => match sql_type.to_graphql_type(None, false, schema) { + None => { + return None; + } + Some(inner_type) => inner_type, + }, + false => { + return None; + } + }, + None => __Type::Scalar(Scalar::Opaque), + }; + Some(__Type::List(ListType { + type_: Box::new(inner_graphql_type), + })) + } + // should not hpapen + None => None, + }, + TypeCategory::Enum => match schema.context.enums.get(&self.oid) { + Some(enum_) => Some(__Type::Enum(EnumType { + enum_: EnumSource::Enum(Arc::clone(enum_)), + schema: schema.clone(), + })), + None => Some(__Type::Scalar(Scalar::Opaque)), + }, + TypeCategory::Table => { + match self.table_oid { + // Shouldn't happen + None => None, + Some(table_oid) => match schema.context.tables.get(&table_oid) { + // Can happen if search path doesn't include referenced table + None => None, + Some(table) => match is_set_of { + true => Some(__Type::Connection(ConnectionType { + table: Arc::clone(table), + fkey: None, + schema: Arc::clone(schema), + })), + false => Some(__Type::Node(NodeType { + table: Arc::clone(table), + fkey: None, + reverse_reference: None, + schema: Arc::clone(schema), + })), + }, + }, } - _ => {} } + // Composites not yet supported + TypeCategory::Composite => None, + // Psudotypes like "record" are not supported + TypeCategory::Pseudo => None, } - }; - type_w_list_mod + } } -pub fn sql_column_to_graphql_type(col: &Column, schema: &Arc<__Schema>) -> __Type { - let type_w_list_mod = sql_type_to_graphql_type( - col.type_oid, - col.type_name.as_str(), - col.max_characters, - schema, - ); - - match col.is_not_null { - true => __Type::NonNull(NonNullType { - type_: Box::new(type_w_list_mod), - }), - _ => type_w_list_mod, +pub fn sql_column_to_graphql_type(col: &Column, schema: &Arc<__Schema>) -> Option<__Type> { + let sql_type = schema.context.types.get(&col.type_oid); + if sql_type.is_none() { + // Should never happen + return None; + } + let sql_type = sql_type.unwrap(); + let maybe_type_w_list_mod = sql_type.to_graphql_type(col.max_characters, false, schema); + match maybe_type_w_list_mod { + None => None, + Some(type_with_list_mod) => match col.is_not_null { + true => Some(__Type::NonNull(NonNullType { + type_: Box::new(type_with_list_mod), + })), + _ => Some(type_with_list_mod), + }, } } @@ -1708,13 +1695,19 @@ impl ___Type for NodeType { .iter() .filter(|x| x.permissions.is_selectable) .filter(|x| !self.schema.context.is_composite(x.type_oid)) - .map(|col| __Field { - name_: self.schema.graphql_column_field_name(&col), - type_: sql_column_to_graphql_type(col, &self.schema), - args: vec![], - description: col.directives.description.clone(), - deprecation_reason: None, - sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + .filter_map(|col| { + if let Some(utype) = sql_column_to_graphql_type(col, &self.schema) { + Some(__Field { + name_: self.schema.graphql_column_field_name(&col), + type_: utype, + args: vec![], + description: col.directives.description.clone(), + deprecation_reason: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + }) + } else { + None + } }) .filter(|x| is_valid_graphql_name(&x.name_)) .collect(); @@ -1742,6 +1735,7 @@ impl ___Type for NodeType { node_id_field.push(node_id); }; + let sql_types = &self.schema.context.types; // Functions require selecting an entire row. the whole table must be selectable // for functions to work let mut function_fields: Vec<__Field> = vec![]; @@ -1751,18 +1745,45 @@ impl ___Type for NodeType { .functions .iter() .filter(|x| x.permissions.is_executable) - .map(|func| __Field { - name_: self.schema.graphql_function_field_name(&func), - type_: sql_type_to_graphql_type( - func.type_oid, - func.type_name.as_str(), - None, - &self.schema, - ), - args: vec![], - description: func.directives.description.clone(), - deprecation_reason: None, - sql_type: Some(NodeSQLType::Function(Arc::clone(func))), + .filter(|func| { + // TODO: remove in favor of making `to_sql_type` return an Option + // so we can optionally remove inappropriate types + match sql_types.get(&func.type_oid) { + None => true, + Some(sql_type) => { + // disallow pseudo types + match &sql_type.category { + TypeCategory::Pseudo => false, + _ => true, + } + } + } + }) + .filter_map(|func| match sql_types.get(&func.type_oid) { + None => None, + Some(sql_type) => { + if let Some(gql_ret_type) = + sql_type.to_graphql_type(None, func.is_set_of, &self.schema) + { + let gql_args = match &gql_ret_type { + __Type::Connection(connection_type) => { + connection_type.get_connection_input_args() + } + _ => vec![], + }; + + Some(__Field { + name_: self.schema.graphql_function_field_name(&func), + type_: gql_ret_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(); @@ -1843,71 +1864,22 @@ impl ___Type for NodeType { let relation_field = match self.schema.context.fkey_is_locally_unique(fkey) { false => { + let connection_type = ConnectionType { + table: Arc::clone(foreign_table), + fkey: Some(ForeignKeyReversible { + fkey: Arc::clone(fkey), + reverse_reference: reverse_reference, + }), + schema: Arc::clone(&self.schema), + }; + let connection_args = connection_type.get_connection_input_args(); __Field { - name_: self.schema.graphql_foreign_key_field_name(fkey, reverse_reference), + name_: self + .schema + .graphql_foreign_key_field_name(fkey, reverse_reference), // XXX: column nullability ignored for NonNull type to match pg_graphql - type_: __Type::Connection(ConnectionType { - table: Arc::clone(foreign_table), - fkey: Some(Arc::clone(fkey)), - reverse_reference: Some(reverse_reference), - schema: Arc::clone(&self.schema), - }), - args: vec![ - __InputValue { - name_: "first".to_string(), - type_: __Type::Scalar(Scalar::Int), - description: Some("Query the first `n` records in the collection".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "last".to_string(), - type_: __Type::Scalar(Scalar::Int), - description: Some("Query the last `n` records in the collection".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "before".to_string(), - type_: __Type::Scalar(Scalar::Cursor), - description: Some("Query values in the collection before the provided cursor".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "after".to_string(), - type_: __Type::Scalar(Scalar::Cursor), - description: Some("Query values in the collection after the provided cursor".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "filter".to_string(), - type_: __Type::FilterEntity(FilterEntityType { - table: Arc::clone(foreign_table), - schema: Arc::clone(&self.schema), - }), - description: Some("Filters to apply to the results set when querying from the collection".to_string()), - default_value: None, - sql_type: None, - }, - __InputValue { - name_: "orderBy".to_string(), - type_: __Type::List(ListType { - type_: Box::new(__Type::NonNull(NonNullType { - type_: Box::new(__Type::OrderByEntity( - OrderByEntityType { - table: Arc::clone(foreign_table), - schema: Arc::clone(&self.schema), - }, - )), - })), - }), - description: Some("Sort order to apply to the collection".to_string()), - default_value: None, - sql_type: None, - }, - ], + type_: __Type::Connection(connection_type), + args: connection_args, description: None, deprecation_reason: None, sql_type: None, @@ -2800,15 +2772,20 @@ impl ___Type for InsertInputType { .filter(|x| !x.is_generated) .filter(|x| !x.is_serial) .filter(|x| !self.schema.context.is_composite(x.type_oid)) - // TODO: not composite - .map(|col| __InputValue { - name_: self.schema.graphql_column_field_name(&col), - // If triggers are involved, we can't detect if a field is non-null. Default - // all fields to non-null and let postgres errors handle it. - type_: sql_column_to_graphql_type(col, &self.schema).nullable_type(), - description: None, - default_value: None, - sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + .filter_map(|col| { + if let Some(utype) = sql_column_to_graphql_type(col, &self.schema) { + Some(__InputValue { + name_: self.schema.graphql_column_field_name(&col), + // If triggers are involved, we can't detect if a field is non-null. Default + // all fields to non-null and let postgres errors handle it. + type_: utype.nullable_type(), + description: None, + default_value: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + }) + } else { + None + } }) .collect(), ) @@ -2887,13 +2864,19 @@ impl ___Type for UpdateInputType { .filter(|x| !x.is_generated) .filter(|x| !x.is_serial) .filter(|x| !self.schema.context.is_composite(x.type_oid)) - .map(|col| __InputValue { - name_: self.schema.graphql_column_field_name(&col), - // TODO: handle possible array inputs - type_: sql_column_to_graphql_type(col, &self.schema).nullable_type(), - description: None, - default_value: None, - sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + .filter_map(|col| { + if let Some(utype) = sql_column_to_graphql_type(col, &self.schema) { + Some(__InputValue { + name_: self.schema.graphql_column_field_name(&col), + // TODO: handle possible array inputs + type_: utype.nullable_type(), + description: None, + default_value: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + }) + } else { + None + } }) .collect(), ) @@ -3322,33 +3305,35 @@ impl ___Type for FilterEntityType { .filter(|x| !vec!["json", "jsonb"].contains(&x.type_name.as_ref())) .filter_map(|col| { // Should be a scalar - let utype = sql_column_to_graphql_type(col, &self.schema).unmodified_type(); - - let column_graphql_name = self.schema.graphql_column_field_name(col); - - match utype { - __Type::Scalar(s) => Some(__InputValue { - name_: column_graphql_name, - type_: __Type::FilterType(FilterTypeType { - entity: FilterableType::Scalar(s), - schema: Arc::clone(&self.schema), + if let Some(utype) = sql_column_to_graphql_type(col, &self.schema) { + let column_graphql_name = self.schema.graphql_column_field_name(col); + + match utype.unmodified_type() { + __Type::Scalar(s) => Some(__InputValue { + name_: column_graphql_name, + type_: __Type::FilterType(FilterTypeType { + entity: FilterableType::Scalar(s), + schema: Arc::clone(&self.schema), + }), + description: None, + default_value: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), }), - description: None, - default_value: None, - sql_type: Some(NodeSQLType::Column(Arc::clone(col))), - }), - // ERROR HERE - __Type::Enum(s) => Some(__InputValue { - name_: column_graphql_name, - type_: __Type::FilterType(FilterTypeType { - entity: FilterableType::Enum(s), - schema: Arc::clone(&self.schema), + // ERROR HERE + __Type::Enum(s) => Some(__InputValue { + name_: column_graphql_name, + type_: __Type::FilterType(FilterTypeType { + entity: FilterableType::Enum(s), + schema: Arc::clone(&self.schema), + }), + description: None, + default_value: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), }), - description: None, - default_value: None, - sql_type: Some(NodeSQLType::Column(Arc::clone(col))), - }), - _ => None, + _ => None, + } + } else { + None } }) .filter(|x| is_valid_graphql_name(&x.name_)) @@ -3616,7 +3601,6 @@ impl __Schema { types_.push(__Type::Connection(ConnectionType { table: Arc::clone(table), fkey: None, - reverse_reference: None, schema: Arc::clone(&schema_rc), })); diff --git a/src/sql_types.rs b/src/sql_types.rs index 44e8ce50..96209b67 100644 --- a/src/sql_types.rs +++ b/src/sql_types.rs @@ -2,7 +2,9 @@ use cached::proc_macro::cached; use cached::SizedCache; use pgx::*; use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::*; @@ -58,6 +60,7 @@ pub struct Function { pub schema_name: String, pub type_oid: u32, pub type_name: String, + pub is_set_of: bool, pub comment: Option, pub directives: FunctionDirectives, pub permissions: FunctionPermissions, @@ -80,7 +83,9 @@ pub struct TypePermissions { pub enum TypeCategory { Enum, Composite, + Table, Array, + Pseudo, Other, } @@ -91,6 +96,7 @@ pub struct Type { pub name: String, pub category: TypeCategory, pub array_element_type_oid: Option, + pub table_oid: Option, pub comment: Option, pub permissions: TypePermissions, pub directives: EnumDirectives, @@ -491,9 +497,6 @@ pub fn load_sql_config() -> Config { config } -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; - pub fn calculate_hash(t: &T) -> u64 { let mut s = DefaultHasher::new(); t.hash(&mut s); diff --git a/src/transpile.rs b/src/transpile.rs index 865724cb..bc2bc86e 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1,6 +1,6 @@ use crate::builder::*; use crate::graphql::*; -use crate::sql_types::{Column, ForeignKey, ForeignKeyTableInfo, Table}; +use crate::sql_types::{Column, ForeignKey, ForeignKeyTableInfo, Function, Table}; use pgx::pg_sys::submodules::panic::CaughtError; use pgx::pg_sys::PgBuiltInOids; use pgx::prelude::*; @@ -728,6 +728,13 @@ impl FilterBuilder { } } +pub struct FromFunction { + function: Arc, + input_table: Arc
, + // The block name for the functions argument + input_block_name: String, +} + impl ConnectionBuilder { fn requested_total(&self) -> bool { self.selections @@ -759,86 +766,129 @@ impl ConnectionBuilder { .any(|x| matches!(&x, PageInfoSelection::HasPreviousPage { alias: _ })) } - pub fn to_sql( + fn is_reverse_pagination(&self) -> bool { + self.last.is_some() || self.before.is_some() + } + + fn to_join_clause( &self, - quoted_parent_block_name: Option<&str>, - param_context: &mut ParamContext, + quoted_block_name: &str, + quoted_parent_block_name: &Option<&str>, ) -> Result { - let quoted_block_name = rand_block_name(); - let quoted_schema = quote_ident(&self.table.schema); - let quoted_table = quote_ident(&self.table.name); - - let where_clause = - self.filter - .to_where_clause("ed_block_name, &self.table, param_context)?; - - let order_by_clause = self.order_by.to_order_by_clause("ed_block_name); - let order_by_clause_reversed = self - .order_by - .reverse() - .to_order_by_clause("ed_block_name); - - let is_reverse_pagination = self.last.is_some() || self.before.is_some(); - - let order_by_clause_records = match is_reverse_pagination { - true => &order_by_clause_reversed, - false => &order_by_clause, - }; - - let requested_total = self.requested_total(); - let requested_next_page = self.requested_next_page(); - let requested_previous_page = self.requested_previous_page(); - - let join_clause = match &self.fkey { + match &self.source.fkey { Some(fkey) => { let quoted_parent_block_name = quoted_parent_block_name .ok_or("Internal Error: Parent block name is required when fkey_ix is set")?; - self.table.to_join_clause( - fkey, - self.reverse_reference.unwrap(), + self.source.table.to_join_clause( + &fkey.fkey, + fkey.reverse_reference, "ed_block_name, quoted_parent_block_name, - )? + ) } - None => "true".to_string(), - }; + None => Ok("true".to_string()), + } + } + fn object_clause( + &self, + quoted_block_name: &str, + param_context: &mut ParamContext, + ) -> Result { let frags: Vec = self .selections .iter() .map(|x| { x.to_sql( - "ed_block_name, + quoted_block_name, &self.order_by, - &self.table, + &self.source.table, param_context, ) }) .collect::, _>>()?; - let limit: u64 = cmp::min( + Ok(frags.join(", ")) + } + + fn limit_clause(&self) -> u64 { + cmp::min( self.first .unwrap_or_else(|| self.last.unwrap_or(self.max_rows)), self.max_rows, - ); + ) + } - let object_clause = frags.join(", "); + fn from_clause(&self, quoted_block_name: &str, function: &Option) -> String { + let quoted_schema = quote_ident(&self.source.table.schema); + let quoted_table = quote_ident(&self.source.table.name); + + match function { + Some(from_function) => { + let quoted_func_schema = quote_ident(&from_function.function.schema_name); + let quoted_func = quote_ident(&from_function.function.name); + let input_block_name = &from_function.input_block_name; + let quoted_input_schema = quote_ident(&from_function.input_table.schema); + let quoted_input_table = quote_ident(&from_function.input_table.name); + format!("{quoted_func_schema}.{quoted_func}({input_block_name}::{quoted_input_schema}.{quoted_input_table}) {quoted_block_name}") + } + None => { + format!("{quoted_schema}.{quoted_table} {quoted_block_name}") + } + } + } + + pub fn to_sql( + &self, + quoted_parent_block_name: Option<&str>, + param_context: &mut ParamContext, + from_func: Option, + ) -> Result { + let quoted_block_name = rand_block_name(); + + let from_clause = self.from_clause("ed_block_name, &from_func); + + let where_clause = + self.filter + .to_where_clause("ed_block_name, &self.source.table, param_context)?; + + let order_by_clause = self.order_by.to_order_by_clause("ed_block_name); + let order_by_clause_reversed = self + .order_by + .reverse() + .to_order_by_clause("ed_block_name); + + let order_by_clause_records = match self.is_reverse_pagination() { + true => &order_by_clause_reversed, + false => &order_by_clause, + }; + + let requested_total = self.requested_total(); + let requested_next_page = self.requested_next_page(); + let requested_previous_page = self.requested_previous_page(); + + let join_clause = self.to_join_clause("ed_block_name, "ed_parent_block_name)?; let cursor = &self.before.clone().or_else(|| self.after.clone()); - let selectable_columns_clause = self.table.to_selectable_columns_clause(); + let object_clause = self.object_clause("ed_block_name, param_context)?; - let pkey_tuple_clause_from_block = - self.table.to_primary_key_tuple_clause("ed_block_name); - let pkey_tuple_clause_from_records = self.table.to_primary_key_tuple_clause("__records"); + let selectable_columns_clause = self.source.table.to_selectable_columns_clause(); + + let pkey_tuple_clause_from_block = self + .source + .table + .to_primary_key_tuple_clause("ed_block_name); + let pkey_tuple_clause_from_records = + self.source.table.to_primary_key_tuple_clause("__records"); let pagination_clause = { - let order_by = match is_reverse_pagination { + let order_by = match self.is_reverse_pagination() { true => self.order_by.reverse(), false => self.order_by.clone(), }; match cursor { - Some(cursor) => self.table.to_pagination_clause( + Some(cursor) => self.source.table.to_pagination_clause( "ed_block_name, &order_by, cursor, @@ -849,6 +899,8 @@ impl ConnectionBuilder { } }; + let limit = self.limit_clause(); + // initialized assuming forwards pagination let mut has_next_page_query = format!( " @@ -856,7 +908,7 @@ impl ConnectionBuilder { select 1 from - {quoted_schema}.{quoted_table} {quoted_block_name} + {from_clause} where {join_clause} and {where_clause} @@ -874,7 +926,7 @@ impl ConnectionBuilder { select not ({pkey_tuple_clause_from_block} = any( __records.seen )) is_pkey_in_records from - {quoted_schema}.{quoted_table} {quoted_block_name} + {from_clause} left join (select array_agg({pkey_tuple_clause_from_records}) from __records ) __records(seen) on true where @@ -887,7 +939,7 @@ impl ConnectionBuilder { select coalesce(bool_and(is_pkey_in_records), false) from page_minus_1 "); - if is_reverse_pagination { + if self.is_reverse_pagination() { // Reverse has_next_page and has_previous_page std::mem::swap(&mut has_next_page_query, &mut has_prev_page_query); } @@ -905,7 +957,7 @@ impl ConnectionBuilder { select {selectable_columns_clause} from - {quoted_schema}.{quoted_table} {quoted_block_name} + {from_clause} where true and {join_clause} @@ -920,7 +972,7 @@ impl ConnectionBuilder { select count(*) from - {quoted_schema}.{quoted_table} {quoted_block_name} + {from_clause} where {requested_total} -- skips total when not requested and {join_clause} @@ -947,7 +999,7 @@ impl ConnectionBuilder { impl QueryEntrypoint for ConnectionBuilder { fn to_sql_entrypoint(&self, param_context: &mut ParamContext) -> Result { - self.to_sql(None, param_context) + self.to_sql(None, param_context, None) } } @@ -1236,7 +1288,7 @@ impl NodeSelection { Self::Connection(builder) => format!( "{}, {}", quote_literal(&builder.alias), - builder.to_sql(Some(block_name), param_context)? + builder.to_sql(Some(block_name), param_context, None)? ), Self::Node(builder) => format!( "{}, {}", @@ -1258,7 +1310,7 @@ impl NodeSelection { format!( "{}, {}{}", quote_literal(&builder.alias), - builder.to_sql(block_name)?, + builder.to_sql(block_name, param_context)?, type_adjustment_clause ) } @@ -1301,14 +1353,53 @@ impl NodeIdBuilder { } impl FunctionBuilder { - pub fn to_sql(&self, block_name: &str) -> Result { - let schema_name = &self.function.schema_name; - let function_name = &self.function.name; - Ok(format!( - "{schema_name}.{function_name}({block_name}::{}.{})", - quote_ident(&self.table.schema), - quote_ident(&self.table.name) - )) + pub fn to_sql( + &self, + block_name: &str, + param_context: &mut ParamContext, + ) -> Result { + let schema_name = quote_ident(&self.function.schema_name); + let function_name = quote_ident(&self.function.name); + + let sql_frag = match &self.selection { + FunctionSelection::ScalarSelf => format!( + "{schema_name}.{function_name}({block_name}::{}.{})", + quote_ident(&self.table.schema), + quote_ident(&self.table.name) + ), + FunctionSelection::Node(node_builder) => { + let func_block_name = rand_block_name(); + let object_clause = node_builder.to_sql(&func_block_name, param_context)?; + + let from_clause = format!( + "{schema_name}.{function_name}({block_name}::{}.{})", + quote_ident(&self.table.schema), + quote_ident(&self.table.name) + ); + format!( + " + ( + select + {object_clause} + from + {from_clause} as {func_block_name} + where + {func_block_name} is not null + ) + " + ) + } + FunctionSelection::Connection(connection_builder) => connection_builder.to_sql( + None, + param_context, + Some(FromFunction { + function: Arc::clone(&self.function), + input_table: Arc::clone(&self.table), + input_block_name: block_name.to_string(), + }), + )?, + }; + Ok(sql_frag) } } diff --git a/test/expected/extend_type_with_function_relation.out b/test/expected/extend_type_with_function_relation.out new file mode 100644 index 00000000..8298a62f --- /dev/null +++ b/test/expected/extend_type_with_function_relation.out @@ -0,0 +1,506 @@ +begin; + create table account( + id serial primary key, + email varchar(255) not null + ); + insert into account(email) values ('foo'), ('bar'), ('baz'); + create table blog( + id serial primary key, + name varchar(255) not null + ); + insert into blog(name) + select + 'blog ' || x + from + generate_series(1, 5) y(x); + create function public.many_blogs(public.account) + returns setof public.blog + language sql + as + $$ + select * from public.blog where id between $1.id * 4 - 4 and $1.id * 4; + $$; + -- To Many + select jsonb_pretty( + graphql.resolve($$ + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + } + } + + { + __type(name: "Account") { + fields { + name + type { + ...TypeRef + } + } + } + } + $$) + ); + jsonb_pretty +--------------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "nodeId", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "ID" + + } + + } + + }, + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int" + + } + + } + + }, + + { + + "name": "email", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "String" + + } + + } + + }, + + { + + "name": "manyBlogs", + + "type": { + + "kind": "OBJECT", + + "name": "BlogConnection",+ + "ofType": null + + } + + } + + ] + + } + + } + + } +(1 row) + + select jsonb_pretty( + graphql.resolve($$ + { + accountCollection { + edges { + node { + id + manyBlogs(first: 2) { + pageInfo { + hasNextPage + } + edges { + node { + id + name + } + } + } + } + } + } + } + $$) + ); + jsonb_pretty +---------------------------------------------------------- + { + + "data": { + + "accountCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "manyBlogs": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "name": "blog 1"+ + } + + }, + + { + + "node": { + + "id": 2, + + "name": "blog 2"+ + } + + } + + ], + + "pageInfo": { + + "hasNextPage": true + + } + + } + + } + + }, + + { + + "node": { + + "id": 2, + + "manyBlogs": { + + "edges": [ + + { + + "node": { + + "id": 4, + + "name": "blog 4"+ + } + + }, + + { + + "node": { + + "id": 5, + + "name": "blog 5"+ + } + + } + + ], + + "pageInfo": { + + "hasNextPage": false + + } + + } + + } + + }, + + { + + "node": { + + "id": 3, + + "manyBlogs": { + + "edges": [ + + ], + + "pageInfo": { + + "hasNextPage": false + + } + + } + + } + + } + + ] + + } + + } + + } +(1 row) + + -- To One (function returns single value) + savepoint a; + create function public.one_account(public.blog) + returns public.account + language sql + as + $$ + select * from public.account where id = $1.id - 2; + $$; + select jsonb_pretty( + graphql.resolve($$ + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + } + } + + { + __type(name: "Blog") { + fields { + name + type { + ...TypeRef + } + } + } + } + $$) + ); + jsonb_pretty +----------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "nodeId", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "ID" + + } + + } + + }, + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "Int" + + } + + } + + }, + + { + + "name": "name", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "String" + + } + + } + + }, + + { + + "name": "oneAccount", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + } + + } + + ] + + } + + } + + } +(1 row) + + select jsonb_pretty( + graphql.resolve($$ + { + blogCollection(first: 3) { + edges { + node { + id + oneAccount { + id + email + } + } + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------- + { + + "data": { + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "oneAccount": null+ + } + + }, + + { + + "node": { + + "id": 2, + + "oneAccount": null+ + } + + }, + + { + + "node": { + + "id": 3, + + "oneAccount": { + + "id": 1, + + "email": "foo"+ + } + + } + + } + + ] + + } + + } + + } +(1 row) + + rollback to savepoint a; + -- To One (function returns set of <> rows 1) + create or replace function public.one_account(public.blog) + returns setof public.account rows 1 + language sql + as + $$ + select * from public.account where id = $1.id - 2; + $$; + select jsonb_pretty( + graphql.resolve($$ + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + } + } + + { + __type(name: "Blog") { + fields { + name + type { + ...TypeRef + } + } + } + } + $$) + ); + jsonb_pretty +----------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "nodeId", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "ID" + + } + + } + + }, + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "Int" + + } + + } + + }, + + { + + "name": "name", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "String" + + } + + } + + }, + + { + + "name": "oneAccount", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + } + + } + + ] + + } + + } + + } +(1 row) + + select jsonb_pretty( + graphql.resolve($$ + { + blogCollection(first: 3) { + edges { + node { + id + oneAccount { + id + email + } + } + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------- + { + + "data": { + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "oneAccount": null+ + } + + }, + + { + + "node": { + + "id": 2, + + "oneAccount": null+ + } + + }, + + { + + "node": { + + "id": 3, + + "oneAccount": { + + "id": 1, + + "email": "foo"+ + } + + } + + } + + ] + + } + + } + + } +(1 row) + + -- Confirm name overrides work + comment on function public.one_account(public.blog) is E'@graphql({"name": "acctOverride"})'; + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Blog") { + fields { + name + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "nodeId" + + }, + + { + + "name": "id" + + }, + + { + + "name": "name" + + }, + + { + + "name": "acctOverride"+ + } + + ] + + } + + } + + } +(1 row) + +rollback; diff --git a/test/expected/issue_339_function_return_table.out b/test/expected/issue_339_function_return_table.out index ae499ce3..3f6716ae 100644 --- a/test/expected/issue_339_function_return_table.out +++ b/test/expected/issue_339_function_return_table.out @@ -2,6 +2,7 @@ begin; create table public.account( id int primary key ); + -- appears in pg_catalog as returning a set of int create function public._computed(rec public.account) returns table ( id int ) immutable @@ -10,7 +11,17 @@ begin; as $$ select 2 as id; $$; + -- appears in pg_catalog as returning a set of pseudotype "record" + create function public._computed2(rec public.account) + returns table ( id int, name text ) + immutable + strict + language sql + as $$ + select 2 as id, 'abc' as name; + $$; insert into account(id) values (1); + -- neither computed nor computed2 should be present select jsonb_pretty( graphql.resolve($$ { @@ -42,30 +53,4 @@ begin; } (1 row) - select jsonb_pretty( - graphql.resolve($$ - { - accountCollection { - edges { - node { - id - computed - } - } - } - } - $$) - ); - jsonb_pretty ---------------------------------------------------------------------- - { + - "data": null, + - "errors": [ + - { + - "message": "Unknown field 'computed' on type 'Account'"+ - } + - ] + - } -(1 row) - rollback; diff --git a/test/sql/extend_type_with_function_relation.sql b/test/sql/extend_type_with_function_relation.sql new file mode 100644 index 00000000..2f05de0c --- /dev/null +++ b/test/sql/extend_type_with_function_relation.sql @@ -0,0 +1,207 @@ +begin; + create table account( + id serial primary key, + email varchar(255) not null + ); + + insert into account(email) values ('foo'), ('bar'), ('baz'); + + create table blog( + id serial primary key, + name varchar(255) not null + ); + + insert into blog(name) + select + 'blog ' || x + from + generate_series(1, 5) y(x); + + + create function public.many_blogs(public.account) + returns setof public.blog + language sql + as + $$ + select * from public.blog where id between $1.id * 4 - 4 and $1.id * 4; + $$; + + -- To Many + + select jsonb_pretty( + graphql.resolve($$ + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + } + } + + { + __type(name: "Account") { + fields { + name + type { + ...TypeRef + } + } + } + } + $$) + ); + + select jsonb_pretty( + graphql.resolve($$ + { + accountCollection { + edges { + node { + id + manyBlogs(first: 2) { + pageInfo { + hasNextPage + } + edges { + node { + id + name + } + } + } + } + } + } + } + $$) + ); + + -- To One (function returns single value) + savepoint a; + + create function public.one_account(public.blog) + returns public.account + language sql + as + $$ + select * from public.account where id = $1.id - 2; + $$; + + select jsonb_pretty( + graphql.resolve($$ + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + } + } + + { + __type(name: "Blog") { + fields { + name + type { + ...TypeRef + } + } + } + } + $$) + ); + + + select jsonb_pretty( + graphql.resolve($$ + { + blogCollection(first: 3) { + edges { + node { + id + oneAccount { + id + email + } + } + } + } + } + $$) + ); + + rollback to savepoint a; + + -- To One (function returns set of <> rows 1) + create or replace function public.one_account(public.blog) + returns setof public.account rows 1 + language sql + as + $$ + select * from public.account where id = $1.id - 2; + $$; + + select jsonb_pretty( + graphql.resolve($$ + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + } + } + + { + __type(name: "Blog") { + fields { + name + type { + ...TypeRef + } + } + } + } + $$) + ); + + + select jsonb_pretty( + graphql.resolve($$ + { + blogCollection(first: 3) { + edges { + node { + id + oneAccount { + id + email + } + } + } + } + } + $$) + ); + + -- Confirm name overrides work + comment on function public.one_account(public.blog) is E'@graphql({"name": "acctOverride"})'; + + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Blog") { + fields { + name + } + } + } + $$) + ); + + +rollback; diff --git a/test/sql/issue_339_function_return_table.sql b/test/sql/issue_339_function_return_table.sql index 175c0f5c..c4db26ae 100644 --- a/test/sql/issue_339_function_return_table.sql +++ b/test/sql/issue_339_function_return_table.sql @@ -3,6 +3,7 @@ begin; id int primary key ); + -- appears in pg_catalog as returning a set of int create function public._computed(rec public.account) returns table ( id int ) immutable @@ -12,8 +13,19 @@ begin; select 2 as id; $$; + -- appears in pg_catalog as returning a set of pseudotype "record" + create function public._computed2(rec public.account) + returns table ( id int, name text ) + immutable + strict + language sql + as $$ + select 2 as id, 'abc' as name; + $$; + insert into account(id) values (1); + -- neither computed nor computed2 should be present select jsonb_pretty( graphql.resolve($$ { @@ -27,19 +39,4 @@ begin; $$) ); - select jsonb_pretty( - graphql.resolve($$ - { - accountCollection { - edges { - node { - id - computed - } - } - } - } - $$) - ); - rollback;