diff --git a/src/builder.rs b/src/builder.rs index 67f5dd41..63f222d3 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -991,6 +991,19 @@ pub struct NodeBuilder { pub selections: Vec, } +#[derive(Clone, Debug)] +pub struct NodeByPkBuilder { + // args - map of column name to value + pub pk_values: HashMap, + + pub _alias: String, + + // metadata + pub table: Arc, + + pub selections: Vec, +} + #[derive(Clone, Debug)] pub enum NodeSelection { Connection(ConnectionBuilder), @@ -1009,6 +1022,16 @@ pub struct NodeIdInstance { pub values: Vec, } +impl NodeIdInstance { + pub fn validate(&self, table: &Table) -> Result<(), String> { + // Validate that nodeId belongs to the table being queried + if self.schema_name != table.schema || self.table_name != table.name { + return Err("nodeId belongs to a different collection".to_string()); + } + Ok(()) + } +} + #[derive(Clone, Debug)] pub struct NodeIdBuilder { pub alias: String, @@ -2028,6 +2051,188 @@ 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>, +) -> Result +where + T: Text<'a> + Eq + AsRef + Clone, + T::Value: Hash, +{ + let type_ = field.type_().unmodified_type(); + let alias = alias_or_name(query_field); + + match type_ { + __Type::Node(xtype) => { + let type_name = xtype + .name() + .ok_or("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("Table has no primary key".to_string())?; + + // 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() { + return Err("All primary key columns must be provided".to_string()); + } + + 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(format!( + "Unknown field '{}' on type '{}'", + 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( + "invalid return type from function".to_string() + ) + } + }; + 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(format!( + "unexpected field type on node {}", + f.name() + )); + } + }, + }, + }; + builder_fields.push(node_selection); + } + } + } + + Ok(NodeByPkBuilder { + pk_values, + _alias: alias, + table: Arc::clone(&xtype.table), + selections: builder_fields, + }) + } + _ => Err("cannot build query for non-node type".to_string()), + } +} + // Introspection #[allow(clippy::large_enum_variant)] diff --git a/src/graphql.rs b/src/graphql.rs index 06147774..3f8a45dc 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_else(|_| __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 9a87e77e..d9d06b34 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -245,6 +245,58 @@ where Err(msg) => res_errors.push(ErrorMessage { message: msg }), } } + __Type::Node(_) => { + // Determine if this is a primary key query field + let has_pk_args = !field_def.args().is_empty() + && field_def.args().iter().all(|arg| { + // All PK field args have a SQL Column type + arg.sql_type.is_some() + && matches!( + arg.sql_type.as_ref().unwrap(), + NodeSQLType::Column(_) + ) + }); + + if has_pk_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 }), + }, + Err(msg) => res_errors.push(ErrorMessage { message: msg }), + } + } else { + // Regular node access + let node_builder = to_node_builder( + field_def, + selection, + &fragment_definitions, + variables, + &[], + variable_definitions, + ); + + match node_builder { + Ok(builder) => match builder.execute() { + Ok(d) => { + res_data[alias_or_name(selection)] = d; + } + Err(msg) => res_errors.push(ErrorMessage { message: msg }), + }, + Err(msg) => res_errors.push(ErrorMessage { message: msg }), + } + } + } __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 0ef1bb8d..d6ed1b87 100644 --- a/src/sql_types.rs +++ b/src/sql_types.rs @@ -576,6 +576,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) } @@ -597,6 +609,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 062f3a80..2885cb0a 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1487,12 +1487,84 @@ 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} + ) + " + )) + } +} + +impl NodeByPkBuilder { + pub fn to_sql( + &self, + block_name: &str, + param_context: &mut ParamContext, + ) -> Result { + let mut field_clauses = vec![]; + for selection in &self.selections { + field_clauses.push(selection.to_sql(block_name, param_context)?); + } + + if field_clauses.is_empty() { + return Ok("'{}'::jsonb".to_string()); + } + + let fields_clause = field_clauses.join(", "); + Ok(format!("jsonb_build_object({fields_clause})")) + } - let node_id_clause = node_id.to_sql("ed_block_name, &self.table, param_context)?; + pub fn to_pk_where_clause( + &self, + block_name: &str, + param_context: &mut ParamContext, + ) -> Result { + 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(|| 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) -> 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 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!( " @@ -1502,7 +1574,7 @@ impl QueryEntrypoint for NodeBuilder { from {quoted_schema}.{quoted_table} as {quoted_block_name} where - {node_id_clause} + {where_clause} ) " )) @@ -1516,19 +1588,41 @@ impl NodeIdInstance { table: &Table, param_context: &mut ParamContext, ) -> Result { - // TODO: abstract this logical check into builder. It is not related to - // transpiling and should not be in this module - if (&self.schema_name, &self.table_name) != (&table.schema, &table.name) { - return Err("nodeId belongs to a different collection".to_string()); + // Validate that nodeId belongs to the table being queried + self.validate(table)?; + + let pkey = table + .primary_key() + .ok_or_else(|| "Found table with no primary key".to_string())?; + + if pkey.column_names.len() != self.values.len() { + return Err(format!( + "Primary key column count mismatch. Expected {}, provided {}", + pkey.column_names.len(), + self.values.len() + )); } - 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 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(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 c31fa2d5..780d0ae6 100644 --- a/test/expected/function_calls.out +++ b/test/expected/function_calls.out @@ -2037,213 +2037,233 @@ 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": "OBJECT" + - }, + - "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" + - }, + - "description": "Retrieve a record by its `ID`" + - }, + - { + - "args": [ + - ], + - "name": "returnsAccount", + - "type": { + - "kind": "OBJECT" + - }, + - "description": null + - }, + - { + - "args": [ + - { + - "name": "idToSearch", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int" + - } + - } + - } + - ], + - "name": "returnsAccountWithId", + - "type": { + - "kind": "OBJECT" + - }, + - "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" + - }, + - "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" + + }, + + "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": "OBJECT" + + }, + + "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" + + }, + + "description": "Retrieve a record by its `ID`" + + }, + + { + + "args": [ + + ], + + "name": "returnsAccount", + + "type": { + + "kind": "OBJECT" + + }, + + "description": null + + }, + + { + + "args": [ + + { + + "name": "idToSearch", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int" + + } + + } + + } + + ], + + "name": "returnsAccountWithId", + + "type": { + + "kind": "OBJECT" + + }, + + "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" + + }, + + "description": null + + } + + ] + + } + + } + + } + } (1 row) diff --git a/test/expected/function_calls_unsupported.out b/test/expected/function_calls_unsupported.out index 3b1a1e9e..39a1c342 100644 --- a/test/expected/function_calls_unsupported.out +++ b/test/expected/function_calls_unsupported.out @@ -303,83 +303,98 @@ 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": "OBJECT" + - }, + - "description": "A pagable collection of type `Account`"+ - }, + - { + - "args": [ + - { + - "name": "nodeId", + - "type": { + - "name": null + - } + - } + - ], + - "name": "node", + - "type": { + - "kind": "INTERFACE" + - }, + - "description": "Retrieve a record by its `ID`" + - } + - ] + - } + - } + - } + + jsonb_pretty +------------------------------------------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "queryType": { + + "fields": [ + + { + + "args": [ + + { + + "name": "id", + + "type": { + + "name": null + + } + + } + + ], + + "name": "accountByPk", + + "type": { + + "kind": "OBJECT" + + }, + + "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": "OBJECT" + + }, + + "description": "A pagable collection of type `Account`" + + }, + + { + + "args": [ + + { + + "name": "nodeId", + + "type": { + + "name": null + + } + + } + + ], + + "name": "node", + + "type": { + + "kind": "INTERFACE" + + }, + + "description": "Retrieve a record by its `ID`" + + } + + ] + + } + + } + + } + } (1 row) 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..b78e27ad --- /dev/null +++ b/test/expected/primary_key_queries.out @@ -0,0 +1,314 @@ +begin; + -- 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 a; + -- 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": "All primary key columns must be provided"+ + } + + ] + + } +(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; diff --git a/test/expected/resolve_graphiql_schema.out b/test/expected/resolve_graphiql_schema.out index 454f9465..fcf88296 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": [ + { + @@ -5836,6 +5867,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": [ + { + @@ -5927,6 +5989,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": [ + { + @@ -6045,6 +6138,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..4e90838e --- /dev/null +++ b/test/sql/primary_key_queries.sql @@ -0,0 +1,196 @@ +begin; + -- 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 a; + + -- 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;