diff --git a/docs/api.md b/docs/api.md index 25b89beb..39362281 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1742,6 +1742,183 @@ The default order of results is defined by the underlying table's primary key co Note, only one key value pair may be provided to each element of the input array. For example, `[{name: AscNullsLast}, {id: AscNullFirst}]` is valid. Passing multiple key value pairs in a single element of the input array e.g. `[{name: AscNullsLast, id: AscNullFirst}]`, is invalid. +### Primary Key Queries + +Each table has a top level field in the `Query` type for selecting a single record by primary key from that table. The field is named `ByPk` + +**SQL Setup** +```sql +create table "Blog"( + id serial primary key, + name varchar(255) not null, + description varchar(255), + "createdAt" timestamp not null, + "updatedAt" timestamp not null +); +``` + +**GraphQL Types** +=== "QueryType" + + ```graphql + """The root type for querying data""" + type Query { + + """Retrieve a blog by its id""" + blogByPk(id: Int!): Blog + + } + ``` + +To query the table by primary key, pass the value of the primary key field to the field: + +**Example** +=== "Query" + + ```graphql + { + blogByPk( + id: 1 + ) { + id + name + description + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "blogByPk": { + "id": 1, + "name": "Some Blog", + "description": "Description of Some Blog" + } + } + } + ``` + +If a record with the give id doesn't exist, the field will return null: + +**Example** +=== "Query" + + ```graphql + { + blogByPk( + id: 999 + ) { + id + name + description + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "blogByPk": null + } + } + ``` + +If the key is a composite primary key, all the columns of the primary key should be sent in the query: + + + +**SQL Setup** +```sql +create table item( + item_id int, + product_id int, + quantity int, + price numeric(10,2), + primary key(item_id, product_id) +); +``` + +**GraphQL Types** +=== "QueryType" + + ```graphql + """The root type for querying data""" + type Query { + + """Retrieve an item by its item and product ids""" + itemByPk(itemId: Int!, productId: Int!): Item + + } + ``` +**Example** +=== "Query" + + ```graphql + { + itemByPk( + itemId: 1, productId: 2 + ) { + itemId + productId + quantity + price + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "itemByPk": { + "itemId": 1, + "productId": 2, + "quantity": 1, + "price": "24.99" + } + } + } + ``` + +Otherwise an error will be returned: + +**Example** +=== "Query" + + ```graphql + { + itemByPk( + itemId: 1 + ) { + itemId + productId + quantity + price + } + } + ``` + +=== "Response" + + ```json + { + "data": null, + "errors": [ + { + "message": "Missing primary key column(s): product_id" + } + ] + } + ``` + + + ## MutationType The `Mutation` type is the entrypoint for mutations/edits. diff --git a/docs/changelog.md b/docs/changelog.md index 29b85a7b..a2b9285b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -127,3 +127,5 @@ - feature: Add support for Postgres 18 ## master + +- feature: Add support for single record queries by primary key diff --git a/src/builder.rs b/src/builder.rs index 6baf1526..1275b3fb 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1037,6 +1037,13 @@ pub struct NodeBuilder { pub selections: Vec, } +#[derive(Clone, Debug)] +pub struct NodeByPkBuilder { + pub pk_values: HashMap, + pub table: Arc
, + pub selections: Vec, +} + #[derive(Clone, Debug)] pub enum NodeSelection { Connection(ConnectionBuilder), @@ -2156,6 +2163,194 @@ where }) } +pub fn to_node_by_pk_builder<'a, T>( + field: &__Field, + query_field: &graphql_parser::query::Field<'a, T>, + fragment_definitions: &Vec>, + variables: &serde_json::Value, + variable_definitions: &Vec>, +) -> GraphQLResult +where + T: Text<'a> + Eq + AsRef + Clone, + T::Value: Hash, +{ + let type_ = field.type_().unmodified_type(); + + // This function is only called for Node types from resolve_selection_set + let xtype = match type_ { + __Type::Node(node_type) => node_type, + _ => return Err(GraphQLError::internal("to_node_by_pk_builder called with non-Node type")), + }; + + let type_name = xtype + .name() + .ok_or_else(|| GraphQLError::internal("Encountered type without name in node_by_pk builder"))?; + + let field_map = field_map(&__Type::Node(xtype.clone())); + + // Get primary key columns from the table + let pkey = xtype + .table + .primary_key() + .ok_or_else(|| GraphQLError::validation("Table has no primary key"))?; + + // Create a map of expected field arguments based on the field's arg definitions + let mut pk_arg_map = HashMap::new(); + for arg in field.args() { + if let Some(NodeSQLType::Column(col)) = &arg.sql_type { + pk_arg_map.insert(arg.name().to_string(), col.name.clone()); + } + } + + let mut pk_values = HashMap::new(); + + // Process each argument in the query + for arg in &query_field.arguments { + let arg_name = arg.0.as_ref(); + + // Find the corresponding column name from our argument map + if let Some(col_name) = pk_arg_map.get(arg_name) { + let value = to_gson(&arg.1, variables, variable_definitions)?; + let json_value = gson::gson_to_json(&value)?; + pk_values.insert(col_name.clone(), json_value); + } + } + + // Need values for all primary key columns + if pk_values.len() != pkey.column_names.len() { + let missing_cols: Vec<_> = pkey + .column_names + .iter() + .filter(|col| !pk_values.contains_key(*col)) + .collect(); + return Err(GraphQLError::argument(format!( + "Missing primary key column(s): {}", + missing_cols.iter().map(|s| s.as_str()).collect::>().join(", ") + ))); + } + + let mut builder_fields = vec![]; + let selection_fields = normalize_selection_set( + &query_field.selection_set, + fragment_definitions, + &type_name, + variables, + )?; + + for selection_field in selection_fields { + match field_map.get(selection_field.name.as_ref()) { + None => { + return Err(GraphQLError::field_not_found( + selection_field.name.as_ref(), + &type_name + )) + } + Some(f) => { + 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::List(_) => FunctionSelection::Array, + __Type::Node(_) => { + let node_builder = to_node_builder( + f, + &selection_field, + fragment_definitions, + variables, + &[], + variable_definitions, + )?; + FunctionSelection::Node(node_builder) + } + __Type::Connection(_) => { + let connection_builder = to_connection_builder( + f, + &selection_field, + fragment_definitions, + variables, + &[], + variable_definitions, + )?; + FunctionSelection::Connection(connection_builder) + } + _ => { + return Err(GraphQLError::type_error( + "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(), + 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().expect("node type should have a name"), + }, + _ => match f.type_().unmodified_type() { + __Type::Connection(_) => { + let con_builder = to_connection_builder( + f, + &selection_field, + fragment_definitions, + variables, + &[], + variable_definitions, + ); + NodeSelection::Connection(con_builder?) + } + __Type::Node(_) => { + let node_builder = to_node_builder( + f, + &selection_field, + fragment_definitions, + variables, + &[], + variable_definitions, + ); + NodeSelection::Node(node_builder?) + } + _ => { + return Err(GraphQLError::type_error(format!( + "unexpected field type on node {}", + f.name() + ))); + } + }, + }, + }; + builder_fields.push(node_selection); + } + } + } + + Ok(NodeByPkBuilder { + pk_values, + table: Arc::clone(&xtype.table), + selections: builder_fields, + }) +} + // Introspection #[allow(clippy::large_enum_variant)] diff --git a/src/graphql.rs b/src/graphql.rs index 1d07eeb1..0b27a432 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1247,6 +1247,61 @@ impl ___Type for QueryType { }; f.push(collection_entrypoint); + + // Add single record query by primary key if the table has a primary key + // and the primary key types are supported (int, bigint, uuid, string) + if let Some(primary_key) = table.primary_key() { + if table.has_supported_pk_types_for_by_pk() { + let node_type = NodeType { + table: Arc::clone(table), + fkey: None, + reverse_reference: None, + schema: Arc::clone(&self.schema), + }; + + // Create arguments for each primary key column + let mut pk_args = Vec::new(); + for col_name in &primary_key.column_names { + if let Some(col) = table.columns.iter().find(|c| &c.name == col_name) { + let col_type = sql_column_to_graphql_type(col, &self.schema) + .ok_or_else(|| { + format!( + "Could not determine GraphQL type for column {}", + col_name + ) + }) + .unwrap_or(__Type::Scalar(Scalar::String(None))); + + // Use graphql_column_field_name to convert snake_case to camelCase if needed + let arg_name = self.schema.graphql_column_field_name(col); + + pk_args.push(__InputValue { + name_: arg_name, + type_: __Type::NonNull(NonNullType { + type_: Box::new(col_type), + }), + description: Some(format!("The record's `{}` value", col_name)), + default_value: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + }); + } + } + + let pk_entrypoint = __Field { + name_: format!("{}ByPk", lowercase_first_letter(table_base_type_name)), + type_: __Type::Node(node_type), + args: pk_args, + description: Some(format!( + "Retrieve a record of type `{}` by its primary key", + table_base_type_name + )), + deprecation_reason: None, + sql_type: None, + }; + + f.push(pk_entrypoint); + } + } } } @@ -3433,7 +3488,7 @@ impl FromStr for FilterOp { "contains" => Ok(Self::Contains), "containedBy" => Ok(Self::ContainedBy), "overlaps" => Ok(Self::Overlap), - _ => Err("Invalid filter operation".to_string()), + other => Err(format!("Invalid filter operation: {}", other)), } } } diff --git a/src/resolve.rs b/src/resolve.rs index 2f734bba..20f2f299 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -257,6 +257,26 @@ where }), } } + __Type::Node(_) => { + // Node types at Query level are *ByPk fields with primary key column args + let node_by_pk_builder = to_node_by_pk_builder( + field_def, + selection, + &fragment_definitions, + variables, + variable_definitions, + ); + + match node_by_pk_builder { + Ok(builder) => match builder.execute() { + Ok(d) => { + res_data[alias_or_name(selection)] = d; + } + Err(msg) => res_errors.push(ErrorMessage { message: msg.to_string() }), + }, + Err(msg) => res_errors.push(ErrorMessage { message: msg.to_string() }), + } + } __Type::__Type(_) => { let __type_builder = schema_type.to_type_builder( field_def, diff --git a/src/sql_types.rs b/src/sql_types.rs index 1520f383..40e636e0 100644 --- a/src/sql_types.rs +++ b/src/sql_types.rs @@ -578,6 +578,18 @@ impl Table { .collect::>>() } + pub fn has_supported_pk_types_for_by_pk(&self) -> bool { + let pk_columns = self.primary_key_columns(); + if pk_columns.is_empty() { + return false; + } + + // Check that all primary key columns have supported types + pk_columns + .iter() + .all(|col| SupportedPrimaryKeyType::from_type_name(&col.type_name).is_some()) + } + pub fn is_any_column_selectable(&self) -> bool { self.columns.iter().any(|x| x.permissions.is_selectable) } @@ -601,6 +613,41 @@ impl Table { } } +#[derive(Debug, PartialEq)] +pub enum SupportedPrimaryKeyType { + // Integer types + Int, // int, int4, integer + BigInt, // bigint, int8 + SmallInt, // smallint, int2 + // String types + Text, // text + VarChar, // varchar + Char, // char, bpchar + CiText, // citext + // UUID + Uuid, // uuid +} + +impl SupportedPrimaryKeyType { + fn from_type_name(type_name: &str) -> Option { + match type_name { + // Integer types + "int" | "int4" | "integer" => Some(Self::Int), + "bigint" | "int8" => Some(Self::BigInt), + "smallint" | "int2" => Some(Self::SmallInt), + // String types + "text" => Some(Self::Text), + "varchar" => Some(Self::VarChar), + "char" | "bpchar" => Some(Self::Char), + "citext" => Some(Self::CiText), + // UUID + "uuid" => Some(Self::Uuid), + // Any other type is not supported + _ => None, + } + } +} + #[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct SchemaDirectives { // @graphql({"inflect_names": true}) diff --git a/src/transpile.rs b/src/transpile.rs index aa6fbaa2..60093bd4 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1502,12 +1502,80 @@ impl QueryEntrypoint for NodeBuilder { let quoted_table = quote_ident(&self.table.name); let object_clause = self.to_sql("ed_block_name, param_context)?; - let node_id = self - .node_id - .as_ref() - .ok_or("Expected nodeId argument missing")?; + let where_clause = match &self.node_id { + Some(node_id) => node_id.to_sql("ed_block_name, &self.table, param_context)?, + None => "true".to_string(), + }; + + Ok(format!( + " + ( + select + {object_clause} + from + {quoted_schema}.{quoted_table} as {quoted_block_name} + where + {where_clause} + ) + " + )) + } +} - let node_id_clause = node_id.to_sql("ed_block_name, &self.table, param_context)?; +impl NodeByPkBuilder { + pub fn to_sql( + &self, + block_name: &str, + param_context: &mut ParamContext, + ) -> GraphQLResult { + let mut field_clauses = vec![]; + for selection in &self.selections { + field_clauses.push(selection.to_sql(block_name, param_context)?); + } + + let fields_clause = field_clauses.join(", "); + Ok(format!("jsonb_build_object({fields_clause})")) + } + + pub fn to_pk_where_clause( + &self, + block_name: &str, + param_context: &mut ParamContext, + ) -> GraphQLResult { + let mut conditions = Vec::new(); + + for (column_name, value) in &self.pk_values { + let value_clause = param_context.clause_for( + value, + &self + .table + .columns + .iter() + .find(|c| &c.name == column_name) + .ok_or_else(|| GraphQLError::internal(format!("Column {} not found", column_name)))? + .type_name, + )?; + + conditions.push(format!( + "{}.{} = {}", + block_name, + quote_ident(column_name), + value_clause + )); + } + + Ok(conditions.join(" AND ")) + } +} + +impl QueryEntrypoint for NodeByPkBuilder { + fn to_sql_entrypoint(&self, param_context: &mut ParamContext) -> GraphQLResult { + let quoted_block_name = rand_block_name(); + let quoted_schema = quote_ident(&self.table.schema); + let quoted_table = quote_ident(&self.table.name); + let object_clause = self.to_sql("ed_block_name, param_context)?; + + let where_clause = self.to_pk_where_clause("ed_block_name, param_context)?; Ok(format!( " @@ -1517,7 +1585,7 @@ impl QueryEntrypoint for NodeBuilder { from {quoted_schema}.{quoted_table} as {quoted_block_name} where - {node_id_clause} + {where_clause} ) " )) @@ -1539,13 +1607,38 @@ impl NodeIdInstance { )); } - let mut col_val_pairs: Vec = vec![]; - for (col, val) in table.primary_key_columns().iter().zip(self.values.iter()) { - let column_name = &col.name; - let val_clause = param_context.clause_for(val, &col.type_name)?; - col_val_pairs.push(format!("{block_name}.{column_name} = {val_clause}")) + let pkey = table + .primary_key() + .ok_or_else(|| GraphQLError::validation("Found table with no primary key"))?; + + if pkey.column_names.len() != self.values.len() { + return Err(GraphQLError::validation(format!( + "Primary key column count mismatch. Expected {}, provided {}", + pkey.column_names.len(), + self.values.len() + ))); + } + + let mut conditions = vec![]; + + for (column_name, value) in pkey.column_names.iter().zip(&self.values) { + let column = table + .columns + .iter() + .find(|c| &c.name == column_name) + .ok_or_else(|| GraphQLError::validation(format!("Primary key column {} not found", column_name)))?; + + let value_clause = param_context.clause_for(value, &column.type_name)?; + + conditions.push(format!( + "{}.{} = {}", + block_name, + quote_ident(column_name), + value_clause + )); } - Ok(col_val_pairs.join(" and ")) + + Ok(conditions.join(" AND ")) } } diff --git a/test/expected/function_calls.out b/test/expected/function_calls.out index bd2dd34d..3d6a3ea3 100644 --- a/test/expected/function_calls.out +++ b/test/expected/function_calls.out @@ -2042,226 +2042,248 @@ begin; } } } $$)); - jsonb_pretty ---------------------------------------------------------------------------------- - { + - "data": { + - "__schema": { + - "queryType": { + - "fields": [ + - { + - "args": [ + - { + - "name": "first", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - { + - "name": "last", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - { + - "name": "before", + - "type": { + - "kind": "SCALAR", + - "name": "Cursor", + - "ofType": null + - } + - }, + - { + - "name": "after", + - "type": { + - "kind": "SCALAR", + - "name": "Cursor", + - "ofType": null + - } + - }, + - { + - "name": "offset", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - { + - "name": "filter", + - "type": { + - "kind": "INPUT_OBJECT", + - "name": "AccountFilter", + - "ofType": null + - } + - }, + - { + - "name": "orderBy", + - "type": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null + - } + - } + - } + - ], + - "name": "accountCollection", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "AccountConnection" + - } + - }, + - "description": "A pagable collection of type `Account`"+ - }, + - { + - "args": [ + - { + - "name": "nodeId", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "ID" + - } + - } + - } + - ], + - "name": "node", + - "type": { + - "kind": "INTERFACE", + - "name": "Node", + - "ofType": null + - }, + - "description": "Retrieve a record by its `ID`" + - }, + - { + - "args": [ + - ], + - "name": "returnsAccount", + - "type": { + - "kind": "OBJECT", + - "name": "Account", + - "ofType": null + - }, + - "description": null + - }, + - { + - "args": [ + - { + - "name": "idToSearch", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int" + - } + - } + - } + - ], + - "name": "returnsAccountWithId", + - "type": { + - "kind": "OBJECT", + - "name": "Account", + - "ofType": null + - }, + - "description": null + - }, + - { + - "args": [ + - { + - "name": "top", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int" + - } + - } + - }, + - { + - "name": "first", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - { + - "name": "last", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - { + - "name": "before", + - "type": { + - "kind": "SCALAR", + - "name": "Cursor", + - "ofType": null + - } + - }, + - { + - "name": "after", + - "type": { + - "kind": "SCALAR", + - "name": "Cursor", + - "ofType": null + - } + - }, + - { + - "name": "offset", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - { + - "name": "filter", + - "type": { + - "kind": "INPUT_OBJECT", + - "name": "AccountFilter", + - "ofType": null + - } + - }, + - { + - "name": "orderBy", + - "type": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null + - } + - } + - } + - ], + - "name": "returnsSetofAccount", + - "type": { + - "kind": "OBJECT", + - "name": "AccountConnection", + - "ofType": null + - }, + - "description": null + - } + - ] + - } + - } + - } + + jsonb_pretty +------------------------------------------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "queryType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null + + } + + } + + } + + ], + + "name": "accountByPk", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + }, + + "description": "Retrieve a record of type `Account` by its primary key"+ + }, + + { + + "args": [ + + { + + "name": "first", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "last", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "before", + + "type": { + + "kind": "SCALAR", + + "name": "Cursor", + + "ofType": null + + } + + }, + + { + + "name": "after", + + "type": { + + "kind": "SCALAR", + + "name": "Cursor", + + "ofType": null + + } + + }, + + { + + "name": "offset", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "filter", + + "type": { + + "kind": "INPUT_OBJECT", + + "name": "AccountFilter", + + "ofType": null + + } + + }, + + { + + "name": "orderBy", + + "type": { + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null + + } + + } + + } + + ], + + "name": "accountCollection", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "AccountConnection" + + } + + }, + + "description": "A pagable collection of type `Account`" + + }, + + { + + "args": [ + + { + + "name": "nodeId", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "ID" + + } + + } + + } + + ], + + "name": "node", + + "type": { + + "kind": "INTERFACE", + + "name": "Node", + + "ofType": null + + }, + + "description": "Retrieve a record by its `ID`" + + }, + + { + + "args": [ + + ], + + "name": "returnsAccount", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "idToSearch", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int" + + } + + } + + } + + ], + + "name": "returnsAccountWithId", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "top", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int" + + } + + } + + }, + + { + + "name": "first", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "last", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "before", + + "type": { + + "kind": "SCALAR", + + "name": "Cursor", + + "ofType": null + + } + + }, + + { + + "name": "after", + + "type": { + + "kind": "SCALAR", + + "name": "Cursor", + + "ofType": null + + } + + }, + + { + + "name": "offset", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "filter", + + "type": { + + "kind": "INPUT_OBJECT", + + "name": "AccountFilter", + + "ofType": null + + } + + }, + + { + + "name": "orderBy", + + "type": { + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null + + } + + } + + } + + ], + + "name": "returnsSetofAccount", + + "type": { + + "kind": "OBJECT", + + "name": "AccountConnection", + + "ofType": null + + }, + + "description": null + + } + + ] + + } + + } + + } + } (1 row) diff --git a/test/expected/function_calls_unsupported.out b/test/expected/function_calls_unsupported.out index 97c79334..3be4d720 100644 --- a/test/expected/function_calls_unsupported.out +++ b/test/expected/function_calls_unsupported.out @@ -308,90 +308,107 @@ begin; } } } $$)); - 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": "offset", + - "type": { + - "name": "Int" + - } + - }, + - { + - "name": "filter", + - "type": { + - "name": "AccountFilter" + - } + - }, + - { + - "name": "orderBy", + - "type": { + - "name": null + - } + - } + - ], + - "name": "accountCollection", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "AccountConnection" + - } + - }, + - "description": "A pagable collection of type `Account`"+ - }, + - { + - "args": [ + - { + - "name": "nodeId", + - "type": { + - "name": null + - } + - } + - ], + - "name": "node", + - "type": { + - "kind": "INTERFACE", + - "name": "Node", + - "ofType": null + - }, + - "description": "Retrieve a record by its `ID`" + - } + - ] + - } + - } + - } + + jsonb_pretty +------------------------------------------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "queryType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "name": null + + } + + } + + ], + + "name": "accountByPk", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + }, + + "description": "Retrieve a record of type `Account` by its primary key"+ + }, + + { + + "args": [ + + { + + "name": "first", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "last", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "before", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "after", + + "type": { + + "name": "Cursor" + + } + + }, + + { + + "name": "offset", + + "type": { + + "name": "Int" + + } + + }, + + { + + "name": "filter", + + "type": { + + "name": "AccountFilter" + + } + + }, + + { + + "name": "orderBy", + + "type": { + + "name": null + + } + + } + + ], + + "name": "accountCollection", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "AccountConnection" + + } + + }, + + "description": "A pagable collection of type `Account`" + + }, + + { + + "args": [ + + { + + "name": "nodeId", + + "type": { + + "name": null + + } + + } + + ], + + "name": "node", + + "type": { + + "kind": "INTERFACE", + + "name": "Node", + + "ofType": null + + }, + + "description": "Retrieve a record by its `ID`" + + } + + ] + + } + + } + + } + } (1 row) diff --git a/test/expected/function_return_row_is_selectable.out b/test/expected/function_return_row_is_selectable.out index b803fe3f..3b35c5c6 100644 --- a/test/expected/function_return_row_is_selectable.out +++ b/test/expected/function_return_row_is_selectable.out @@ -56,6 +56,9 @@ begin; "__schema": { + "queryType": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + diff --git a/test/expected/function_return_view_has_pkey.out b/test/expected/function_return_view_has_pkey.out index 24114f50..438f37ee 100644 --- a/test/expected/function_return_view_has_pkey.out +++ b/test/expected/function_return_view_has_pkey.out @@ -102,6 +102,9 @@ begin; "__schema": { + "queryType": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + diff --git a/test/expected/permissions_table_level.out b/test/expected/permissions_table_level.out index d0c35295..4ca722b2 100644 --- a/test/expected/permissions_table_level.out +++ b/test/expected/permissions_table_level.out @@ -21,6 +21,9 @@ begin; "data": { + "__type": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + @@ -96,6 +99,9 @@ begin; "data": { + "__type": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + @@ -138,6 +144,9 @@ begin; "data": { + "__type": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + @@ -180,6 +189,9 @@ begin; "data": { + "__type": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + diff --git a/test/expected/primary_key_queries.out b/test/expected/primary_key_queries.out new file mode 100644 index 00000000..a1553a2b --- /dev/null +++ b/test/expected/primary_key_queries.out @@ -0,0 +1,1189 @@ +begin; + savepoint a; + -- Set up test tables with different primary key configurations + -- Table with single column integer primary key + create table person( + id int primary key, + name text, + email text + ); + insert into public.person(id, name, email) + values + (1, 'Alice', 'alice@example.com'), + (2, 'Bob', 'bob@example.com'), + (3, 'Charlie', null); + -- Table with multi-column primary key + create table item( + item_id int, + product_id int, + quantity int, + price numeric(10,2), + primary key(item_id, product_id) + ); + insert into item(item_id, product_id, quantity, price) + values + (1, 101, 2, 10.99), + (1, 102, 1, 24.99), + (2, 101, 3, 10.99), + (3, 103, 5, 5.99); + -- Table with text primary key (instead of UUID) + create table document( + id text primary key, + title text, + content text + ); + insert into document(id, title, content) + values + ('doc-1', 'Document 1', 'Content 1'), + ('doc-2', 'Document 2', 'Content 2'); + savepoint b; + -- Test 1: Query a person by primary key (single integer column) + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 1) { + id + name + email + } + } + $$) + ); + jsonb_pretty +------------------------------------------ + { + + "data": { + + "personByPk": { + + "id": 1, + + "name": "Alice", + + "email": "alice@example.com"+ + } + + } + + } +(1 row) + + -- Test 2: Query a person by primary key with relationship + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 2) { + id + name + email + nodeId + } + } + $$) + ); + jsonb_pretty +---------------------------------------------------------- + { + + "data": { + + "personByPk": { + + "id": 2, + + "name": "Bob", + + "email": "bob@example.com", + + "nodeId": "WyJwdWJsaWMiLCAicGVyc29uIiwgMl0="+ + } + + } + + } +(1 row) + + -- Test 3: Query a non-existent person by primary key + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 999) { + id + name + } + } + $$) + ); + jsonb_pretty +---------------------------- + { + + "data": { + + "personByPk": null+ + } + + } +(1 row) + + -- Test 4: Query with multi-column primary key + select jsonb_pretty( + graphql.resolve($$ + { + itemByPk(itemId: 1, productId: 102) { + itemId + productId + quantity + price + } + } + $$) + ); + jsonb_pretty +------------------------------- + { + + "data": { + + "itemByPk": { + + "price": "24.99",+ + "itemId": 1, + + "quantity": 1, + + "productId": 102 + + } + + } + + } +(1 row) + + -- Test 5: Query with multi-column primary key, one column value is incorrect + select jsonb_pretty( + graphql.resolve($$ + { + itemByPk(itemId: 1, productId: 999) { + itemId + productId + quantity + price + } + } + $$) + ); + jsonb_pretty +-------------------------- + { + + "data": { + + "itemByPk": null+ + } + + } +(1 row) + + -- Test 6: Query with text primary key + select jsonb_pretty( + graphql.resolve($$ + { + documentByPk(id: "doc-1") { + id + title + content + } + } + $$) + ); + jsonb_pretty +------------------------------------ + { + + "data": { + + "documentByPk": { + + "id": "doc-1", + + "title": "Document 1",+ + "content": "Content 1"+ + } + + } + + } +(1 row) + + -- Test 7: Using variables with primary key queries + select jsonb_pretty( + graphql.resolve($$ + query GetPerson($personId: Int!) { + personByPk(id: $personId) { + id + name + email + } + } + $$, '{"personId": 3}') + ); + jsonb_pretty +-------------------------------- + { + + "data": { + + "personByPk": { + + "id": 3, + + "name": "Charlie",+ + "email": null + + } + + } + + } +(1 row) + + -- Test 8: Using variables with multi-column primary key queries + select jsonb_pretty( + graphql.resolve($$ + query GetItem($itemId: Int!, $productId: Int!) { + itemByPk(itemId: $itemId, productId: $productId) { + itemId + productId + quantity + price + } + } + $$, '{"itemId": 2, "productId": 101}') + ); + jsonb_pretty +------------------------------- + { + + "data": { + + "itemByPk": { + + "price": "10.99",+ + "itemId": 2, + + "quantity": 3, + + "productId": 101 + + } + + } + + } +(1 row) + + -- Test 9: Error case - missing required primary key column + select jsonb_pretty( + graphql.resolve($$ + { + itemByPk(itemId: 1) { + itemId + productId + } + } + $$) + ); + jsonb_pretty +-------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Missing primary key column(s): product_id"+ + } + + ] + + } +(1 row) + + -- Test 10: Using fragments with primary key queries + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 1) { + ...PersonFields + } + } + + fragment PersonFields on Person { + id + name + email + } + $$) + ); + jsonb_pretty +------------------------------------------ + { + + "data": { + + "personByPk": { + + "id": 1, + + "name": "Alice", + + "email": "alice@example.com"+ + } + + } + + } +(1 row) + + -- Test 11: Query with null values in results + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 3) { + id + name + email + } + } + $$) + ); + jsonb_pretty +-------------------------------- + { + + "data": { + + "personByPk": { + + "id": 3, + + "name": "Charlie",+ + "email": null + + } + + } + + } +(1 row) + + rollback to savepoint b; + -- Set up tables with relationships for connection and function tests + create table author( + id int primary key, + name text not null + ); + create table book( + id int primary key, + title text not null, + author_id int references author(id) + ); + insert into author(id, name) + values + (1, 'Jane Austen'), + (2, 'Charles Dickens'), + (3, 'Mark Twain'); + insert into book(id, title, author_id) + values + (1, 'Pride and Prejudice', 1), + (2, 'Sense and Sensibility', 1), + (3, 'Emma', 1), + (4, 'Oliver Twist', 2), + (5, 'Great Expectations', 2), + (6, 'Adventures of Tom Sawyer', 3); + -- Create a function that takes the author type as its first argument + create function public._book_count(rec public.author) + returns int + stable + language sql + as $$ + select count(*)::int from book where author_id = rec.id + $$; + -- Create a function that returns text + create function public._formatted_name(rec public.author) + returns text + immutable + language sql + as $$ + select 'Author: ' || rec.name + $$; + -- Test 12: nodeByPk with a nested function (scalar return) + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCount + formattedName + } + } + $$) + ); + jsonb_pretty +---------------------------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "bookCount": 3, + + "formattedName": "Author: Jane Austen"+ + } + + } + + } +(1 row) + + -- Test 13: nodeByPk with a connection (one-to-many relationship) + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "bookCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice" + + } + + }, + + { + + "node": { + + "id": 2, + + "title": "Sense and Sensibility"+ + } + + }, + + { + + "node": { + + "id": 3, + + "title": "Emma" + + } + + } + + ] + + } + + } + + } + + } +(1 row) + + -- Test 14: nodeByPk with connection and pagination + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection(first: 2) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "bookCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice" + + } + + }, + + { + + "node": { + + "id": 2, + + "title": "Sense and Sensibility"+ + } + + } + + ], + + "pageInfo": { + + "hasNextPage": true, + + "hasPreviousPage": false + + } + + } + + } + + } + + } +(1 row) + + -- Test 15: nodeByPk with connection filter + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection(filter: {title: {like: "%Pride%"}}) { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------ + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "bookCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice"+ + } + + } + + ] + + } + + } + + } + + } +(1 row) + + -- Test 16: nodeByPk with connection ordering + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection(orderBy: [{title: DescNullsLast}]) { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "bookCollection": { + + "edges": [ + + { + + "node": { + + "id": 2, + + "title": "Sense and Sensibility"+ + } + + }, + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice" + + } + + }, + + { + + "node": { + + "id": 3, + + "title": "Emma" + + } + + } + + ] + + } + + } + + } + + } +(1 row) + + -- Test 17: nodeByPk with both function and connection + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 2) { + id + name + bookCount + formattedName + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +----------------------------------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 2, + + "name": "Charles Dickens", + + "bookCount": 2, + + "formattedName": "Author: Charles Dickens", + + "bookCollection": { + + "edges": [ + + { + + "node": { + + "id": 4, + + "title": "Oliver Twist" + + } + + }, + + { + + "node": { + + "id": 5, + + "title": "Great Expectations"+ + } + + } + + ] + + } + + } + + } + + } +(1 row) + + -- Test 18: nodeByPk with nested relationship (book -> author) + select jsonb_pretty( + graphql.resolve($$ + { + bookByPk(id: 1) { + id + title + author { + id + name + bookCount + } + } + } + $$) + ); + jsonb_pretty +--------------------------------------------- + { + + "data": { + + "bookByPk": { + + "id": 1, + + "title": "Pride and Prejudice",+ + "author": { + + "id": 1, + + "name": "Jane Austen", + + "bookCount": 3 + + } + + } + + } + + } +(1 row) + + -- Test 19: nodeByPk with deeply nested connection + select jsonb_pretty( + graphql.resolve($$ + { + bookByPk(id: 4) { + id + title + author { + id + name + bookCollection { + edges { + node { + id + title + } + } + } + } + } + } + $$) + ); + jsonb_pretty +--------------------------------------------------------------- + { + + "data": { + + "bookByPk": { + + "id": 4, + + "title": "Oliver Twist", + + "author": { + + "id": 2, + + "name": "Charles Dickens", + + "bookCollection": { + + "edges": [ + + { + + "node": { + + "id": 4, + + "title": "Oliver Twist" + + } + + }, + + { + + "node": { + + "id": 5, + + "title": "Great Expectations"+ + } + + } + + ] + + } + + } + + } + + } + + } +(1 row) + + -- Test 20: nodeByPk returning null with connection (non-existent author) + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 999) { + id + name + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +---------------------------- + { + + "data": { + + "authorByPk": null+ + } + + } +(1 row) + + -- Test 21: nodeByPk with empty connection (author with no books) + insert into author(id, name) values (4, 'New Author'); + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 4) { + id + name + bookCount + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +----------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 4, + + "name": "New Author",+ + "bookCount": 0, + + "bookCollection": { + + "edges": [ + + ] + + } + + } + + } + + } +(1 row) + + -- Test 22: nodeByPk with function returning array type + create function public._book_titles(rec public.author) + returns text[] + stable + language sql + as $$ + select array_agg(title) from book where author_id = rec.id + $$; + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookTitles + } + } + $$) + ); + jsonb_pretty +------------------------------------------ + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "bookTitles": [ + + "Pride and Prejudice", + + "Sense and Sensibility",+ + "Emma" + + ] + + } + + } + + } +(1 row) + + -- Test 23: nodeByPk with function returning node type (single related record) + create function public._latest_book(rec public.author) + returns public.book + stable + language sql + as $$ + select * from book where author_id = rec.id order by id desc limit 1 + $$; + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + latestBook { + id + title + } + } + } + $$) + ); + jsonb_pretty +------------------------------------ + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen",+ + "latestBook": { + + "id": 3, + + "title": "Emma" + + } + + } + + } + + } +(1 row) + + -- Test 24: nodeByPk with function returning node type, nested selection + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 2) { + id + name + latestBook { + id + title + author { + id + name + } + } + } + } + $$) + ); + jsonb_pretty +------------------------------------------------ + { + + "data": { + + "authorByPk": { + + "id": 2, + + "name": "Charles Dickens", + + "latestBook": { + + "id": 5, + + "title": "Great Expectations",+ + "author": { + + "id": 2, + + "name": "Charles Dickens" + + } + + } + + } + + } + + } +(1 row) + + -- Test 25: nodeByPk with function returning connection type (setof) + create function public._popular_books(rec public.author) + returns setof public.book + stable + language sql + as $$ + select * from book where author_id = rec.id and id <= 2 + $$; + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + popularBooks { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +-------------------------------------------------------------- + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "popularBooks": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice" + + } + + }, + + { + + "node": { + + "id": 2, + + "title": "Sense and Sensibility"+ + } + + } + + ] + + } + + } + + } + + } +(1 row) + + -- Test 26: nodeByPk with function returning connection type with pagination + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + popularBooks(first: 1) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------ + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "popularBooks": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice"+ + } + + } + + ], + + "pageInfo": { + + "hasNextPage": true, + + "hasPreviousPage": false + + } + + } + + } + + } + + } +(1 row) + + -- Test 27: nodeByPk with function returning connection type with filter + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + popularBooks(filter: {id: {eq: 1}}) { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------ + { + + "data": { + + "authorByPk": { + + "id": 1, + + "name": "Jane Austen", + + "popularBooks": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "title": "Pride and Prejudice"+ + } + + } + + ] + + } + + } + + } + + } +(1 row) + + -- Test 28: Aliases work correctly with ByPk fields + select jsonb_pretty( + graphql.resolve($$ + { + firstAuthor: authorByPk(id: 1) { + id + name + } + secondAuthor: authorByPk(id: 2) { + id + name + } + } + $$) + ); + jsonb_pretty +--------------------------------------- + { + + "data": { + + "firstAuthor": { + + "id": 1, + + "name": "Jane Austen" + + }, + + "secondAuthor": { + + "id": 2, + + "name": "Charles Dickens"+ + } + + } + + } +(1 row) + + -- Test 29: Nested aliases within ByPk queries + select jsonb_pretty( + graphql.resolve($$ + { + myAuthor: authorByPk(id: 1) { + authorId: id + authorName: name + books: bookCollection { + edges { + node { + bookId: id + bookTitle: title + } + } + } + } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------------ + { + + "data": { + + "myAuthor": { + + "books": { + + "edges": [ + + { + + "node": { + + "bookId": 1, + + "bookTitle": "Pride and Prejudice" + + } + + }, + + { + + "node": { + + "bookId": 2, + + "bookTitle": "Sense and Sensibility"+ + } + + }, + + { + + "node": { + + "bookId": 3, + + "bookTitle": "Emma" + + } + + } + + ] + + }, + + "authorId": 1, + + "authorName": "Jane Austen" + + } + + } + + } +(1 row) + + -- Test 30: ByPk fields are only exposed for tables with supported primary key types + rollback to savepoint a; + -- Create tables with various primary key configurations + create table no_pk_table(value int); -- No primary key + create table float_pk_table(id float primary key); -- Unsupported: float + create table bool_pk_table(id boolean primary key); -- Unsupported: boolean + create table bytea_pk_table(id bytea primary key); -- Unsupported: bytea + create table smallint_pk_table(id smallint primary key); -- Supported: smallint + create table bigint_pk_table(id bigint primary key); -- Supported: bigint + -- Query the schema to verify which tables have ByPk fields + -- Expected ByPk fields: smallintPkTableByPk, bigintPkTableByPk + -- Should NOT have: noPkTableByPk, floatPkTableByPk, boolPkTableByPk, byteaPkTableByPk + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Query") { + fields { + name + } + } + } + $$) + ); + jsonb_pretty +--------------------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "bigintPkTableByPk" + + }, + + { + + "name": "bigintPkTableCollection" + + }, + + { + + "name": "boolPkTableCollection" + + }, + + { + + "name": "byteaPkTableCollection" + + }, + + { + + "name": "floatPkTableCollection" + + }, + + { + + "name": "node" + + }, + + { + + "name": "smallintPkTableByPk" + + }, + + { + + "name": "smallintPkTableCollection"+ + } + + ] + + } + + } + + } +(1 row) + +rollback; diff --git a/test/expected/resolve___typename.out b/test/expected/resolve___typename.out index 9e4742d6..d411602a 100644 --- a/test/expected/resolve___typename.out +++ b/test/expected/resolve___typename.out @@ -154,4 +154,130 @@ begin; } (1 row) + -- Reset data for byPk tests + insert into public.account(id, parent_id) + values + (1, 1), + (2, 1); + -- Test __typename with byPk query + select jsonb_pretty( + graphql.resolve($$ + query { + accountByPk(id: 1) { + __typename + id + parent { + __typename + id + } + } + } + $$) + ); + jsonb_pretty +----------------------------------------- + { + + "data": { + + "accountByPk": { + + "id": 1, + + "parent": { + + "id": 1, + + "__typename": "Account"+ + }, + + "__typename": "Account" + + } + + } + + } +(1 row) + + -- Test __typename with multi-column primary key + create table order_item( + order_id int, + item_id int, + quantity int, + primary key (order_id, item_id) + ); + insert into public.order_item(order_id, item_id, quantity) + values + (100, 1, 5), + (100, 2, 3); + select jsonb_pretty( + graphql.resolve($$ + query { + orderItemByPk(orderId: 100, itemId: 1) { + __typename + orderId + itemId + quantity + } + } + $$) + ); + jsonb_pretty +--------------------------------------- + { + + "data": { + + "orderItemByPk": { + + "itemId": 1, + + "orderId": 100, + + "quantity": 5, + + "__typename": "OrderItem"+ + } + + } + + } +(1 row) + + -- Test __typename with non-int primary key + create table product( + sku text primary key, + name text + ); + insert into public.product(sku, name) + values + ('PROD-001', 'Widget'), + ('PROD-002', 'Gadget'); + select jsonb_pretty( + graphql.resolve($$ + query { + productByPk(sku: "PROD-001") { + __typename + sku + name + } + } + $$) + ); + jsonb_pretty +------------------------------------- + { + + "data": { + + "productByPk": { + + "sku": "PROD-001", + + "name": "Widget", + + "__typename": "Product"+ + } + + } + + } +(1 row) + + -- Test __typename with byPk query returning null + select jsonb_pretty( + graphql.resolve($$ + query { + accountByPk(id: 999) { + __typename + id + } + } + $$) + ); + jsonb_pretty +----------------------------- + { + + "data": { + + "accountByPk": null+ + } + + } +(1 row) + rollback; diff --git a/test/expected/resolve_error_node_no_field.out b/test/expected/resolve_error_node_no_field.out index 8c334c9c..c3620a2c 100644 --- a/test/expected/resolve_error_node_no_field.out +++ b/test/expected/resolve_error_node_no_field.out @@ -22,4 +22,18 @@ begin; {"data": null, "errors": [{"message": "Unknown field 'dneField' on type 'Account'"}]} (1 row) + -- Test unknown field on byPk query + select graphql.resolve($$ + { + accountByPk(id: 1) { + id + nonExistentField + } + } + $$); + resolve +----------------------------------------------------------------------------------------------- + {"data": null, "errors": [{"message": "Unknown field \"nonExistentField\" on type Account"}]} +(1 row) + rollback; diff --git a/test/expected/resolve_graphiql_schema.out b/test/expected/resolve_graphiql_schema.out index cac1414c..ccd5fcaa 100644 --- a/test/expected/resolve_graphiql_schema.out +++ b/test/expected/resolve_graphiql_schema.out @@ -5745,6 +5745,37 @@ begin; "kind": "OBJECT", + "name": "Query", + "fields": [ + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + } + + }, + + "description": "The record's `id` value", + + "defaultValue": null + + } + + ], + + "name": "accountByPk", + + "type": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + }, + + "description": "Retrieve a record of type `Account` by its primary key", + + "isDeprecated": false, + + "deprecationReason": null + + }, + { + "args": [ + { + @@ -5840,6 +5871,37 @@ begin; "isDeprecated": false, + "deprecationReason": null + }, + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + } + + }, + + "description": "The record's `id` value", + + "defaultValue": null + + } + + ], + + "name": "blogByPk", + + "type": { + + "kind": "OBJECT", + + "name": "Blog", + + "ofType": null + + }, + + "description": "Retrieve a record of type `Blog` by its primary key", + + "isDeprecated": false, + + "deprecationReason": null + + }, + { + "args": [ + { + @@ -5935,6 +5997,37 @@ begin; "isDeprecated": false, + "deprecationReason": null + }, + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "UUID", + + "ofType": null + + } + + } + + }, + + "description": "The record's `id` value", + + "defaultValue": null + + } + + ], + + "name": "blogPostByPk", + + "type": { + + "kind": "OBJECT", + + "name": "BlogPost", + + "ofType": null + + }, + + "description": "Retrieve a record of type `BlogPost` by its primary key", + + "isDeprecated": false, + + "deprecationReason": null + + }, + { + "args": [ + { + @@ -6057,6 +6150,33 @@ begin; "isDeprecated": false, + "deprecationReason": null + }, + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + "description": "The record's `id` value", + + "defaultValue": null + + } + + ], + + "name": "personByPk", + + "type": { + + "kind": "OBJECT", + + "name": "Person", + + "ofType": null + + }, + + "description": "Retrieve a record of type `Person` by its primary key", + + "isDeprecated": false, + + "deprecationReason": null + + }, + { + "args": [ + { + diff --git a/test/expected/views_integration.out b/test/expected/views_integration.out index f570e2c3..edb98187 100644 --- a/test/expected/views_integration.out +++ b/test/expected/views_integration.out @@ -31,9 +31,15 @@ begin; "data": { + "__type": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + + { + + "name": "blogByPk" + + }, + { + "name": "blogCollection" + }, + @@ -69,15 +75,24 @@ begin; "data": { + "__type": { + "fields": [ + + { + + "name": "accountByPk" + + }, + { + "name": "accountCollection"+ }, + + { + + "name": "blogByPk" + + }, + { + "name": "blogCollection" + }, + { + "name": "node" + }, + + { + + "name": "personByPk" + + }, + { + "name": "personCollection" + } + diff --git a/test/sql/primary_key_queries.sql b/test/sql/primary_key_queries.sql new file mode 100644 index 00000000..2e1fccca --- /dev/null +++ b/test/sql/primary_key_queries.sql @@ -0,0 +1,648 @@ +begin; + savepoint a; + + -- Set up test tables with different primary key configurations + + -- Table with single column integer primary key + create table person( + id int primary key, + name text, + email text + ); + + insert into public.person(id, name, email) + values + (1, 'Alice', 'alice@example.com'), + (2, 'Bob', 'bob@example.com'), + (3, 'Charlie', null); + + -- Table with multi-column primary key + create table item( + item_id int, + product_id int, + quantity int, + price numeric(10,2), + primary key(item_id, product_id) + ); + + insert into item(item_id, product_id, quantity, price) + values + (1, 101, 2, 10.99), + (1, 102, 1, 24.99), + (2, 101, 3, 10.99), + (3, 103, 5, 5.99); + + -- Table with text primary key (instead of UUID) + create table document( + id text primary key, + title text, + content text + ); + + insert into document(id, title, content) + values + ('doc-1', 'Document 1', 'Content 1'), + ('doc-2', 'Document 2', 'Content 2'); + + savepoint b; + + -- Test 1: Query a person by primary key (single integer column) + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 1) { + id + name + email + } + } + $$) + ); + + -- Test 2: Query a person by primary key with relationship + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 2) { + id + name + email + nodeId + } + } + $$) + ); + + -- Test 3: Query a non-existent person by primary key + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 999) { + id + name + } + } + $$) + ); + + -- Test 4: Query with multi-column primary key + select jsonb_pretty( + graphql.resolve($$ + { + itemByPk(itemId: 1, productId: 102) { + itemId + productId + quantity + price + } + } + $$) + ); + + -- Test 5: Query with multi-column primary key, one column value is incorrect + select jsonb_pretty( + graphql.resolve($$ + { + itemByPk(itemId: 1, productId: 999) { + itemId + productId + quantity + price + } + } + $$) + ); + + -- Test 6: Query with text primary key + select jsonb_pretty( + graphql.resolve($$ + { + documentByPk(id: "doc-1") { + id + title + content + } + } + $$) + ); + + -- Test 7: Using variables with primary key queries + select jsonb_pretty( + graphql.resolve($$ + query GetPerson($personId: Int!) { + personByPk(id: $personId) { + id + name + email + } + } + $$, '{"personId": 3}') + ); + + -- Test 8: Using variables with multi-column primary key queries + select jsonb_pretty( + graphql.resolve($$ + query GetItem($itemId: Int!, $productId: Int!) { + itemByPk(itemId: $itemId, productId: $productId) { + itemId + productId + quantity + price + } + } + $$, '{"itemId": 2, "productId": 101}') + ); + + -- Test 9: Error case - missing required primary key column + select jsonb_pretty( + graphql.resolve($$ + { + itemByPk(itemId: 1) { + itemId + productId + } + } + $$) + ); + + -- Test 10: Using fragments with primary key queries + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 1) { + ...PersonFields + } + } + + fragment PersonFields on Person { + id + name + email + } + $$) + ); + + -- Test 11: Query with null values in results + select jsonb_pretty( + graphql.resolve($$ + { + personByPk(id: 3) { + id + name + email + } + } + $$) + ); + + rollback to savepoint b; + + -- Set up tables with relationships for connection and function tests + create table author( + id int primary key, + name text not null + ); + + create table book( + id int primary key, + title text not null, + author_id int references author(id) + ); + + insert into author(id, name) + values + (1, 'Jane Austen'), + (2, 'Charles Dickens'), + (3, 'Mark Twain'); + + insert into book(id, title, author_id) + values + (1, 'Pride and Prejudice', 1), + (2, 'Sense and Sensibility', 1), + (3, 'Emma', 1), + (4, 'Oliver Twist', 2), + (5, 'Great Expectations', 2), + (6, 'Adventures of Tom Sawyer', 3); + + -- Create a function that takes the author type as its first argument + create function public._book_count(rec public.author) + returns int + stable + language sql + as $$ + select count(*)::int from book where author_id = rec.id + $$; + + -- Create a function that returns text + create function public._formatted_name(rec public.author) + returns text + immutable + language sql + as $$ + select 'Author: ' || rec.name + $$; + + -- Test 12: nodeByPk with a nested function (scalar return) + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCount + formattedName + } + } + $$) + ); + + -- Test 13: nodeByPk with a connection (one-to-many relationship) + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 14: nodeByPk with connection and pagination + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection(first: 2) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 15: nodeByPk with connection filter + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection(filter: {title: {like: "%Pride%"}}) { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 16: nodeByPk with connection ordering + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookCollection(orderBy: [{title: DescNullsLast}]) { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 17: nodeByPk with both function and connection + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 2) { + id + name + bookCount + formattedName + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 18: nodeByPk with nested relationship (book -> author) + select jsonb_pretty( + graphql.resolve($$ + { + bookByPk(id: 1) { + id + title + author { + id + name + bookCount + } + } + } + $$) + ); + + -- Test 19: nodeByPk with deeply nested connection + select jsonb_pretty( + graphql.resolve($$ + { + bookByPk(id: 4) { + id + title + author { + id + name + bookCollection { + edges { + node { + id + title + } + } + } + } + } + } + $$) + ); + + -- Test 20: nodeByPk returning null with connection (non-existent author) + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 999) { + id + name + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 21: nodeByPk with empty connection (author with no books) + insert into author(id, name) values (4, 'New Author'); + + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 4) { + id + name + bookCount + bookCollection { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 22: nodeByPk with function returning array type + create function public._book_titles(rec public.author) + returns text[] + stable + language sql + as $$ + select array_agg(title) from book where author_id = rec.id + $$; + + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + bookTitles + } + } + $$) + ); + + -- Test 23: nodeByPk with function returning node type (single related record) + create function public._latest_book(rec public.author) + returns public.book + stable + language sql + as $$ + select * from book where author_id = rec.id order by id desc limit 1 + $$; + + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + latestBook { + id + title + } + } + } + $$) + ); + + -- Test 24: nodeByPk with function returning node type, nested selection + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 2) { + id + name + latestBook { + id + title + author { + id + name + } + } + } + } + $$) + ); + + -- Test 25: nodeByPk with function returning connection type (setof) + create function public._popular_books(rec public.author) + returns setof public.book + stable + language sql + as $$ + select * from book where author_id = rec.id and id <= 2 + $$; + + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + popularBooks { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 26: nodeByPk with function returning connection type with pagination + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + popularBooks(first: 1) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 27: nodeByPk with function returning connection type with filter + select jsonb_pretty( + graphql.resolve($$ + { + authorByPk(id: 1) { + id + name + popularBooks(filter: {id: {eq: 1}}) { + edges { + node { + id + title + } + } + } + } + } + $$) + ); + + -- Test 28: Aliases work correctly with ByPk fields + select jsonb_pretty( + graphql.resolve($$ + { + firstAuthor: authorByPk(id: 1) { + id + name + } + secondAuthor: authorByPk(id: 2) { + id + name + } + } + $$) + ); + + -- Test 29: Nested aliases within ByPk queries + select jsonb_pretty( + graphql.resolve($$ + { + myAuthor: authorByPk(id: 1) { + authorId: id + authorName: name + books: bookCollection { + edges { + node { + bookId: id + bookTitle: title + } + } + } + } + } + $$) + ); + + -- Test 30: ByPk fields are only exposed for tables with supported primary key types + rollback to savepoint a; + + -- Create tables with various primary key configurations + create table no_pk_table(value int); -- No primary key + create table float_pk_table(id float primary key); -- Unsupported: float + create table bool_pk_table(id boolean primary key); -- Unsupported: boolean + create table bytea_pk_table(id bytea primary key); -- Unsupported: bytea + create table smallint_pk_table(id smallint primary key); -- Supported: smallint + create table bigint_pk_table(id bigint primary key); -- Supported: bigint + + -- Query the schema to verify which tables have ByPk fields + -- Expected ByPk fields: smallintPkTableByPk, bigintPkTableByPk + -- Should NOT have: noPkTableByPk, floatPkTableByPk, boolPkTableByPk, byteaPkTableByPk + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Query") { + fields { + name + } + } + } + $$) + ); + +rollback; diff --git a/test/sql/resolve___typename.sql b/test/sql/resolve___typename.sql index 8a5548a1..16730544 100644 --- a/test/sql/resolve___typename.sql +++ b/test/sql/resolve___typename.sql @@ -79,4 +79,87 @@ begin; $$) ); + -- Reset data for byPk tests + insert into public.account(id, parent_id) + values + (1, 1), + (2, 1); + + -- Test __typename with byPk query + select jsonb_pretty( + graphql.resolve($$ + query { + accountByPk(id: 1) { + __typename + id + parent { + __typename + id + } + } + } + $$) + ); + + -- Test __typename with multi-column primary key + create table order_item( + order_id int, + item_id int, + quantity int, + primary key (order_id, item_id) + ); + + insert into public.order_item(order_id, item_id, quantity) + values + (100, 1, 5), + (100, 2, 3); + + select jsonb_pretty( + graphql.resolve($$ + query { + orderItemByPk(orderId: 100, itemId: 1) { + __typename + orderId + itemId + quantity + } + } + $$) + ); + + -- Test __typename with non-int primary key + create table product( + sku text primary key, + name text + ); + + insert into public.product(sku, name) + values + ('PROD-001', 'Widget'), + ('PROD-002', 'Gadget'); + + select jsonb_pretty( + graphql.resolve($$ + query { + productByPk(sku: "PROD-001") { + __typename + sku + name + } + } + $$) + ); + + -- Test __typename with byPk query returning null + select jsonb_pretty( + graphql.resolve($$ + query { + accountByPk(id: 999) { + __typename + id + } + } + $$) + ); + rollback; diff --git a/test/sql/resolve_error_node_no_field.sql b/test/sql/resolve_error_node_no_field.sql index eaf81884..cdad7730 100644 --- a/test/sql/resolve_error_node_no_field.sql +++ b/test/sql/resolve_error_node_no_field.sql @@ -21,4 +21,14 @@ begin; } $$); + -- Test unknown field on byPk query + select graphql.resolve($$ + { + accountByPk(id: 1) { + id + nonExistentField + } + } + $$); + rollback;