From bc29fd3d1850d2769efd6457f72334a497ce642b Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 12:55:52 -0400 Subject: [PATCH 01/53] Add support for aggregates --- src/builder.rs | 185 +++++++++++++++- src/graphql.rs | 408 +++++++++++++++++++++++++++++++----- src/transpile.rs | 133 ++++++++++-- test/expected/aggregate.out | 8 + test/sql/aggregate.sql | 211 +++++++++++++++++++ 5 files changed, 870 insertions(+), 75 deletions(-) create mode 100644 test/expected/aggregate.out create mode 100644 test/sql/aggregate.sql diff --git a/src/builder.rs b/src/builder.rs index 73800466..01e0e184 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -10,6 +10,22 @@ use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; +#[derive(Clone, Debug)] +pub struct AggregateBuilder { + pub alias: String, + pub selections: Vec, +} + +#[derive(Clone, Debug)] +pub enum AggregateSelection { + Count { alias: String }, + Sum { alias: String, selections: Vec }, + Avg { alias: String, selections: Vec }, + Min { alias: String, selections: Vec }, + Max { alias: String, selections: Vec }, + Typename { alias: String, typename: String }, +} + #[derive(Clone, Debug)] pub struct InsertBuilder { // args @@ -758,6 +774,7 @@ pub struct ConnectionBuilder { //fields pub selections: Vec, + pub aggregate_selection: Option, pub max_rows: u64, } @@ -923,7 +940,6 @@ pub enum PageInfoSelection { #[derive(Clone, Debug)] pub enum ConnectionSelection { - TotalCount { alias: String }, Edge(EdgeBuilder), PageInfo(PageInfoBuilder), Typename { alias: String, typename: String }, @@ -1429,6 +1445,7 @@ where read_argument_order_by(field, query_field, variables, variable_definitions)?; let mut builder_fields: Vec = vec![]; + let mut aggregate_builder: Option = None; let selection_fields = normalize_selection_set( &query_field.selection_set, @@ -1454,20 +1471,37 @@ where fragment_definitions, variables, )?), - - _ => match f.name().as_ref() { - "totalCount" => ConnectionSelection::TotalCount { - alias: alias_or_name(&selection_field), - }, - "__typename" => ConnectionSelection::Typename { + __Type::Aggregate(_) => { + aggregate_builder = Some(to_aggregate_builder( + f, + &selection_field, + fragment_definitions, + variables, + variable_definitions, + )?); + ConnectionSelection::Typename { alias: alias_or_name(&selection_field), typename: xtype.name().expect("connection type should have a name"), - }, - _ => return Err("unexpected field type on connection".to_string()), - }, + } + } + __Type::Scalar(Scalar::String(None)) => { + if selection_field.name.as_ref() == "__typename" { + ConnectionSelection::Typename { + alias: alias_or_name(&selection_field), + typename: xtype.name().expect("connection type should have a name"), + } + } else { + return Err(format!( + "Unsupported field type for connection field {}", + selection_field.name.as_ref() + )) + } + } + _ => return Err(format!("unknown field type on connection: {}", selection_field.name.as_ref())), }), } } + Ok(ConnectionBuilder { alias, source: ConnectionBuilderSource { @@ -1482,6 +1516,7 @@ where filter, order_by, selections: builder_fields, + aggregate_selection: aggregate_builder, max_rows, }) } @@ -1492,6 +1527,136 @@ where } } +fn to_aggregate_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 __Type::Aggregate(ref _agg_type) = type_ else { + return Err("Internal Error: Expected AggregateType in to_aggregate_builder".to_string()); + }; + + let alias = query_field.alias.as_ref().map_or(field.name_.as_str(), |x| x.as_ref()).to_string(); + let mut selections = Vec::new(); + let field_map = field_map(&type_); // Get fields of the AggregateType (count, sum, avg, etc.) + + for item in query_field.selection_set.items.iter() { + match item { + Selection::Field(query_sub_field) => { + let field_name = query_sub_field.name.as_ref(); + let sub_field = field_map.get(field_name).ok_or(format!( + "Unknown field \"{}\" selected on type \"{}\"", + field_name, + type_.name().unwrap_or_default() + ))?; + let sub_alias = query_sub_field.alias.as_ref().map_or(sub_field.name_.as_str(), |x| x.as_ref()).to_string(); + + match field_name { + "count" => selections.push(AggregateSelection::Count { alias: sub_alias }), + "sum" | "avg" | "min" | "max" => { + let col_selections = parse_aggregate_numeric_selections( + sub_field, + query_sub_field, + fragment_definitions, + variables, + variable_definitions + )?; + match field_name { + "sum" => selections.push(AggregateSelection::Sum { alias: sub_alias, selections: col_selections }), + "avg" => selections.push(AggregateSelection::Avg { alias: sub_alias, selections: col_selections }), + "min" => selections.push(AggregateSelection::Min { alias: sub_alias, selections: col_selections }), + "max" => selections.push(AggregateSelection::Max { alias: sub_alias, selections: col_selections }), + _ => unreachable!(), // Should not happen due to outer match + } + } + "__typename" => selections.push(AggregateSelection::Typename { + alias: sub_alias, + typename: sub_field.type_().name().ok_or("Typename missing")?, + }), + _ => return Err(format!("Unknown aggregate field: {}", field_name)), + } + } + Selection::FragmentSpread(_spread) => { + // TODO: Handle fragment spreads within aggregate selection if needed + return Err("Fragment spreads within aggregate selections are not yet supported".to_string()); + } + Selection::InlineFragment(_inline_frag) => { + // TODO: Handle inline fragments within aggregate selection if needed + return Err("Inline fragments within aggregate selections are not yet supported".to_string()); + } + } + } + + Ok(AggregateBuilder { + alias, + selections, + }) +} + +fn parse_aggregate_numeric_selections<'a, T>( + field: &__Field, // The sum/avg/min/max field itself + query_field: &graphql_parser::query::Field<'a, T>, // The query field for sum/avg/min/max + _fragment_definitions: &Vec>, + _variables: &serde_json::Value, + _variable_definitions: &Vec>, +) -> Result, String> +where + T: Text<'a> + Eq + AsRef + Clone, + T::Value: Hash, +{ + let type_ = field.type_().unmodified_type(); + let __Type::AggregateNumeric(ref _agg_numeric_type) = type_ else { + return Err("Internal Error: Expected AggregateNumericType".to_string()); + }; + + let mut col_selections = Vec::new(); + let field_map = field_map(&type_); // Fields of AggregateNumericType (numeric columns) + + for item in query_field.selection_set.items.iter() { + match item { + Selection::Field(col_field) => { + let col_name = col_field.name.as_ref(); + let sub_field = field_map.get(col_name).ok_or(format!( + "Unknown field \"{}\" selected on type \"{}\"", + col_name, + type_.name().unwrap_or_default() + ))?; + + // Ensure the selected field is actually a column + let __Type::Scalar(_) = sub_field.type_().unmodified_type() else { + return Err(format!("Field \"{}\" on type \"{}\" is not a scalar column", col_name, type_.name().unwrap_or_default())); + }; + // We expect the sql_type to be set for columns within the numeric aggregate type's fields + // This might require adjustment in how AggregateNumericType fields are created in graphql.rs if sql_type isn't populated there + let Some(NodeSQLType::Column(column)) = sub_field.sql_type.clone() else { + // We need the Arc! It should be available via the __Field's sql_type. + // If it's not, the creation of AggregateNumericType fields in graphql.rs needs adjustment. + return Err(format!("Internal error: Missing column info for aggregate field '{}'", col_name)); + }; + + let alias = col_field.alias.as_ref().map_or(col_name, |x| x.as_ref()).to_string(); + + col_selections.push(ColumnBuilder { + alias, + column, + }); + } + Selection::FragmentSpread(_) | Selection::InlineFragment(_) => { + // TODO: Support fragments if needed within numeric aggregates + return Err("Fragments within numeric aggregate selections are not yet supported".to_string()); + } + } + } + Ok(col_selections) +} + fn to_page_info_builder<'a, T>( field: &__Field, query_field: &graphql_parser::query::Field<'a, T>, diff --git a/src/graphql.rs b/src/graphql.rs index 1e2d5639..9b3e5e79 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -531,6 +531,8 @@ pub enum __Type { // Modifiers List(ListType), NonNull(NonNullType), + Aggregate(AggregateType), + AggregateNumeric(AggregateNumericType), } #[cached( @@ -605,6 +607,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.kind(), Self::List(x) => x.kind(), Self::NonNull(x) => x.kind(), + Self::Aggregate(x) => x.kind(), + Self::AggregateNumeric(x) => x.kind(), } } @@ -640,6 +644,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.name(), Self::List(x) => x.name(), Self::NonNull(x) => x.name(), + Self::Aggregate(x) => x.name(), + Self::AggregateNumeric(x) => x.name(), } } @@ -675,6 +681,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.description(), Self::List(x) => x.description(), Self::NonNull(x) => x.description(), + Self::Aggregate(x) => x.description(), + Self::AggregateNumeric(x) => x.description(), } } @@ -711,6 +719,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.fields(_include_deprecated), Self::List(x) => x.fields(_include_deprecated), Self::NonNull(x) => x.fields(_include_deprecated), + Self::Aggregate(x) => x.fields(_include_deprecated), + Self::AggregateNumeric(x) => x.fields(_include_deprecated), } } @@ -747,6 +757,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.interfaces(), Self::List(x) => x.interfaces(), Self::NonNull(x) => x.interfaces(), + Self::Aggregate(x) => x.interfaces(), + Self::AggregateNumeric(x) => x.interfaces(), } } @@ -792,6 +804,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.enum_values(_include_deprecated), Self::List(x) => x.enum_values(_include_deprecated), Self::NonNull(x) => x.enum_values(_include_deprecated), + Self::Aggregate(x) => x.enum_values(_include_deprecated), + Self::AggregateNumeric(x) => x.enum_values(_include_deprecated), } } @@ -828,6 +842,8 @@ impl ___Type for __Type { Self::__Directive(x) => x.input_fields(), Self::List(x) => x.input_fields(), Self::NonNull(x) => x.input_fields(), + Self::Aggregate(x) => x.input_fields(), + Self::AggregateNumeric(x) => x.input_fields(), } } @@ -1676,54 +1692,64 @@ impl ___Type for ConnectionType { } fn fields(&self, _include_deprecated: bool) -> Option> { - let mut fields = vec![ - __Field { - name_: "edges".to_string(), - type_: __Type::NonNull(NonNullType { - type_: Box::new(__Type::List(ListType { - type_: Box::new(__Type::NonNull(NonNullType { - type_: Box::new(__Type::Edge(EdgeType { - table: Arc::clone(&self.table), - schema: Arc::clone(&self.schema), - })), - })), - })), - }), - args: vec![], - description: None, - deprecation_reason: None, - sql_type: None, - }, - __Field { - name_: "pageInfo".to_string(), - type_: __Type::NonNull(NonNullType { - type_: Box::new(__Type::PageInfo(PageInfoType)), - }), - args: vec![], - description: None, - deprecation_reason: None, - sql_type: None, - }, - ]; + let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); + let edge_type = __Type::Edge(EdgeType { + table: Arc::clone(&self.table), + schema: self.schema.clone(), + }); + + let edge = __Field { + name_: "edges".to_string(), + type_: __Type::NonNull(NonNullType { + type_: Box::new(__Type::List(ListType { + type_: Box::new(edge_type), + })), + }), + args: vec![], + description: Some("Array of edges".to_string()), + deprecation_reason: None, + sql_type: None, + }; - if let Some(total_count) = self.table.directives.total_count.as_ref() { - if total_count.enabled { - let total_count_field = __Field { - name_: "totalCount".to_string(), - type_: __Type::NonNull(NonNullType { - type_: Box::new(__Type::Scalar(Scalar::Int)), - }), - args: vec![], - description: Some( - "The total number of records matching the `filter` criteria".to_string(), - ), - deprecation_reason: None, - sql_type: None, - }; - fields.push(total_count_field); - } - } - Some(fields) + let page_info = __Field { + name_: "pageInfo".to_string(), + type_: __Type::NonNull(NonNullType { + type_: Box::new(__Type::PageInfo(PageInfoType)), + }), + args: vec![], + description: Some("Information to aid in pagination".to_string()), + deprecation_reason: None, + sql_type: None, + }; + + let total_count = __Field { + name_: "totalCount".to_string(), + type_: __Type::NonNull(NonNullType { + type_: Box::new(__Type::Scalar(Scalar::Int)), + }), + args: vec![], + description: Some(format!( + "The total number of records matching the query, ignoring pagination. Is null if the query returns no rows." + )), + deprecation_reason: None, + sql_type: None, + }; + + let aggregate = __Field { + name_: "aggregate".to_string(), + type_: __Type::Aggregate(AggregateType { + table: Arc::clone(&self.table), + schema: self.schema.clone(), + }), + args: vec![], + description: Some(format!( + "Aggregate functions calculated on the collection of `{table_base_type_name}`" + )), + deprecation_reason: None, + sql_type: None, + }; + + Some(vec![page_info, edge, total_count, aggregate]) } } @@ -4299,3 +4325,291 @@ impl __Schema { ] } } + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct AggregateType { + pub table: Arc, + pub schema: Arc<__Schema>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct AggregateNumericType { + pub table: Arc
, + pub schema: Arc<__Schema>, + pub aggregate_op: AggregateOperation, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub enum AggregateOperation { + Sum, + Avg, + Min, + Max, + // Count is handled directly in AggregateType +} + + +/// Determines if a column's type is suitable for a given aggregate operation. +fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { + let Some(ref type_) = column.type_ else { return false }; + + // Helper to check if a type name is numeric based on common PostgreSQL numeric types + let is_numeric = |name: &str| { + matches!(name, "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money") + }; + // Helper for common string types + let is_string = |name: &str| { + matches!(name, "text" | "varchar" | "char" | "bpchar" | "name" | "citext") + }; + // Helper for common date/time types + let is_datetime = |name: &str| { + matches!(name, "date" | "time" | "timetz" | "timestamp" | "timestamptz") + }; + // Helper for boolean + let is_boolean = |name: &str| { + matches!(name, "bool") + }; + + + match op { + // Sum/Avg only make sense for numeric types + AggregateOperation::Sum | AggregateOperation::Avg => { + // Check category first for arrays/enums, then check name for base types + match type_.category { + TypeCategory::Other => is_numeric(&type_.name), + _ => false // Only allow sum/avg on base numeric types for now + } + } + // Min/Max can work on more types (numeric, string, date/time, etc.) + AggregateOperation::Min | AggregateOperation::Max => { + match type_.category { + TypeCategory::Other => { + is_numeric(&type_.name) || is_string(&type_.name) || is_datetime(&type_.name) || is_boolean(&type_.name) + }, + _ => false // Don't allow min/max on composites, arrays, tables, pseudo + } + } + } +} + +/// Returns the appropriate GraphQL scalar type for an aggregate result. +fn aggregate_result_type(column: &Column, op: &AggregateOperation) -> Option { + let Some(ref type_) = column.type_ else { return None }; + + // Use the same helpers as is_aggregatable + let is_numeric = |name: &str| { + matches!(name, "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money") + }; + let is_string = |name: &str| { + matches!(name, "text" | "varchar" | "char" | "bpchar" | "name" | "citext") + }; + let is_datetime = |name: &str| { + matches!(name, "date" | "time" | "timetz" | "timestamp" | "timestamptz") + }; + let is_boolean = |name: &str| { + matches!(name, "bool") + }; + + match op { + AggregateOperation::Sum => { + // SUM of integers often results in bigint, SUM of float/numeric results in numeric/bigfloat + // Let's simplify and return BigInt for int-like, BigFloat otherwise + if matches!(type_.name.as_str(), "int2" | "int4" | "int8") { + Some(Scalar::BigInt) + } else if is_numeric(&type_.name) { + Some(Scalar::BigFloat) + } else { + None + } + } + AggregateOperation::Avg => { + if is_numeric(&type_.name) { + Some(Scalar::BigFloat) + } else { + None + } + } + AggregateOperation::Min | AggregateOperation::Max => { + if is_numeric(&type_.name) { + sql_type_to_scalar(&type_.name, column.max_characters) + } else if is_string(&type_.name) { + Some(Scalar::String(column.max_characters)) + } else if is_datetime(&type_.name) { + sql_type_to_scalar(&type_.name, column.max_characters) + } else if is_boolean(&type_.name) { + Some(Scalar::Boolean) + } else { + None + } + } + } +} + +impl ___Type for AggregateType { + fn kind(&self) -> __TypeKind { + __TypeKind::OBJECT + } + + fn name(&self) -> Option { + let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); + Some(format!("{table_base_type_name}Aggregate")) + } + + fn description(&self) -> Option { + let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); + Some(format!("Aggregate results for `{table_base_type_name}`")) + } + + fn fields(&self, _include_deprecated: bool) -> Option> { + let mut fields = Vec::new(); + + // Count field (always present) + fields.push(__Field { + name_: "count".to_string(), + type_: __Type::NonNull(NonNullType { type_: Box::new(__Type::Scalar(Scalar::Int)) }), + args: vec![], + description: Some("The number of records matching the query".to_string()), + deprecation_reason: None, + sql_type: None, + }); + + // Add fields for Sum, Avg, Min, Max if there are any aggregatable columns + let has_numeric = self.table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Sum)); + let has_min_maxable = self.table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Min)); + + if has_numeric { + fields.push(__Field { + name_: "sum".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Sum, + }), + args: vec![], + description: Some("Summation aggregates for numeric fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); + fields.push(__Field { + name_: "avg".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Avg, + }), + args: vec![], + description: Some("Average aggregates for numeric fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); + } + + if has_min_maxable { + fields.push(__Field { + name_: "min".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Min, + }), + args: vec![], + description: Some("Minimum aggregates for comparable fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); + fields.push(__Field { + name_: "max".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Max, + }), + args: vec![], + description: Some("Maximum aggregates for comparable fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); + } + Some(fields) + } +} + +impl ___Type for AggregateNumericType { + fn kind(&self) -> __TypeKind { + __TypeKind::OBJECT + } + + fn name(&self) -> Option { + let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); + let op_name = match self.aggregate_op { + AggregateOperation::Sum => "Sum", + AggregateOperation::Avg => "Avg", + AggregateOperation::Min => "Min", + AggregateOperation::Max => "Max", + }; + Some(format!("{table_base_type_name}{op_name}AggregateResult")) + } + + fn description(&self) -> Option { + let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); + let op_desc = match self.aggregate_op { + AggregateOperation::Sum => "summation", + AggregateOperation::Avg => "average", + AggregateOperation::Min => "minimum", + AggregateOperation::Max => "maximum", + }; + Some(format!("Result of {op_desc} aggregation for `{table_base_type_name}`")) + } + + + fn fields(&self, _include_deprecated: bool) -> Option> { + let mut fields = Vec::new(); + + for col in self.table.columns.iter() { + if is_aggregatable(col, &self.aggregate_op) { + if let Some(scalar_type) = aggregate_result_type(col, &self.aggregate_op) { + let field_name = self.schema.graphql_column_field_name(col); + fields.push(__Field { + name_: field_name.clone(), + type_: __Type::Scalar(scalar_type), + args: vec![], + description: Some(format!( + "{} of {} across all matching records", + match self.aggregate_op { + AggregateOperation::Sum => "Sum", + AggregateOperation::Avg => "Average", + AggregateOperation::Min => "Minimum", + AggregateOperation::Max => "Maximum", + }, + field_name + )), + deprecation_reason: None, + sql_type: Some(NodeSQLType::Column(Arc::clone(col))), + }); + } + } + } + if fields.is_empty() { None } else { Some(fields) } + } +} + + +// Converts SQL type name to a GraphQL Scalar, needed for aggregate_result_type +// This function might already exist or needs to be created/adapted. +// Placeholder implementation: +fn sql_type_to_scalar(sql_type_name: &str, typmod: Option) -> Option { + // Simplified mapping - adapt based on existing logic in sql_types.rs or elsewhere + match sql_type_name { + "int2" | "int4" => Some(Scalar::Int), + "int8" => Some(Scalar::BigInt), + "float4" | "float8" | "numeric" | "decimal" => Some(Scalar::BigFloat), // Use BigFloat for precision + "text" | "varchar" | "char" | "bpchar" | "name" => Some(Scalar::String(typmod)), + "bool" => Some(Scalar::Boolean), + "date" => Some(Scalar::Date), + "time" | "timetz" => Some(Scalar::Time), + "timestamp" | "timestamptz" => Some(Scalar::Datetime), + "uuid" => Some(Scalar::UUID), + "json" | "jsonb" => Some(Scalar::JSON), + _ => Some(Scalar::Opaque), // Fallback for unknown types + } +} diff --git a/src/transpile.rs b/src/transpile.rs index efe981da..3117434a 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -813,12 +813,6 @@ pub struct FromFunction { } impl ConnectionBuilder { - fn requested_total(&self) -> bool { - self.selections - .iter() - .any(|x| matches!(&x, ConnectionSelection::TotalCount { alias: _ })) - } - fn page_selections(&self) -> Vec { self.selections .iter() @@ -917,6 +911,78 @@ impl ConnectionBuilder { } } + // Generates the *contents* of the aggregate jsonb_build_object + fn aggregate_select_list( + &self, + quoted_block_name: &str, + // param_context: &mut ParamContext, // No longer needed here + ) -> Result, String> { + let Some(ref agg_builder) = self.aggregate_selection else { + return Ok(None); + }; + + let mut agg_selections = vec![]; + + for selection in &agg_builder.selections { + match selection { + AggregateSelection::Count { alias } => { + // Produces: 'count_alias', count(*) + agg_selections.push(format!("{}, count(*)", quote_literal(alias))); + } + AggregateSelection::Sum { alias, selections } | + AggregateSelection::Avg { alias, selections } | + AggregateSelection::Min { alias, selections } | + AggregateSelection::Max { alias, selections } => { + + let pg_func = match selection { + AggregateSelection::Sum { .. } => "sum", + AggregateSelection::Avg { .. } => "avg", + AggregateSelection::Min { .. } => "min", + AggregateSelection::Max { .. } => "max", + _ => unreachable!(), + }; + + let mut field_selections = vec![]; + for col_builder in selections { + let col_sql = col_builder.to_sql(quoted_block_name)?; + let col_alias = &col_builder.alias; + + // Always cast avg input to numeric for precision + let col_sql_casted = if pg_func == "avg" { + format!("{}::numeric", col_sql) + } else { + col_sql.clone() + }; + // Produces: 'col_alias', agg_func(col) + field_selections.push(format!( + "{}, {}({})", + quote_literal(col_alias), + pg_func, + col_sql_casted + )); + } + // Produces: 'agg_alias', jsonb_build_object('col_alias', agg_func(col), ...) + agg_selections.push(format!( + "{}, jsonb_build_object({})", + quote_literal(alias), + field_selections.join(", ") + )); + + } + AggregateSelection::Typename { alias, typename } => { + // Produces: '__typename', 'AggregateTypeName' + agg_selections.push(format!("{}, {}", quote_literal(alias), quote_literal(typename))); + } + } + } + + if agg_selections.is_empty() { + Ok(None) + } else { + Ok(Some(agg_selections.join(", "))) + } + } + pub fn to_sql( &self, quoted_parent_block_name: Option<&str>, @@ -946,7 +1012,6 @@ impl ConnectionBuilder { false => &order_by_clause, }; - let requested_total = self.requested_total(); let requested_next_page = self.requested_next_page(); let requested_previous_page = self.requested_previous_page(); @@ -955,6 +1020,9 @@ impl ConnectionBuilder { let cursor = &self.before.clone().or_else(|| self.after.clone()); let object_clause = self.object_clause("ed_block_name, param_context)?; + // --- REVISED --- Generate the *select list* for the aggregate CTE + let aggregate_select_list = self.aggregate_select_list("ed_block_name)?; + // --- END REVISED --- let selectable_columns_clause = self.source.table.to_selectable_columns_clause(); @@ -985,6 +1053,9 @@ impl ConnectionBuilder { let limit = self.limit_clause(); let offset = self.offset.unwrap_or(0); + // Determine if aggregates are requested based on if we generated a select list + let requested_aggregates = self.aggregate_selection.is_some() && aggregate_select_list.is_some(); + // initialized assuming forwards pagination let mut has_next_page_query = format!( " @@ -1035,6 +1106,27 @@ impl ConnectionBuilder { has_prev_page_query = "select null".to_string() } + // Build aggregate CTE if requested + let aggregate_cte = if requested_aggregates { + // Use the generated select list to build the object inside the CTE + let select_list_str = aggregate_select_list.unwrap_or_default(); // Safe unwrap due to requested_aggregates check + format!(" + ,__aggregates(agg_result) as ( + select + jsonb_build_object({select_list_str}) + from + {from_clause} + where + {join_clause} + and {where_clause} + )" + ) + } else { + // Dummy CTE still needed for syntax if not requested + // It must output a single column named agg_result of type jsonb + "\n ,__aggregates(agg_result) as (select null::jsonb)".to_string() + }; + Ok(format!( " ( @@ -1061,8 +1153,7 @@ impl ConnectionBuilder { from {from_clause} where - {requested_total} -- skips total when not requested - and {join_clause} + {join_clause} and {where_clause} ), __has_next_page(___has_next_page) as ( @@ -1072,14 +1163,26 @@ impl ConnectionBuilder { __has_previous_page(___has_previous_page) as ( {has_prev_page_query} ) + {aggregate_cte} select - jsonb_build_object({object_clause}) -- sorted within edge + jsonb_build_object({object_clause}) from __records {quoted_block_name}, __total_count, __has_next_page, - __has_previous_page - )" + __has_previous_page, + __aggregates + )", + object_clause = { + let mut clauses = vec![object_clause]; + if requested_aggregates { + // Use the alias from the AggregateBuilder + let agg_alias = self.aggregate_selection.as_ref().map_or("aggregate".to_string(), |b| b.alias.clone()); + // Select the pre-built JSON object from the CTE's agg_result column + clauses.push(format!("{}, coalesce(__aggregates.agg_result, \'{{}}\'::jsonb)", quote_literal(&agg_alias))); + } + clauses.join(", ") + } )) } } @@ -1176,12 +1279,6 @@ impl ConnectionSelection { x.to_sql(block_name, order_by, table)? ) } - Self::TotalCount { alias } => { - format!( - "{}, coalesce(min(__total_count.___total_count), 0)", - quote_literal(alias) - ) - } Self::Typename { alias, typename } => { format!("{}, {}", quote_literal(alias), quote_literal(typename)) } diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out new file mode 100644 index 00000000..1f00dd74 --- /dev/null +++ b/test/expected/aggregate.out @@ -0,0 +1,8 @@ +{"data": {"testsPostsCollection": {"aggregate": {"count": 3}}}} +{"data": {"testsPostsCollection": {"aggregate": {"count": 2}}}} +{"data": {"testsPostsCollection": {"aggregate": {"count": 3, "sum": {"views": 350}, "avg": {"views": 116.6666666666666667}, "min": {"views": 50}, "max": {"views": 200}}}}} +{"data": {"testsPostsCollection": {"aggregate": {"count": 2, "sum": {"views": 150}, "avg": {"views": 75.0000000000000000}, "min": {"views": 50}, "max": {"views": 100}}}}} +{"data": {"testsPostsCollection": {"edges": [{"node": {"id": "WyJ0ZXN0cyIsInBvc3RzIiwxXQ==", "title": "Post 1"}}], "aggregate": {"count": 3, "sum": {"views": 350}}}}} +{"data": {"testsNumericTypesCollection": {"aggregate": {"count": 3, "sum": {"intVal": 60, "bigintVal": "60000000000", "floatVal": 61.5, "numericVal": "601.50"}, "avg": {"intVal": 20.0000000000000000, "bigintVal": 20000000000.0000000000000000, "floatVal": 20.5000000000000000, "numericVal": 200.5000000000000000}, "min": {"intVal": 10, "bigintVal": "10000000000", "floatVal": 10.5, "numericVal": "100.50"}, "max": {"intVal": 30, "bigintVal": "30000000000", "floatVal": 30.5, "numericVal": "300.50"}}}}} +{"data": {"testsPostsCollection": {"aggregate": {"count": 0, "sum": {"views": null}, "avg": {"views": null}, "min": {"views": null}, "max": {"views": null}}}}} +{"data": {"testsPostsWithNullsCollection": {"aggregate": {"count": 3, "sum": {"views": 300}, "avg": {"views": 150.0000000000000000}, "min": {"views": 100}, "max": {"views": 200}}}}} \ No newline at end of file diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql new file mode 100644 index 00000000..c77cedb5 --- /dev/null +++ b/test/sql/aggregate.sql @@ -0,0 +1,211 @@ +-- Setup common test schema and table +drop schema if exists tests cascade; +create schema tests; +create table tests.posts ( + id serial primary key, + title text, + body text, + views integer, + created_at timestamp default now() +); + +insert into tests.posts (title, body, views) values + ('Post 1', 'Body 1', 100), + ('Post 2', 'Body 2', 200), + ('Post 3', 'Body 3', 50); + + +-- Test Case 1: Basic Count +select graphql.resolve($$ + query { + testsPostsCollection { + aggregate { + count + } + } + } +$$); + + +-- Test Case 2: Filtered Count +select graphql.resolve($$ + query { + testsPostsCollection(filter: { views: { gt: 75 } }) { + aggregate { + count + } + } + } +$$); + + +-- Test Case 3: Sum, Avg, Min, Max on 'views' +select graphql.resolve($$ + query { + testsPostsCollection { + aggregate { + count + sum { + views + } + avg { + views + } + min { + views + } + max { + views + } + } + } + } +$$); + + +-- Test Case 4: Aggregates with Filter +select graphql.resolve($$ + query { + testsPostsCollection(filter: { views: { lt: 150 } }) { + aggregate { + count + sum { + views + } + avg { + views + } + min { + views + } + max { + views + } + } + } + } +$$); + + +-- Test Case 5: Aggregates with Pagination (should ignore pagination) +select graphql.resolve($$ + query { + testsPostsCollection(first: 1) { + edges { + node { + id + title + } + } + aggregate { + count + sum { + views + } + } + } + } +$$); + + +-- Test Case 6: Aggregates on table with different numeric types +drop table if exists tests.numeric_types cascade; +create table tests.numeric_types ( + id serial primary key, + int_val int, + bigint_val bigint, + float_val float, + numeric_val numeric(10, 2) +); + +insert into tests.numeric_types (int_val, bigint_val, float_val, numeric_val) values + (10, 10000000000, 10.5, 100.50), + (20, 20000000000, 20.5, 200.50), + (30, 30000000000, 30.5, 300.50); + +select graphql.resolve($$ + query { + testsNumericTypesCollection { + aggregate { + count + sum { + intVal + bigintVal + floatVal + numericVal + } + avg { + intVal + bigintVal + floatVal + numericVal + } + min { + intVal + bigintVal + floatVal + numericVal + } + max { + intVal + bigintVal + floatVal + numericVal + } + } + } + } +$$); + +-- Test Case 7: Aggregates with empty result set +select graphql.resolve($$ + query { + testsPostsCollection(filter: { views: { gt: 1000 } }) { + aggregate { + count + sum { + views + } + avg { + views + } + min { + views + } + max { + views + } + } + } + } +$$); + +-- Test Case 8: Aggregates on table with null values +drop table if exists tests.posts_with_nulls cascade; +create table tests.posts_with_nulls ( + id serial primary key, + views integer +); +insert into tests.posts_with_nulls(views) values (100), (null), (200); + +select graphql.resolve($$ + query { + testsPostsWithNullsCollection { + aggregate { + count + sum { + views + } + avg { + views + } + min { + views + } + max { + views + } + } + } + } +$$); \ No newline at end of file From a11a31ea60d58a5981bfaba0f1592b4fd4cee51a Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 15:30:38 -0400 Subject: [PATCH 02/53] Add totalCount back --- src/builder.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/builder.rs b/src/builder.rs index 01e0e184..e1846525 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -940,6 +940,7 @@ pub enum PageInfoSelection { #[derive(Clone, Debug)] pub enum ConnectionSelection { + TotalCount { alias: String }, Edge(EdgeBuilder), PageInfo(PageInfoBuilder), Typename { alias: String, typename: String }, @@ -1484,6 +1485,18 @@ where typename: xtype.name().expect("connection type should have a name"), } } + __Type::Scalar(Scalar::Int) => { + if selection_field.name.as_ref() == "totalCount" { + ConnectionSelection::TotalCount { + alias: alias_or_name(&selection_field), + } + } else { + return Err(format!( + "Unsupported field type for connection field {}", + selection_field.name.as_ref() + )) + } + } __Type::Scalar(Scalar::String(None)) => { if selection_field.name.as_ref() == "__typename" { ConnectionSelection::Typename { From 4ac585c668d81d44643c090bd5da40fa73a7fec3 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 15:31:11 -0400 Subject: [PATCH 03/53] Add aggregate sql test --- test/sql/aggregate.sql | 372 ++++++++++++++++++++--------------------- 1 file changed, 185 insertions(+), 187 deletions(-) diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index c77cedb5..a2cd64f8 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -1,211 +1,209 @@ --- Setup common test schema and table -drop schema if exists tests cascade; -create schema tests; -create table tests.posts ( - id serial primary key, - title text, - body text, - views integer, - created_at timestamp default now() -); - -insert into tests.posts (title, body, views) values - ('Post 1', 'Body 1', 100), - ('Post 2', 'Body 2', 200), - ('Post 3', 'Body 3', 50); - - --- Test Case 1: Basic Count -select graphql.resolve($$ - query { - testsPostsCollection { - aggregate { - count +begin; + + create table account( + id serial primary key, + email varchar(255) not null, + created_at timestamp not null + ); + + + create table blog( + id serial primary key, + owner_id integer not null references account(id) on delete cascade, + name varchar(255) not null, + description varchar(255), + created_at timestamp not null + ); + + + create type blog_post_status as enum ('PENDING', 'RELEASED'); + + + create table blog_post( + id uuid not null default gen_random_uuid() primary key, + blog_id integer not null references blog(id) on delete cascade, + title varchar(255) not null, + body varchar(10000), + tags TEXT[], + status blog_post_status not null, + created_at timestamp not null + ); + + + -- 5 Accounts + insert into public.account(email, created_at) + values + ('aardvark@x.com', now()), + ('bat@x.com', now()), + ('cat@x.com', now()), + ('dog@x.com', now()), + ('elephant@x.com', now()); + + insert into blog(owner_id, name, description, created_at) + values + ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', now()), + ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', now()), + ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', now()), + ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', now()); + + insert into blog_post (blog_id, title, body, tags, status, created_at) + values + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW()), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', NOW()); + + + comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; + + -- Test Case 1: Basic Count on accountCollection + select graphql.resolve($$ + query { + accountCollection { + aggregate { + count + } + } } - } - } -$$); + $$); --- Test Case 2: Filtered Count -select graphql.resolve($$ - query { - testsPostsCollection(filter: { views: { gt: 75 } }) { - aggregate { - count - } - } - } -$$); - - --- Test Case 3: Sum, Avg, Min, Max on 'views' -select graphql.resolve($$ - query { - testsPostsCollection { - aggregate { - count - sum { - views - } - avg { - views - } - min { - views - } - max { - views + -- Test Case 2: Filtered Count on accountCollection + select graphql.resolve($$ + query { + accountCollection(filter: { id: { gt: 3 } }) { + aggregate { + count } } } - } -$$); - - --- Test Case 4: Aggregates with Filter -select graphql.resolve($$ - query { - testsPostsCollection(filter: { views: { lt: 150 } }) { - aggregate { - count - sum { - views - } - avg { - views - } - min { - views - } - max { - views + $$); + + + -- Test Case 3: Sum, Avg, Min, Max on blogCollection.id + select graphql.resolve($$ + query { + blogCollection { + aggregate { + count + sum { + id + } + avg { + id + } + min { + id + } + max { + id + } } } } - } -$$); - - --- Test Case 5: Aggregates with Pagination (should ignore pagination) -select graphql.resolve($$ - query { - testsPostsCollection(first: 1) { - edges { - node { - id - title - } - } - aggregate { - count - sum { - views + $$); + + + -- Test Case 4: Aggregates with Filter on blogCollection.id + select graphql.resolve($$ + query { + blogCollection(filter: { ownerId: { lt: 2 } }) { + aggregate { + count + sum { + id + } + avg { + id + } + min { + id + } + max { + id + } } } } - } -$$); - - --- Test Case 6: Aggregates on table with different numeric types -drop table if exists tests.numeric_types cascade; -create table tests.numeric_types ( - id serial primary key, - int_val int, - bigint_val bigint, - float_val float, - numeric_val numeric(10, 2) -); - -insert into tests.numeric_types (int_val, bigint_val, float_val, numeric_val) values - (10, 10000000000, 10.5, 100.50), - (20, 20000000000, 20.5, 200.50), - (30, 30000000000, 30.5, 300.50); - -select graphql.resolve($$ - query { - testsNumericTypesCollection { - aggregate { - count - sum { - intVal - bigintVal - floatVal - numericVal - } - avg { - intVal - bigintVal - floatVal - numericVal - } - min { - intVal - bigintVal - floatVal - numericVal + $$); + + + -- Test Case 5: Aggregates with Pagination on blogCollection (should ignore pagination for aggregates) + select graphql.resolve($$ + query { + blogCollection(first: 1) { + edges { + node { + id + name + } } - max { - intVal - bigintVal - floatVal - numericVal + aggregate { + count + sum { + id + } } } } - } -$$); - --- Test Case 7: Aggregates with empty result set -select graphql.resolve($$ - query { - testsPostsCollection(filter: { views: { gt: 1000 } }) { - aggregate { - count - sum { - views - } - avg { - views - } - min { - views - } - max { - views + $$); + + + -- Test Case 7: Aggregates with empty result set on accountCollection + select graphql.resolve($$ + query { + accountCollection(filter: { id: { gt: 1000 } }) { + aggregate { + count + sum { + id + } + avg { + id + } + min { + id + } + max { + id + } } } } - } -$$); - --- Test Case 8: Aggregates on table with null values -drop table if exists tests.posts_with_nulls cascade; -create table tests.posts_with_nulls ( - id serial primary key, - views integer -); -insert into tests.posts_with_nulls(views) values (100), (null), (200); - -select graphql.resolve($$ - query { - testsPostsWithNullsCollection { - aggregate { - count - sum { - views - } - avg { - views + $$); + + -- Test Case 8: Aggregates on table with null values (using blog.description) + -- Count where description is not null + select graphql.resolve($$ + query { + blogCollection(filter: { description: { isNull: false }}) { + aggregate { + count } - min { - views + } + } + $$); + -- Count where description is null + select graphql.resolve($$ + query { + blogCollection(filter: { description: { isNull: true }}) { + aggregate { + count } - max { - views + } + } + $$); + + -- Test Case 9: Basic Count on blogPostCollection + select graphql.resolve($$ + query { + blogPostCollection { + aggregate { + count } } } - } -$$); \ No newline at end of file + $$); \ No newline at end of file From 8f5576eb61e27cb7a0b5c49d54a7d0718dc82316 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 15:33:05 -0400 Subject: [PATCH 04/53] Add totalCount case to transpile to_sql. Remove aggregate out --- src/transpile.rs | 6 ++++++ test/expected/aggregate.out | 8 -------- 2 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 test/expected/aggregate.out diff --git a/src/transpile.rs b/src/transpile.rs index 3117434a..261b99f4 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1279,6 +1279,12 @@ impl ConnectionSelection { x.to_sql(block_name, order_by, table)? ) } + Self::TotalCount { alias } => { + format!( + "{}, coalesce(__total_count.___total_count, 0)", + quote_literal(alias), + ) + } Self::Typename { alias, typename } => { format!("{}, {}", quote_literal(alias), quote_literal(typename)) } diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out deleted file mode 100644 index 1f00dd74..00000000 --- a/test/expected/aggregate.out +++ /dev/null @@ -1,8 +0,0 @@ -{"data": {"testsPostsCollection": {"aggregate": {"count": 3}}}} -{"data": {"testsPostsCollection": {"aggregate": {"count": 2}}}} -{"data": {"testsPostsCollection": {"aggregate": {"count": 3, "sum": {"views": 350}, "avg": {"views": 116.6666666666666667}, "min": {"views": 50}, "max": {"views": 200}}}}} -{"data": {"testsPostsCollection": {"aggregate": {"count": 2, "sum": {"views": 150}, "avg": {"views": 75.0000000000000000}, "min": {"views": 50}, "max": {"views": 100}}}}} -{"data": {"testsPostsCollection": {"edges": [{"node": {"id": "WyJ0ZXN0cyIsInBvc3RzIiwxXQ==", "title": "Post 1"}}], "aggregate": {"count": 3, "sum": {"views": 350}}}}} -{"data": {"testsNumericTypesCollection": {"aggregate": {"count": 3, "sum": {"intVal": 60, "bigintVal": "60000000000", "floatVal": 61.5, "numericVal": "601.50"}, "avg": {"intVal": 20.0000000000000000, "bigintVal": 20000000000.0000000000000000, "floatVal": 20.5000000000000000, "numericVal": 200.5000000000000000}, "min": {"intVal": 10, "bigintVal": "10000000000", "floatVal": 10.5, "numericVal": "100.50"}, "max": {"intVal": 30, "bigintVal": "30000000000", "floatVal": 30.5, "numericVal": "300.50"}}}}} -{"data": {"testsPostsCollection": {"aggregate": {"count": 0, "sum": {"views": null}, "avg": {"views": null}, "min": {"views": null}, "max": {"views": null}}}}} -{"data": {"testsPostsWithNullsCollection": {"aggregate": {"count": 3, "sum": {"views": 300}, "avg": {"views": 150.0000000000000000}, "min": {"views": 100}, "max": {"views": 200}}}}} \ No newline at end of file From f5dec6ae16df747aaebe4deba2d0d2f6dd494756 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 17:05:45 -0400 Subject: [PATCH 05/53] Add aggregate types. Fix totalCounts directive --- src/graphql.rs | 69 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 9b3e5e79..7cb208d0 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1722,18 +1722,25 @@ impl ___Type for ConnectionType { sql_type: None, }; - let total_count = __Field { - name_: "totalCount".to_string(), - type_: __Type::NonNull(NonNullType { - type_: Box::new(__Type::Scalar(Scalar::Int)), - }), - args: vec![], - description: Some(format!( - "The total number of records matching the query, ignoring pagination. Is null if the query returns no rows." - )), - deprecation_reason: None, - sql_type: None, - }; + let mut fields = vec![page_info, edge]; // Start with pageInfo and edge + + // Conditionally add totalCount based on the directive + if let Some(total_count_directive) = self.table.directives.total_count.as_ref() { + if total_count_directive.enabled { + let total_count = __Field { + name_: "totalCount".to_string(), + type_: __Type::NonNull(NonNullType { + type_: Box::new(__Type::Scalar(Scalar::Int)), + }), + args: vec![], + // Using description from the user's provided removed block + description: Some("The total number of records matching the `filter` criteria".to_string()), + deprecation_reason: None, + sql_type: None, + }; + fields.push(total_count); + } + } let aggregate = __Field { name_: "aggregate".to_string(), @@ -1749,7 +1756,8 @@ impl ___Type for ConnectionType { sql_type: None, }; - Some(vec![page_info, edge, total_count, aggregate]) + fields.push(aggregate); // Add aggregate last + Some(fields) } } @@ -4208,6 +4216,40 @@ impl __Schema { schema: Arc::clone(&schema_rc), })); } + + // Add Aggregate types if the table is selectable + if self.graphql_table_select_types_are_valid(table) { + types_.push(__Type::Aggregate(AggregateType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + })); + // Check if there are any columns aggregatable by sum/avg + if table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Sum)) { + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Sum, + })); + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Avg, + })); + } + // Check if there are any columns aggregatable by min/max + if table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Min)) { + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Min, + })); + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Max, + })); + } + } } for (_, enum_) in self @@ -4613,3 +4655,4 @@ fn sql_type_to_scalar(sql_type_name: &str, typmod: Option) -> Option Some(Scalar::Opaque), // Fallback for unknown types } } + From 8db2ae8fc076ef8a101b1a2236e366cb1bd4a770 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 17:06:02 -0400 Subject: [PATCH 06/53] Fix aggregate test --- test/sql/aggregate.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index a2cd64f8..12b7e793 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -179,7 +179,7 @@ begin; -- Count where description is not null select graphql.resolve($$ query { - blogCollection(filter: { description: { isNull: false }}) { + blogCollection(filter: { description: { is: NOT_NULL }}) { aggregate { count } @@ -189,7 +189,7 @@ begin; -- Count where description is null select graphql.resolve($$ query { - blogCollection(filter: { description: { isNull: true }}) { + blogCollection(filter: { description: { is: NULL }}) { aggregate { count } From ff33b2eccc621445a069678324c428bfd8fc346e Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 18:12:58 -0400 Subject: [PATCH 07/53] Add expected output for aggregate test. Start addressing regressions in tests --- src/transpile.rs | 72 ++++++++---- test/expected/aggregate.out | 228 ++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 test/expected/aggregate.out diff --git a/src/transpile.rs b/src/transpile.rs index 261b99f4..230aa0e2 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1110,7 +1110,8 @@ impl ConnectionBuilder { let aggregate_cte = if requested_aggregates { // Use the generated select list to build the object inside the CTE let select_list_str = aggregate_select_list.unwrap_or_default(); // Safe unwrap due to requested_aggregates check - format!(" + format!( + r#" ,__aggregates(agg_result) as ( select jsonb_build_object({select_list_str}) @@ -1119,16 +1120,38 @@ impl ConnectionBuilder { where {join_clause} and {where_clause} - )" + ) + "# ) } else { // Dummy CTE still needed for syntax if not requested // It must output a single column named agg_result of type jsonb - "\n ,__aggregates(agg_result) as (select null::jsonb)".to_string() + r#" + ,__aggregates(agg_result) as (select null::jsonb) + "# + .to_string() + }; + + // --- NEW STRUCTURE --- + // Clause containing selections *not* including the aggregate + let base_object_clause = object_clause; // Renamed original object_clause + + // Clause to merge the aggregate result if requested + let aggregate_merge_clause = if requested_aggregates { + let agg_alias = self + .aggregate_selection + .as_ref() + .map_or("aggregate".to_string(), |b| b.alias.clone()); + format!( + "|| jsonb_build_object({}, coalesce(__aggregates.agg_result, '{{}}'::jsonb))", + quote_literal(&agg_alias) + ) + } else { + "".to_string() }; Ok(format!( - " + r#" ( with __records as ( select @@ -1163,26 +1186,29 @@ impl ConnectionBuilder { __has_previous_page(___has_previous_page) as ( {has_prev_page_query} ) - {aggregate_cte} + {aggregate_cte}, + __base_object as ( + select jsonb_build_object({base_object_clause}) as obj + from + -- OLD: __records {quoted_block_name}, __total_count, __has_next_page, __has_previous_page + -- Ensure a row is always present by starting with guaranteed CTEs and LEFT JOINing records + __total_count + cross join __has_next_page + cross join __has_previous_page + left join __records {quoted_block_name} on true + -- Required grouping for aggregations like jsonb_agg used within base_object_clause + group by __total_count.___total_count, __has_next_page.___has_next_page, __has_previous_page.___has_previous_page + ) select - jsonb_build_object({object_clause}) + -- Combine base object (might be null if no records) with aggregate object + coalesce(__base_object.obj, '{{}}'::jsonb) {aggregate_merge_clause} from - __records {quoted_block_name}, - __total_count, - __has_next_page, - __has_previous_page, - __aggregates - )", - object_clause = { - let mut clauses = vec![object_clause]; - if requested_aggregates { - // Use the alias from the AggregateBuilder - let agg_alias = self.aggregate_selection.as_ref().map_or("aggregate".to_string(), |b| b.alias.clone()); - // Select the pre-built JSON object from the CTE's agg_result column - clauses.push(format!("{}, coalesce(__aggregates.agg_result, \'{{}}\'::jsonb)", quote_literal(&agg_alias))); - } - clauses.join(", ") - } + -- Use a dummy row and LEFT JOIN to handle cases where __records (and thus __base_object) is empty + (select 1) as __dummy_for_left_join + left join __base_object on true + cross join __aggregates -- Aggregate result always exists (even if null) + ) + "# )) } } @@ -1314,7 +1340,7 @@ impl EdgeBuilder { jsonb_agg( jsonb_build_object({x}) order by {order_by_clause} - ), + ) FILTER (WHERE {block_name} IS NOT NULL), jsonb_build_array() )" )) diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out new file mode 100644 index 00000000..7d2548b8 --- /dev/null +++ b/test/expected/aggregate.out @@ -0,0 +1,228 @@ +begin; + create table account( + id serial primary key, + email varchar(255) not null, + created_at timestamp not null + ); + create table blog( + id serial primary key, + owner_id integer not null references account(id) on delete cascade, + name varchar(255) not null, + description varchar(255), + created_at timestamp not null + ); + create type blog_post_status as enum ('PENDING', 'RELEASED'); + create table blog_post( + id uuid not null default gen_random_uuid() primary key, + blog_id integer not null references blog(id) on delete cascade, + title varchar(255) not null, + body varchar(10000), + tags TEXT[], + status blog_post_status not null, + created_at timestamp not null + ); + -- 5 Accounts + insert into public.account(email, created_at) + values + ('aardvark@x.com', now()), + ('bat@x.com', now()), + ('cat@x.com', now()), + ('dog@x.com', now()), + ('elephant@x.com', now()); + insert into blog(owner_id, name, description, created_at) + values + ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', now()), + ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', now()), + ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', now()), + ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', now()); + insert into blog_post (blog_id, title, body, tags, status, created_at) + values + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW()), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', NOW()); + comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; + -- Test Case 1: Basic Count on accountCollection + select graphql.resolve($$ + query { + accountCollection { + aggregate { + count + } + } + } + $$); + resolve +-------------------------------------------------------------- + {"data": {"accountCollection": {"aggregate": {"count": 5}}}} +(1 row) + + -- Test Case 2: Filtered Count on accountCollection + select graphql.resolve($$ + query { + accountCollection(filter: { id: { gt: 3 } }) { + aggregate { + count + } + } + } + $$); + resolve +-------------------------------------------------------------- + {"data": {"accountCollection": {"aggregate": {"count": 2}}}} +(1 row) + + -- Test Case 3: Sum, Avg, Min, Max on blogCollection.id + select graphql.resolve($$ + query { + blogCollection { + aggregate { + count + sum { + id + } + avg { + id + } + min { + id + } + max { + id + } + } + } + } + $$); + resolve +-------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"aggregate": {"avg": {"id": 2.5}, "max": {"id": 4}, "min": {"id": 1}, "sum": {"id": 10}, "count": 4}}}} +(1 row) + + -- Test Case 4: Aggregates with Filter on blogCollection.id + select graphql.resolve($$ + query { + blogCollection(filter: { ownerId: { lt: 2 } }) { + aggregate { + count + sum { + id + } + avg { + id + } + min { + id + } + max { + id + } + } + } + } + $$); + resolve +------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"aggregate": {"avg": {"id": 2.0}, "max": {"id": 3}, "min": {"id": 1}, "sum": {"id": 6}, "count": 3}}}} +(1 row) + + -- Test Case 5: Aggregates with Pagination on blogCollection (should ignore pagination for aggregates) + select graphql.resolve($$ + query { + blogCollection(first: 1) { + edges { + node { + id + name + } + } + aggregate { + count + sum { + id + } + } + } + } + $$); + resolve +----------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"edges": [{"node": {"id": 1, "name": "A: Blog 1"}}], "aggregate": {"sum": {"id": 10}, "count": 4}}}} +(1 row) + + -- Test Case 7: Aggregates with empty result set on accountCollection + select graphql.resolve($$ + query { + accountCollection(filter: { id: { gt: 1000 } }) { + aggregate { + count + sum { + id + } + avg { + id + } + min { + id + } + max { + id + } + } + } + } + $$); + resolve +-------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"accountCollection": {"aggregate": {"avg": {"id": null}, "max": {"id": null}, "min": {"id": null}, "sum": {"id": null}, "count": 0}}}} +(1 row) + + -- Test Case 8: Aggregates on table with null values (using blog.description) + -- Count where description is not null + select graphql.resolve($$ + query { + blogCollection(filter: { description: { is: NOT_NULL }}) { + aggregate { + count + } + } + } + $$); + resolve +----------------------------------------------------------- + {"data": {"blogCollection": {"aggregate": {"count": 4}}}} +(1 row) + + -- Count where description is null + select graphql.resolve($$ + query { + blogCollection(filter: { description: { is: NULL }}) { + aggregate { + count + } + } + } + $$); + resolve +----------------------------------------------------------- + {"data": {"blogCollection": {"aggregate": {"count": 0}}}} +(1 row) + + -- Test Case 9: Basic Count on blogPostCollection + select graphql.resolve($$ + query { + blogPostCollection { + aggregate { + count + } + } + } + $$); + resolve +--------------------------------------------------------------- + {"data": {"blogPostCollection": {"aggregate": {"count": 7}}}} +(1 row) + From 49034ad7ef7302a0236ab72a995c3f7b95acb56e Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 20:42:36 -0400 Subject: [PATCH 08/53] Revert this commit later... --- bin/installcheck | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bin/installcheck b/bin/installcheck index 23111ed0..62d3734d 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -11,7 +11,7 @@ export PGDATABASE=postgres export PGTZ=UTC export PG_COLOR=auto -# PATH=~/.pgrx/15.1/pgrx-install/bin/:$PATH +PATH=~/.pgrx/16.8/pgrx-install/bin/:$PATH #################### # Ensure Clean Env # @@ -31,21 +31,22 @@ pg_ctl start -o "-F -c listen_addresses=\"\" -c log_min_messages=WARNING -k $PGD # Create the test db createdb contrib_regression +cargo pgrx install + ######### # Tests # ######### TESTDIR="test" -PGXS=$(dirname `pg_config --pgxs`) +PGXS=$(dirname $(pg_config --pgxs)) REGRESS="${PGXS}/../test/regress/pg_regress" # Test names can be passed as parameters to this script. # If any test names are passed run only those tests. # Otherwise run all tests. -if [ "$#" -ne 0 ] -then - TESTS=$@ +if [ "$#" -ne 0 ]; then + TESTS=$@ else - TESTS=$(ls ${TESTDIR}/sql | sed -e 's/\..*$//' | sort ) + TESTS=$(ls ${TESTDIR}/sql | sed -e 's/\..*$//' | sort) fi # Execute the test fixtures From 8e504140a06b26cf2974a7a431bb119960c07d4a Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 21:37:26 -0400 Subject: [PATCH 09/53] Formatting --- src/transpile.rs | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 230aa0e2..3fc36e84 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -929,11 +929,10 @@ impl ConnectionBuilder { // Produces: 'count_alias', count(*) agg_selections.push(format!("{}, count(*)", quote_literal(alias))); } - AggregateSelection::Sum { alias, selections } | - AggregateSelection::Avg { alias, selections } | - AggregateSelection::Min { alias, selections } | - AggregateSelection::Max { alias, selections } => { - + AggregateSelection::Sum { alias, selections } + | AggregateSelection::Avg { alias, selections } + | AggregateSelection::Min { alias, selections } + | AggregateSelection::Max { alias, selections } => { let pg_func = match selection { AggregateSelection::Sum { .. } => "sum", AggregateSelection::Avg { .. } => "avg", @@ -949,9 +948,9 @@ impl ConnectionBuilder { // Always cast avg input to numeric for precision let col_sql_casted = if pg_func == "avg" { - format!("{}::numeric", col_sql) + format!("{}::numeric", col_sql) } else { - col_sql.clone() + col_sql.clone() }; // Produces: 'col_alias', agg_func(col) field_selections.push(format!( @@ -964,22 +963,25 @@ impl ConnectionBuilder { // Produces: 'agg_alias', jsonb_build_object('col_alias', agg_func(col), ...) agg_selections.push(format!( "{}, jsonb_build_object({})", - quote_literal(alias), - field_selections.join(", ") + quote_literal(alias), + field_selections.join(", ") )); - } AggregateSelection::Typename { alias, typename } => { - // Produces: '__typename', 'AggregateTypeName' - agg_selections.push(format!("{}, {}", quote_literal(alias), quote_literal(typename))); + // Produces: '__typename', 'AggregateTypeName' + agg_selections.push(format!( + "{}, {}", + quote_literal(alias), + quote_literal(typename) + )); } } } if agg_selections.is_empty() { - Ok(None) + Ok(None) } else { - Ok(Some(agg_selections.join(", "))) + Ok(Some(agg_selections.join(", "))) } } @@ -1054,7 +1056,8 @@ impl ConnectionBuilder { let offset = self.offset.unwrap_or(0); // Determine if aggregates are requested based on if we generated a select list - let requested_aggregates = self.aggregate_selection.is_some() && aggregate_select_list.is_some(); + let requested_aggregates = + self.aggregate_selection.is_some() && aggregate_select_list.is_some(); // initialized assuming forwards pagination let mut has_next_page_query = format!( @@ -1124,12 +1127,8 @@ impl ConnectionBuilder { "# ) } else { - // Dummy CTE still needed for syntax if not requested - // It must output a single column named agg_result of type jsonb - r#" - ,__aggregates(agg_result) as (select null::jsonb) - "# - .to_string() + // Dummy CTE still needed for syntax + r#",__aggregates(agg_result) as (select null::jsonb)"#.to_string() }; // --- NEW STRUCTURE --- From eea10d3777a319d42b4ade2afed55d8c25ec2157 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 23:06:02 -0400 Subject: [PATCH 10/53] Fix function relation test --- src/transpile.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 3fc36e84..7ea5274c 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1111,7 +1111,6 @@ impl ConnectionBuilder { // Build aggregate CTE if requested let aggregate_cte = if requested_aggregates { - // Use the generated select list to build the object inside the CTE let select_list_str = aggregate_select_list.unwrap_or_default(); // Safe unwrap due to requested_aggregates check format!( r#" @@ -1127,11 +1126,12 @@ impl ConnectionBuilder { "# ) } else { - // Dummy CTE still needed for syntax - r#",__aggregates(agg_result) as (select null::jsonb)"#.to_string() + r#" + ,__aggregates(agg_result) as (select null::jsonb) + "# + .to_string() }; - // --- NEW STRUCTURE --- // Clause containing selections *not* including the aggregate let base_object_clause = object_clause; // Renamed original object_clause @@ -1334,12 +1334,26 @@ impl EdgeBuilder { let x = frags.join(", "); let order_by_clause = order_by.to_order_by_clause(block_name); + // Get the first primary key column name to use in the filter + let first_pk_col = table.primary_key_columns().first().map(|col| &col.name); + + // Create a filter clause that checks if any primary key column is not NULL + let filter_clause = if let Some(pk_col) = first_pk_col { + format!( + "FILTER (WHERE {}.{} IS NOT NULL)", + block_name, + quote_ident(pk_col) + ) + } else { + "".to_string() // Fallback if no primary key columns (should be rare) + }; + Ok(format!( "coalesce( jsonb_agg( jsonb_build_object({x}) order by {order_by_clause} - ) FILTER (WHERE {block_name} IS NOT NULL), + ) {filter_clause}, jsonb_build_array() )" )) From 1adb32af10fdd9c9fd056cf84780722719146e95 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 23:28:26 -0400 Subject: [PATCH 11/53] Fix more tests --- src/transpile.rs | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 7ea5274c..79f2b83a 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1022,9 +1022,7 @@ impl ConnectionBuilder { let cursor = &self.before.clone().or_else(|| self.after.clone()); let object_clause = self.object_clause("ed_block_name, param_context)?; - // --- REVISED --- Generate the *select list* for the aggregate CTE let aggregate_select_list = self.aggregate_select_list("ed_block_name)?; - // --- END REVISED --- let selectable_columns_clause = self.source.table.to_selectable_columns_clause(); @@ -1080,7 +1078,7 @@ impl ConnectionBuilder { " ); - let mut has_prev_page_query = format!(" + let mut has_prev_page_query = format!(" with page_minus_1 as ( select not ({pkey_tuple_clause_from_block} = any( __records.seen )) is_pkey_in_records @@ -1132,6 +1130,11 @@ impl ConnectionBuilder { .to_string() }; + // Add helper cte to set page info correctly for empty collections + let has_records_cte = r#" + ,__has_records(has_records) as (select exists(select 1 from __records)) + "#; + // Clause containing selections *not* including the aggregate let base_object_clause = object_clause; // Renamed original object_clause @@ -1180,32 +1183,32 @@ impl ConnectionBuilder { ), __has_next_page(___has_next_page) as ( {has_next_page_query} - ), __has_previous_page(___has_previous_page) as ( {has_prev_page_query} ) + {has_records_cte} {aggregate_cte}, __base_object as ( select jsonb_build_object({base_object_clause}) as obj from - -- OLD: __records {quoted_block_name}, __total_count, __has_next_page, __has_previous_page - -- Ensure a row is always present by starting with guaranteed CTEs and LEFT JOINing records __total_count cross join __has_next_page cross join __has_previous_page + cross join __has_records left join __records {quoted_block_name} on true - -- Required grouping for aggregations like jsonb_agg used within base_object_clause - group by __total_count.___total_count, __has_next_page.___has_next_page, __has_previous_page.___has_previous_page + group by + __total_count.___total_count, + __has_next_page.___has_next_page, + __has_previous_page.___has_previous_page, + __has_records.has_records ) select - -- Combine base object (might be null if no records) with aggregate object coalesce(__base_object.obj, '{{}}'::jsonb) {aggregate_merge_clause} from - -- Use a dummy row and LEFT JOIN to handle cases where __records (and thus __base_object) is empty (select 1) as __dummy_for_left_join left join __base_object on true - cross join __aggregates -- Aggregate result always exists (even if null) + cross join __aggregates ) "# )) @@ -1252,13 +1255,13 @@ impl PageInfoSelection { Ok(match self { Self::StartCursor { alias } => { format!( - "{}, (array_agg({cursor_clause} order by {order_by_clause}))[1]", + "{}, CASE WHEN __has_records.has_records THEN (array_agg({cursor_clause} order by {order_by_clause}))[1] ELSE NULL END", quote_literal(alias) ) } Self::EndCursor { alias } => { format!( - "{}, (array_agg({cursor_clause} order by {order_by_clause_reversed}))[1]", + "{}, CASE WHEN __has_records.has_records THEN (array_agg({cursor_clause} order by {order_by_clause_reversed}))[1] ELSE NULL END", quote_literal(alias) ) } From e939ff6d99b7a199485b8cafdaafc13dcfc61113 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 23:30:15 -0400 Subject: [PATCH 12/53] Add new aggregate types to inflection_types expected --- test/expected/inflection_types.out | 44 ++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/test/expected/inflection_types.out b/test/expected/inflection_types.out index 33d9012e..2c46059a 100644 --- a/test/expected/inflection_types.out +++ b/test/expected/inflection_types.out @@ -20,19 +20,24 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "blog")' ) ); - jsonb_pretty ---------------------------- + jsonb_pretty +------------------------------- "blog_post" + "blog_postAggregate" + "blog_postAvgAggregateResult" "blog_postConnection" "blog_postDeleteResponse" "blog_postEdge" "blog_postFilter" "blog_postInsertInput" "blog_postInsertResponse" + "blog_postMaxAggregateResult" + "blog_postMinAggregateResult" "blog_postOrderBy" + "blog_postSumAggregateResult" "blog_postUpdateInput" "blog_postUpdateResponse" -(10 rows) +(15 rows) -- Inflection off, Overrides: on comment on table blog_post is e'@graphql({"name": "BlogZZZ"})'; @@ -50,19 +55,24 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "Blog")' ) ); - jsonb_pretty -------------------------- + jsonb_pretty +----------------------------- "BlogZZZ" + "BlogZZZAggregate" + "BlogZZZAvgAggregateResult" "BlogZZZConnection" "BlogZZZDeleteResponse" "BlogZZZEdge" "BlogZZZFilter" "BlogZZZInsertInput" "BlogZZZInsertResponse" + "BlogZZZMaxAggregateResult" + "BlogZZZMinAggregateResult" "BlogZZZOrderBy" + "BlogZZZSumAggregateResult" "BlogZZZUpdateInput" "BlogZZZUpdateResponse" -(10 rows) +(15 rows) rollback to savepoint a; -- Inflection on, Overrides: off @@ -81,19 +91,24 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "Blog")' ) ); - jsonb_pretty --------------------------- + jsonb_pretty +------------------------------ "BlogPost" + "BlogPostAggregate" + "BlogPostAvgAggregateResult" "BlogPostConnection" "BlogPostDeleteResponse" "BlogPostEdge" "BlogPostFilter" "BlogPostInsertInput" "BlogPostInsertResponse" + "BlogPostMaxAggregateResult" + "BlogPostMinAggregateResult" "BlogPostOrderBy" + "BlogPostSumAggregateResult" "BlogPostUpdateInput" "BlogPostUpdateResponse" -(10 rows) +(15 rows) -- Inflection on, Overrides: on comment on table blog_post is e'@graphql({"name": "BlogZZZ"})'; @@ -111,18 +126,23 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "Blog")' ) ); - jsonb_pretty -------------------------- + jsonb_pretty +----------------------------- "BlogZZZ" + "BlogZZZAggregate" + "BlogZZZAvgAggregateResult" "BlogZZZConnection" "BlogZZZDeleteResponse" "BlogZZZEdge" "BlogZZZFilter" "BlogZZZInsertInput" "BlogZZZInsertResponse" + "BlogZZZMaxAggregateResult" + "BlogZZZMinAggregateResult" "BlogZZZOrderBy" + "BlogZZZSumAggregateResult" "BlogZZZUpdateInput" "BlogZZZUpdateResponse" -(10 rows) +(15 rows) rollback; From 554941782466a9076043ece04c84b63de7bd0753 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 23:40:17 -0400 Subject: [PATCH 13/53] Revert ordering of edges and pageInfo. Update omit_exotic_types expected output --- src/graphql.rs | 344 ++++++++++++++------------ test/expected/omit_exotic_types.out | 370 ++++++++++++++++------------ 2 files changed, 411 insertions(+), 303 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 7cb208d0..6198e9f2 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1722,7 +1722,7 @@ impl ___Type for ConnectionType { sql_type: None, }; - let mut fields = vec![page_info, edge]; // Start with pageInfo and edge + let mut fields = vec![edge, page_info]; // Conditionally add totalCount based on the directive if let Some(total_count_directive) = self.table.directives.total_count.as_ref() { @@ -1734,7 +1734,9 @@ impl ___Type for ConnectionType { }), args: vec![], // Using description from the user's provided removed block - description: Some("The total number of records matching the `filter` criteria".to_string()), + description: Some( + "The total number of records matching the `filter` criteria".to_string(), + ), deprecation_reason: None, sql_type: None, }; @@ -4219,36 +4221,44 @@ impl __Schema { // Add Aggregate types if the table is selectable if self.graphql_table_select_types_are_valid(table) { - types_.push(__Type::Aggregate(AggregateType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - })); + types_.push(__Type::Aggregate(AggregateType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + })); // Check if there are any columns aggregatable by sum/avg - if table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Sum)) { - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Sum, - })); - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Avg, - })); + if table + .columns + .iter() + .any(|c| is_aggregatable(c, &AggregateOperation::Sum)) + { + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Sum, + })); + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Avg, + })); + } + // Check if there are any columns aggregatable by min/max + if table + .columns + .iter() + .any(|c| is_aggregatable(c, &AggregateOperation::Min)) + { + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Min, + })); + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Max, + })); } - // Check if there are any columns aggregatable by min/max - if table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Min)) { - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Min, - })); - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Max, - })); - } } } @@ -4390,101 +4400,120 @@ pub enum AggregateOperation { // Count is handled directly in AggregateType } - /// Determines if a column's type is suitable for a given aggregate operation. fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { - let Some(ref type_) = column.type_ else { return false }; + let Some(ref type_) = column.type_ else { + return false; + }; // Helper to check if a type name is numeric based on common PostgreSQL numeric types let is_numeric = |name: &str| { - matches!(name, "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money") + matches!( + name, + "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money" + ) }; // Helper for common string types let is_string = |name: &str| { - matches!(name, "text" | "varchar" | "char" | "bpchar" | "name" | "citext") + matches!( + name, + "text" | "varchar" | "char" | "bpchar" | "name" | "citext" + ) }; // Helper for common date/time types let is_datetime = |name: &str| { - matches!(name, "date" | "time" | "timetz" | "timestamp" | "timestamptz") + matches!( + name, + "date" | "time" | "timetz" | "timestamp" | "timestamptz" + ) }; // Helper for boolean - let is_boolean = |name: &str| { - matches!(name, "bool") - }; - + let is_boolean = |name: &str| matches!(name, "bool"); match op { // Sum/Avg only make sense for numeric types AggregateOperation::Sum | AggregateOperation::Avg => { // Check category first for arrays/enums, then check name for base types match type_.category { - TypeCategory::Other => is_numeric(&type_.name), - _ => false // Only allow sum/avg on base numeric types for now + TypeCategory::Other => is_numeric(&type_.name), + _ => false, // Only allow sum/avg on base numeric types for now } } // Min/Max can work on more types (numeric, string, date/time, etc.) AggregateOperation::Min | AggregateOperation::Max => { - match type_.category { - TypeCategory::Other => { - is_numeric(&type_.name) || is_string(&type_.name) || is_datetime(&type_.name) || is_boolean(&type_.name) - }, - _ => false // Don't allow min/max on composites, arrays, tables, pseudo - } + match type_.category { + TypeCategory::Other => { + is_numeric(&type_.name) + || is_string(&type_.name) + || is_datetime(&type_.name) + || is_boolean(&type_.name) + } + _ => false, // Don't allow min/max on composites, arrays, tables, pseudo + } } } } /// Returns the appropriate GraphQL scalar type for an aggregate result. fn aggregate_result_type(column: &Column, op: &AggregateOperation) -> Option { - let Some(ref type_) = column.type_ else { return None }; + let Some(ref type_) = column.type_ else { + return None; + }; - // Use the same helpers as is_aggregatable - let is_numeric = |name: &str| { - matches!(name, "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money") + // Use the same helpers as is_aggregatable + let is_numeric = |name: &str| { + matches!( + name, + "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money" + ) }; let is_string = |name: &str| { - matches!(name, "text" | "varchar" | "char" | "bpchar" | "name" | "citext") + matches!( + name, + "text" | "varchar" | "char" | "bpchar" | "name" | "citext" + ) }; let is_datetime = |name: &str| { - matches!(name, "date" | "time" | "timetz" | "timestamp" | "timestamptz") - }; - let is_boolean = |name: &str| { - matches!(name, "bool") + matches!( + name, + "date" | "time" | "timetz" | "timestamp" | "timestamptz" + ) }; + let is_boolean = |name: &str| matches!(name, "bool"); - match op { - AggregateOperation::Sum => { - // SUM of integers often results in bigint, SUM of float/numeric results in numeric/bigfloat - // Let's simplify and return BigInt for int-like, BigFloat otherwise - if matches!(type_.name.as_str(), "int2" | "int4" | "int8") { - Some(Scalar::BigInt) - } else if is_numeric(&type_.name) { - Some(Scalar::BigFloat) - } else { - None - } - } - AggregateOperation::Avg => { - if is_numeric(&type_.name) { - Some(Scalar::BigFloat) - } else { - None - } - } - AggregateOperation::Min | AggregateOperation::Max => { - if is_numeric(&type_.name) { - sql_type_to_scalar(&type_.name, column.max_characters) - } else if is_string(&type_.name) { - Some(Scalar::String(column.max_characters)) - } else if is_datetime(&type_.name) { - sql_type_to_scalar(&type_.name, column.max_characters) - } else if is_boolean(&type_.name) { - Some(Scalar::Boolean) - } else { - None - } - } - } + match op { + AggregateOperation::Sum => { + // SUM of integers often results in bigint, SUM of float/numeric results in numeric/bigfloat + // Let's simplify and return BigInt for int-like, BigFloat otherwise + if matches!(type_.name.as_str(), "int2" | "int4" | "int8") { + Some(Scalar::BigInt) + } else if is_numeric(&type_.name) { + Some(Scalar::BigFloat) + } else { + None + } + } + AggregateOperation::Avg => { + if is_numeric(&type_.name) { + Some(Scalar::BigFloat) + } else { + None + } + } + AggregateOperation::Min | AggregateOperation::Max => { + if is_numeric(&type_.name) { + sql_type_to_scalar(&type_.name, column.max_characters) + } else if is_string(&type_.name) { + Some(Scalar::String(column.max_characters)) + } else if is_datetime(&type_.name) { + sql_type_to_scalar(&type_.name, column.max_characters) + } else if is_boolean(&type_.name) { + Some(Scalar::Boolean) + } else { + None + } + } + } } impl ___Type for AggregateType { @@ -4497,7 +4526,7 @@ impl ___Type for AggregateType { Some(format!("{table_base_type_name}Aggregate")) } - fn description(&self) -> Option { + fn description(&self) -> Option { let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); Some(format!("Aggregate results for `{table_base_type_name}`")) } @@ -4508,7 +4537,9 @@ impl ___Type for AggregateType { // Count field (always present) fields.push(__Field { name_: "count".to_string(), - type_: __Type::NonNull(NonNullType { type_: Box::new(__Type::Scalar(Scalar::Int)) }), + type_: __Type::NonNull(NonNullType { + type_: Box::new(__Type::Scalar(Scalar::Int)), + }), args: vec![], description: Some("The number of records matching the query".to_string()), deprecation_reason: None, @@ -4516,72 +4547,80 @@ impl ___Type for AggregateType { }); // Add fields for Sum, Avg, Min, Max if there are any aggregatable columns - let has_numeric = self.table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Sum)); - let has_min_maxable = self.table.columns.iter().any(|c| is_aggregatable(c, &AggregateOperation::Min)); + let has_numeric = self + .table + .columns + .iter() + .any(|c| is_aggregatable(c, &AggregateOperation::Sum)); + let has_min_maxable = self + .table + .columns + .iter() + .any(|c| is_aggregatable(c, &AggregateOperation::Min)); if has_numeric { - fields.push(__Field { - name_: "sum".to_string(), - type_: __Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(&self.table), - schema: Arc::clone(&self.schema), - aggregate_op: AggregateOperation::Sum, - }), - args: vec![], - description: Some("Summation aggregates for numeric fields".to_string()), - deprecation_reason: None, - sql_type: None, - }); - fields.push(__Field { - name_: "avg".to_string(), - type_: __Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(&self.table), - schema: Arc::clone(&self.schema), - aggregate_op: AggregateOperation::Avg, - }), - args: vec![], - description: Some("Average aggregates for numeric fields".to_string()), - deprecation_reason: None, - sql_type: None, - }); + fields.push(__Field { + name_: "sum".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Sum, + }), + args: vec![], + description: Some("Summation aggregates for numeric fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); + fields.push(__Field { + name_: "avg".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Avg, + }), + args: vec![], + description: Some("Average aggregates for numeric fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); } if has_min_maxable { fields.push(__Field { - name_: "min".to_string(), - type_: __Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(&self.table), - schema: Arc::clone(&self.schema), - aggregate_op: AggregateOperation::Min, - }), - args: vec![], - description: Some("Minimum aggregates for comparable fields".to_string()), - deprecation_reason: None, - sql_type: None, - }); - fields.push(__Field { - name_: "max".to_string(), - type_: __Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(&self.table), - schema: Arc::clone(&self.schema), - aggregate_op: AggregateOperation::Max, - }), - args: vec![], - description: Some("Maximum aggregates for comparable fields".to_string()), - deprecation_reason: None, - sql_type: None, - }); + name_: "min".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Min, + }), + args: vec![], + description: Some("Minimum aggregates for comparable fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); + fields.push(__Field { + name_: "max".to_string(), + type_: __Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(&self.table), + schema: Arc::clone(&self.schema), + aggregate_op: AggregateOperation::Max, + }), + args: vec![], + description: Some("Maximum aggregates for comparable fields".to_string()), + deprecation_reason: None, + sql_type: None, + }); } Some(fields) } } impl ___Type for AggregateNumericType { - fn kind(&self) -> __TypeKind { + fn kind(&self) -> __TypeKind { __TypeKind::OBJECT } - fn name(&self) -> Option { + fn name(&self) -> Option { let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); let op_name = match self.aggregate_op { AggregateOperation::Sum => "Sum", @@ -4592,18 +4631,19 @@ impl ___Type for AggregateNumericType { Some(format!("{table_base_type_name}{op_name}AggregateResult")) } - fn description(&self) -> Option { - let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); - let op_desc = match self.aggregate_op { + fn description(&self) -> Option { + let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); + let op_desc = match self.aggregate_op { AggregateOperation::Sum => "summation", AggregateOperation::Avg => "average", AggregateOperation::Min => "minimum", AggregateOperation::Max => "maximum", - }; - Some(format!("Result of {op_desc} aggregation for `{table_base_type_name}`")) + }; + Some(format!( + "Result of {op_desc} aggregation for `{table_base_type_name}`" + )) } - fn fields(&self, _include_deprecated: bool) -> Option> { let mut fields = Vec::new(); @@ -4631,11 +4671,14 @@ impl ___Type for AggregateNumericType { } } } - if fields.is_empty() { None } else { Some(fields) } + if fields.is_empty() { + None + } else { + Some(fields) + } } } - // Converts SQL type name to a GraphQL Scalar, needed for aggregate_result_type // This function might already exist or needs to be created/adapted. // Placeholder implementation: @@ -4655,4 +4698,3 @@ fn sql_type_to_scalar(sql_type_name: &str, typmod: Option) -> Option Some(Scalar::Opaque), // Fallback for unknown types } } - diff --git a/test/expected/omit_exotic_types.out b/test/expected/omit_exotic_types.out index 4eb94751..1df359b6 100644 --- a/test/expected/omit_exotic_types.out +++ b/test/expected/omit_exotic_types.out @@ -34,167 +34,233 @@ begin; '$.data.__schema.types[*] ? (@.name starts with "Something")' ) ); - jsonb_pretty ----------------------------------------- - { + - "name": "Something", + - "fields": [ + - { + - "name": "nodeId" + - }, + - { + - "name": "id" + - }, + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "js" + - }, + - { + - "name": "jsb" + - } + - ], + - "inputFields": null + + jsonb_pretty +-------------------------------------------- + { + + "name": "Something", + + "fields": [ + + { + + "name": "nodeId" + + }, + + { + + "name": "id" + + }, + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "js" + + }, + + { + + "name": "jsb" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingConnection", + - "fields": [ + - { + - "name": "edges" + - }, + - { + - "name": "pageInfo" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingAggregate", + + "fields": [ + + { + + "name": "count" + + }, + + { + + "name": "sum" + + }, + + { + + "name": "avg" + + }, + + { + + "name": "min" + + }, + + { + + "name": "max" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingDeleteResponse",+ - "fields": [ + - { + - "name": "affectedCount" + - }, + - { + - "name": "records" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingAvgAggregateResult",+ + "fields": [ + + { + + "name": "id" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingEdge", + - "fields": [ + - { + - "name": "cursor" + - }, + - { + - "name": "node" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingConnection", + + "fields": [ + + { + + "name": "edges" + + }, + + { + + "name": "pageInfo" + + }, + + { + + "name": "aggregate" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingFilter", + - "fields": null, + - "inputFields": [ + - { + - "name": "id" + - }, + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "nodeId" + - }, + - { + - "name": "and" + - }, + - { + - "name": "or" + - }, + - { + - "name": "not" + - } + - ] + + { + + "name": "SomethingDeleteResponse", + + "fields": [ + + { + + "name": "affectedCount" + + }, + + { + + "name": "records" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingInsertInput", + - "fields": null, + - "inputFields": [ + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "js" + - }, + - { + - "name": "jsb" + - } + - ] + + { + + "name": "SomethingEdge", + + "fields": [ + + { + + "name": "cursor" + + }, + + { + + "name": "node" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingInsertResponse",+ - "fields": [ + - { + - "name": "affectedCount" + - }, + - { + - "name": "records" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingFilter", + + "fields": null, + + "inputFields": [ + + { + + "name": "id" + + }, + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "nodeId" + + }, + + { + + "name": "and" + + }, + + { + + "name": "or" + + }, + + { + + "name": "not" + + } + + ] + } - { + - "name": "SomethingOrderBy", + - "fields": null, + - "inputFields": [ + - { + - "name": "id" + - }, + - { + - "name": "name" + - } + - ] + + { + + "name": "SomethingInsertInput", + + "fields": null, + + "inputFields": [ + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "js" + + }, + + { + + "name": "jsb" + + } + + ] + } - { + - "name": "SomethingUpdateInput", + - "fields": null, + - "inputFields": [ + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "js" + - }, + - { + - "name": "jsb" + - } + - ] + + { + + "name": "SomethingInsertResponse", + + "fields": [ + + { + + "name": "affectedCount" + + }, + + { + + "name": "records" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingUpdateResponse",+ - "fields": [ + - { + - "name": "affectedCount" + - }, + - { + - "name": "records" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingMaxAggregateResult",+ + "fields": [ + + { + + "name": "id" + + }, + + { + + "name": "name" + + } + + ], + + "inputFields": null + } -(10 rows) + { + + "name": "SomethingMinAggregateResult",+ + "fields": [ + + { + + "name": "id" + + }, + + { + + "name": "name" + + } + + ], + + "inputFields": null + + } + { + + "name": "SomethingOrderBy", + + "fields": null, + + "inputFields": [ + + { + + "name": "id" + + }, + + { + + "name": "name" + + } + + ] + + } + { + + "name": "SomethingSumAggregateResult",+ + "fields": [ + + { + + "name": "id" + + } + + ], + + "inputFields": null + + } + { + + "name": "SomethingUpdateInput", + + "fields": null, + + "inputFields": [ + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "js" + + }, + + { + + "name": "jsb" + + } + + ] + + } + { + + "name": "SomethingUpdateResponse", + + "fields": [ + + { + + "name": "affectedCount" + + }, + + { + + "name": "records" + + } + + ], + + "inputFields": null + + } +(15 rows) rollback; From 8556685695e150ce8fbe8b5e156cc41b314182e6 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 23:41:29 -0400 Subject: [PATCH 14/53] Update override_type_name expected output with new aggregate types --- test/expected/override_type_name.out | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/expected/override_type_name.out b/test/expected/override_type_name.out index 88a3eab4..6e17441b 100644 --- a/test/expected/override_type_name.out +++ b/test/expected/override_type_name.out @@ -18,18 +18,23 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "UserAccount")' ) ); - jsonb_pretty ------------------------------ + jsonb_pretty +--------------------------------- "UserAccount" + "UserAccountAggregate" + "UserAccountAvgAggregateResult" "UserAccountConnection" "UserAccountDeleteResponse" "UserAccountEdge" "UserAccountFilter" "UserAccountInsertInput" "UserAccountInsertResponse" + "UserAccountMaxAggregateResult" + "UserAccountMinAggregateResult" "UserAccountOrderBy" + "UserAccountSumAggregateResult" "UserAccountUpdateInput" "UserAccountUpdateResponse" -(10 rows) +(15 rows) rollback; From 9a33da26fb7bf5d89f30471e6cd2b765bf454155 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Fri, 2 May 2025 23:44:30 -0400 Subject: [PATCH 15/53] Update resolve___schema expected out --- test/expected/resolve___schema.out | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/expected/resolve___schema.out b/test/expected/resolve___schema.out index 5a8e4a19..ba6344d8 100644 --- a/test/expected/resolve___schema.out +++ b/test/expected/resolve___schema.out @@ -57,6 +57,14 @@ begin; "kind": "OBJECT", + "name": "Account" + }, + + { + + "kind": "OBJECT", + + "name": "AccountAggregate" + + }, + + { + + "kind": "OBJECT", + + "name": "AccountAvgAggregateResult" + + }, + { + "kind": "OBJECT", + "name": "AccountConnection" + @@ -81,10 +89,22 @@ begin; "kind": "OBJECT", + "name": "AccountInsertResponse" + }, + + { + + "kind": "OBJECT", + + "name": "AccountMaxAggregateResult" + + }, + + { + + "kind": "OBJECT", + + "name": "AccountMinAggregateResult" + + }, + { + "kind": "INPUT_OBJECT", + "name": "AccountOrderBy" + }, + + { + + "kind": "OBJECT", + + "name": "AccountSumAggregateResult" + + }, + { + "kind": "INPUT_OBJECT", + "name": "AccountUpdateInput" + @@ -121,6 +141,14 @@ begin; "kind": "OBJECT", + "name": "Blog" + }, + + { + + "kind": "OBJECT", + + "name": "BlogAggregate" + + }, + + { + + "kind": "OBJECT", + + "name": "BlogAvgAggregateResult" + + }, + { + "kind": "OBJECT", + "name": "BlogConnection" + @@ -145,6 +173,14 @@ begin; "kind": "OBJECT", + "name": "BlogInsertResponse" + }, + + { + + "kind": "OBJECT", + + "name": "BlogMaxAggregateResult" + + }, + + { + + "kind": "OBJECT", + + "name": "BlogMinAggregateResult" + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogOrderBy" + @@ -153,6 +189,14 @@ begin; "kind": "OBJECT", + "name": "BlogPost" + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostAggregate" + + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostAvgAggregateResult" + + }, + { + "kind": "OBJECT", + "name": "BlogPostConnection" + @@ -177,6 +221,14 @@ begin; "kind": "OBJECT", + "name": "BlogPostInsertResponse" + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostMaxAggregateResult" + + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostMinAggregateResult" + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostOrderBy" + @@ -189,6 +241,10 @@ begin; "kind": "INPUT_OBJECT", + "name": "BlogPostStatusFilter" + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostSumAggregateResult" + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostUpdateInput" + @@ -197,6 +253,10 @@ begin; "kind": "OBJECT", + "name": "BlogPostUpdateResponse" + }, + + { + + "kind": "OBJECT", + + "name": "BlogSumAggregateResult" + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogUpdateInput" + From 173ce67c30137c4ea691318625a87fa533f7f79f Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 00:09:57 -0400 Subject: [PATCH 16/53] Fix non-null edge type and remove description for graphql schema consistency --- src/graphql.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 6198e9f2..48dd67db 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1702,11 +1702,13 @@ impl ___Type for ConnectionType { name_: "edges".to_string(), type_: __Type::NonNull(NonNullType { type_: Box::new(__Type::List(ListType { - type_: Box::new(edge_type), + type_: Box::new(__Type::NonNull(NonNullType { + type_: Box::new(edge_type), + })), })), }), args: vec![], - description: Some("Array of edges".to_string()), + description: None, deprecation_reason: None, sql_type: None, }; @@ -1717,7 +1719,7 @@ impl ___Type for ConnectionType { type_: Box::new(__Type::PageInfo(PageInfoType)), }), args: vec![], - description: Some("Information to aid in pagination".to_string()), + description: None, deprecation_reason: None, sql_type: None, }; From b5069de23918d05adcd68329c0b0bffd66174414 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 00:10:18 -0400 Subject: [PATCH 17/53] Updated expected test output for resolve_graphiql_schema --- test/expected/resolve_graphiql_schema.out | 1546 +++++++++++++++++++-- 1 file changed, 1405 insertions(+), 141 deletions(-) diff --git a/test/expected/resolve_graphiql_schema.out b/test/expected/resolve_graphiql_schema.out index 454f9465..8a6f6f61 100644 --- a/test/expected/resolve_graphiql_schema.out +++ b/test/expected/resolve_graphiql_schema.out @@ -371,6 +371,114 @@ begin; "inputFields": null, + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "AccountAggregate", + + "fields": [ + + { + + "args": [ + + ], + + "name": "count", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + "description": "The number of records matching the query", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "sum", + + "type": { + + "kind": "OBJECT", + + "name": "AccountSumAggregateResult", + + "ofType": null + + }, + + "description": "Summation aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "avg", + + "type": { + + "kind": "OBJECT", + + "name": "AccountAvgAggregateResult", + + "ofType": null + + }, + + "description": "Average aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "min", + + "type": { + + "kind": "OBJECT", + + "name": "AccountMinAggregateResult", + + "ofType": null + + }, + + "description": "Minimum aggregates for comparable fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "max", + + "type": { + + "kind": "OBJECT", + + "name": "AccountMaxAggregateResult", + + "ofType": null + + }, + + "description": "Maximum aggregates for comparable fields", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Aggregate results for `Account`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "AccountAvgAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "BigFloat", + + "ofType": null + + }, + + "description": "Average of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of average aggregation for `Account`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "OBJECT", + "name": "AccountConnection", + @@ -433,6 +541,19 @@ begin; "description": "The total number of records matching the `filter` criteria", + "isDeprecated": false, + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "aggregate", + + "type": { + + "kind": "OBJECT", + + "name": "AccountAggregate", + + "ofType": null + + }, + + "description": "Aggregate functions calculated on the collection of `Account`", + + "isDeprecated": false, + + "deprecationReason": null + } + ], + "enumValues": [ + @@ -773,164 +894,149 @@ begin; "possibleTypes": null + }, + { + - "kind": "INPUT_OBJECT", + - "name": "AccountOrderBy", + - "fields": null, + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": null, + - "inputFields": [ + + "kind": "OBJECT", + + "name": "AccountMaxAggregateResult", + + "fields": [ + { + + "args": [ + + ], + "name": "id", + "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + + "kind": "SCALAR", + + "name": "Int", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Maximum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + + "args": [ + + ], + "name": "email", + "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + + "kind": "SCALAR", + + "name": "String", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Maximum of email across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + + "args": [ + + ], + "name": "encryptedPassword", + "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + + "kind": "SCALAR", + + "name": "String", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Maximum of encryptedPassword across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + + "args": [ + + ], + "name": "createdAt", + "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + + "kind": "SCALAR", + + "name": "Datetime", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Maximum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + + "args": [ + + ], + "name": "updatedAt", + "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + + "kind": "SCALAR", + + "name": "Datetime", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Maximum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + } + ], + - "possibleTypes": null + - }, + - { + - "kind": "INPUT_OBJECT", + - "name": "AccountUpdateInput", + - "fields": null, + "enumValues": [ + ], + "interfaces": [ + ], + - "description": null, + - "inputFields": [ + + "description": "Result of maximum aggregation for `Account`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "AccountMinAggregateResult", + + "fields": [ + { + - "name": "email", + + "args": [ + + ], + + "name": "id", + "type": { + "kind": "SCALAR", + - "name": "String", + + "name": "Int", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Minimum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + - "name": "encryptedPassword", + + "args": [ + + ], + + "name": "email", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Minimum of email across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + - "name": "createdAt", + + "args": [ + + ], + + "name": "encryptedPassword", + "type": { + "kind": "SCALAR", + - "name": "Datetime", + + "name": "String", + "ofType": null + }, + - "description": null, + - "defaultValue": null + + "description": "Minimum of encryptedPassword across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + }, + { + - "name": "updatedAt", + + "args": [ + + ], + + "name": "createdAt", + "type": { + "kind": "SCALAR", + "name": "Datetime", + "ofType": null + }, + - "description": null, + - "defaultValue": null + - } + - ], + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "AccountUpdateResponse", + - "fields": [ + - { + - "args": [ + - ], + - "name": "affectedCount", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - "description": "Count of the records impacted by the mutation", + + "description": "Minimum of createdAt across all matching records", + "isDeprecated": false, + "deprecationReason": null + }, + { + "args": [ + ], + - "name": "records", + + "name": "updatedAt", + "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "Account", + - "ofType": null + - } + - } + - } + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + }, + - "description": "Array of records impacted by the mutation", + + "description": "Minimum of updatedAt across all matching records", + "isDeprecated": false, + "deprecationReason": null + } + @@ -939,13 +1045,210 @@ begin; ], + "interfaces": [ + ], + - "description": null, + + "description": "Result of minimum aggregation for `Account`", + "inputFields": null, + "possibleTypes": null + }, + { + - "kind": "SCALAR", + - "name": "BigFloat", + + "kind": "INPUT_OBJECT", + + "name": "AccountOrderBy", + + "fields": null, + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": null, + + "inputFields": [ + + { + + "name": "id", + + "type": { + + "kind": "ENUM", + + "name": "OrderByDirection", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "email", + + "type": { + + "kind": "ENUM", + + "name": "OrderByDirection", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "encryptedPassword", + + "type": { + + "kind": "ENUM", + + "name": "OrderByDirection", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "createdAt", + + "type": { + + "kind": "ENUM", + + "name": "OrderByDirection", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "updatedAt", + + "type": { + + "kind": "ENUM", + + "name": "OrderByDirection", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + } + + ], + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "AccountSumAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "BigInt", + + "ofType": null + + }, + + "description": "Sum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of summation aggregation for `Account`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "INPUT_OBJECT", + + "name": "AccountUpdateInput", + + "fields": null, + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": null, + + "inputFields": [ + + { + + "name": "email", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "encryptedPassword", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + }, + + { + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": null, + + "defaultValue": null + + } + + ], + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "AccountUpdateResponse", + + "fields": [ + + { + + "args": [ + + ], + + "name": "affectedCount", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + "description": "Count of the records impacted by the mutation", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "records", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + } + + } + + } + + }, + + "description": "Array of records impacted by the mutation", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": null, + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "SCALAR", + + "name": "BigFloat", + "fields": null, + "enumValues": [ + ], + @@ -1640,47 +1943,74 @@ begin; }, + { + "kind": "OBJECT", + - "name": "BlogConnection", + + "name": "BlogAggregate", + "fields": [ + { + "args": [ + ], + - "name": "edges", + + "name": "count", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "BlogEdge", + - "ofType": null + - } + - } + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + } + }, + - "description": null, + + "description": "The number of records matching the query", + "isDeprecated": false, + "deprecationReason": null + }, + { + "args": [ + ], + - "name": "pageInfo", + + "name": "sum", + "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "PageInfo", + - "ofType": null + - } + + "kind": "OBJECT", + + "name": "BlogSumAggregateResult", + + "ofType": null + }, + - "description": null, + + "description": "Summation aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "avg", + + "type": { + + "kind": "OBJECT", + + "name": "BlogAvgAggregateResult", + + "ofType": null + + }, + + "description": "Average aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "min", + + "type": { + + "kind": "OBJECT", + + "name": "BlogMinAggregateResult", + + "ofType": null + + }, + + "description": "Minimum aggregates for comparable fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "max", + + "type": { + + "kind": "OBJECT", + + "name": "BlogMaxAggregateResult", + + "ofType": null + + }, + + "description": "Maximum aggregates for comparable fields", + "isDeprecated": false, + "deprecationReason": null + } + @@ -1689,18 +2019,125 @@ begin; ], + "interfaces": [ + ], + - "description": null, + + "description": "Aggregate results for `Blog`", + "inputFields": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + - "name": "BlogDeleteResponse", + + "name": "BlogAvgAggregateResult", + "fields": [ + { + "args": [ + ], + - "name": "affectedCount", + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "BigFloat", + + "ofType": null + + }, + + "description": "Average of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "ownerId", + + "type": { + + "kind": "SCALAR", + + "name": "BigFloat", + + "ofType": null + + }, + + "description": "Average of ownerId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of average aggregation for `Blog`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "BlogConnection", + + "fields": [ + + { + + "args": [ + + ], + + "name": "edges", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "BlogEdge", + + "ofType": null + + } + + } + + } + + }, + + "description": null, + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "pageInfo", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "PageInfo", + + "ofType": null + + } + + }, + + "description": null, + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "aggregate", + + "type": { + + "kind": "OBJECT", + + "name": "BlogAggregate", + + "ofType": null + + }, + + "description": "Aggregate functions calculated on the collection of `Blog`", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": null, + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "BlogDeleteResponse", + + "fields": [ + + { + + "args": [ + + ], + + "name": "affectedCount", + "type": { + "kind": "NON_NULL", + "name": null, + @@ -2066,6 +2503,188 @@ begin; "inputFields": null, + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "BlogMaxAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Maximum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "ownerId", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Maximum of ownerId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "name", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Maximum of name across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "description", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Maximum of description across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Maximum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Maximum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of maximum aggregation for `Blog`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "BlogMinAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Minimum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "ownerId", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Minimum of ownerId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "name", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Minimum of name across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "description", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Minimum of description across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Minimum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Minimum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of minimum aggregation for `Blog`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogOrderBy", + @@ -2278,17 +2897,130 @@ begin; { + "args": [ + ], + - "name": "blog", + + "name": "blog", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "Blog", + + "ofType": null + + } + + }, + + "description": null, + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + { + + "kind": "INTERFACE", + + "name": "Node", + + "ofType": null + + } + + ], + + "description": null, + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostAggregate", + + "fields": [ + + { + + "args": [ + + ], + + "name": "count", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + "description": "The number of records matching the query", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "sum", + + "type": { + + "kind": "OBJECT", + + "name": "BlogPostSumAggregateResult", + + "ofType": null + + }, + + "description": "Summation aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "avg", + + "type": { + + "kind": "OBJECT", + + "name": "BlogPostAvgAggregateResult", + + "ofType": null + + }, + + "description": "Average aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "min", + + "type": { + + "kind": "OBJECT", + + "name": "BlogPostMinAggregateResult", + + "ofType": null + + }, + + "description": "Minimum aggregates for comparable fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "max", + + "type": { + + "kind": "OBJECT", + + "name": "BlogPostMaxAggregateResult", + + "ofType": null + + }, + + "description": "Maximum aggregates for comparable fields", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Aggregate results for `BlogPost`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostAvgAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "blogId", + "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "Blog", + - "ofType": null + - } + + "kind": "SCALAR", + + "name": "BigFloat", + + "ofType": null + }, + - "description": null, + + "description": "Average of blogId across all matching records", + "isDeprecated": false, + "deprecationReason": null + } + @@ -2296,13 +3028,8 @@ begin; "enumValues": [ + ], + "interfaces": [ + - { + - "kind": "INTERFACE", + - "name": "Node", + - "ofType": null + - } + ], + - "description": null, + + "description": "Result of average aggregation for `BlogPost`", + "inputFields": null, + "possibleTypes": null + }, + @@ -2351,6 +3078,19 @@ begin; "description": null, + "isDeprecated": false, + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "aggregate", + + "type": { + + "kind": "OBJECT", + + "name": "BlogPostAggregate", + + "ofType": null + + }, + + "description": "Aggregate functions calculated on the collection of `BlogPost`", + + "isDeprecated": false, + + "deprecationReason": null + } + ], + "enumValues": [ + @@ -2740,6 +3480,162 @@ begin; "inputFields": null, + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostMaxAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "blogId", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Maximum of blogId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "title", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Maximum of title across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "body", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Maximum of body across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Maximum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Maximum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of maximum aggregation for `BlogPost`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostMinAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "blogId", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Minimum of blogId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "title", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Minimum of title across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "body", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Minimum of body across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Minimum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Minimum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of minimum aggregation for `BlogPost`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostOrderBy", + @@ -2908,6 +3804,32 @@ begin; ], + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "BlogPostSumAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "blogId", + + "type": { + + "kind": "SCALAR", + + "name": "BigInt", + + "ofType": null + + }, + + "description": "Sum of blogId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of summation aggregation for `BlogPost`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostUpdateInput", + @@ -3046,6 +3968,45 @@ begin; "inputFields": null, + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "BlogSumAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "BigInt", + + "ofType": null + + }, + + "description": "Sum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "ownerId", + + "type": { + + "kind": "SCALAR", + + "name": "BigInt", + + "ofType": null + + }, + + "description": "Sum of ownerId across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of summation aggregation for `Blog`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "INPUT_OBJECT", + "name": "BlogUpdateInput", + @@ -5142,13 +6103,100 @@ begin; "defaultValue": null + } + ], + - "name": "blogs", + + "name": "blogs", + + "type": { + + "kind": "OBJECT", + + "name": "BlogConnection", + + "ofType": null + + }, + + "description": null, + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + { + + "kind": "INTERFACE", + + "name": "Node", + + "ofType": null + + } + + ], + + "description": null, + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "PersonAggregate", + + "fields": [ + + { + + "args": [ + + ], + + "name": "count", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + "description": "The number of records matching the query", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "sum", + "type": { + "kind": "OBJECT", + - "name": "BlogConnection", + + "name": "PersonSumAggregateResult", + "ofType": null + }, + - "description": null, + + "description": "Summation aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "avg", + + "type": { + + "kind": "OBJECT", + + "name": "PersonAvgAggregateResult", + + "ofType": null + + }, + + "description": "Average aggregates for numeric fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "min", + + "type": { + + "kind": "OBJECT", + + "name": "PersonMinAggregateResult", + + "ofType": null + + }, + + "description": "Minimum aggregates for comparable fields", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "max", + + "type": { + + "kind": "OBJECT", + + "name": "PersonMaxAggregateResult", + + "ofType": null + + }, + + "description": "Maximum aggregates for comparable fields", + "isDeprecated": false, + "deprecationReason": null + } + @@ -5156,13 +6204,34 @@ begin; "enumValues": [ + ], + "interfaces": [ + + ], + + "description": "Aggregate results for `Person`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "PersonAvgAggregateResult", + + "fields": [ + { + - "kind": "INTERFACE", + - "name": "Node", + - "ofType": null + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "BigFloat", + + "ofType": null + + }, + + "description": "Average of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + } + ], + - "description": null, + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of average aggregation for `Person`", + "inputFields": null, + "possibleTypes": null + }, + @@ -5211,6 +6280,19 @@ begin; "description": null, + "isDeprecated": false, + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "aggregate", + + "type": { + + "kind": "OBJECT", + + "name": "PersonAggregate", + + "ofType": null + + }, + + "description": "Aggregate functions calculated on the collection of `Person`", + + "isDeprecated": false, + + "deprecationReason": null + } + ], + "enumValues": [ + @@ -5560,6 +6642,162 @@ begin; "inputFields": null, + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "PersonMaxAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Maximum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "email", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Maximum of email across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "encryptedPassword", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Maximum of encryptedPassword across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Maximum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Maximum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of maximum aggregation for `Person`", + + "inputFields": null, + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "PersonMinAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + }, + + "description": "Minimum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "email", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Minimum of email across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "encryptedPassword", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + }, + + "description": "Minimum of encryptedPassword across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "createdAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Minimum of createdAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + }, + + { + + "args": [ + + ], + + "name": "updatedAt", + + "type": { + + "kind": "SCALAR", + + "name": "Datetime", + + "ofType": null + + }, + + "description": "Minimum of updatedAt across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of minimum aggregation for `Person`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "INPUT_OBJECT", + "name": "PersonOrderBy", + @@ -5623,6 +6861,32 @@ begin; ], + "possibleTypes": null + }, + + { + + "kind": "OBJECT", + + "name": "PersonSumAggregateResult", + + "fields": [ + + { + + "args": [ + + ], + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "BigInt", + + "ofType": null + + }, + + "description": "Sum of id across all matching records", + + "isDeprecated": false, + + "deprecationReason": null + + } + + ], + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": "Result of summation aggregation for `Person`", + + "inputFields": null, + + "possibleTypes": null + + }, + { + "kind": "INPUT_OBJECT", + "name": "PersonUpdateInput", + From 3497c608cba6d7894eec62ead0d50db51f02dc76 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 00:38:38 -0400 Subject: [PATCH 18/53] Exclude UUIDs from min/max aggregation --- src/builder.rs | 178 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 131 insertions(+), 47 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index e1846525..2c20f9ae 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -18,12 +18,29 @@ pub struct AggregateBuilder { #[derive(Clone, Debug)] pub enum AggregateSelection { - Count { alias: String }, - Sum { alias: String, selections: Vec }, - Avg { alias: String, selections: Vec }, - Min { alias: String, selections: Vec }, - Max { alias: String, selections: Vec }, - Typename { alias: String, typename: String }, + Count { + alias: String, + }, + Sum { + alias: String, + selections: Vec, + }, + Avg { + alias: String, + selections: Vec, + }, + Min { + alias: String, + selections: Vec, + }, + Max { + alias: String, + selections: Vec, + }, + Typename { + alias: String, + typename: String, + }, } #[derive(Clone, Debug)] @@ -1486,31 +1503,38 @@ where } } __Type::Scalar(Scalar::Int) => { - if selection_field.name.as_ref() == "totalCount" { + if selection_field.name.as_ref() == "totalCount" { ConnectionSelection::TotalCount { alias: alias_or_name(&selection_field), } - } else { + } else { return Err(format!( "Unsupported field type for connection field {}", selection_field.name.as_ref() - )) - } + )); + } } __Type::Scalar(Scalar::String(None)) => { if selection_field.name.as_ref() == "__typename" { ConnectionSelection::Typename { alias: alias_or_name(&selection_field), - typename: xtype.name().expect("connection type should have a name"), + typename: xtype + .name() + .expect("connection type should have a name"), } } else { return Err(format!( "Unsupported field type for connection field {}", selection_field.name.as_ref() - )) + )); } } - _ => return Err(format!("unknown field type on connection: {}", selection_field.name.as_ref())), + _ => { + return Err(format!( + "unknown field type on connection: {}", + selection_field.name.as_ref() + )) + } }), } } @@ -1556,12 +1580,16 @@ where return Err("Internal Error: Expected AggregateType in to_aggregate_builder".to_string()); }; - let alias = query_field.alias.as_ref().map_or(field.name_.as_str(), |x| x.as_ref()).to_string(); + let alias = query_field + .alias + .as_ref() + .map_or(field.name_.as_str(), |x| x.as_ref()) + .to_string(); let mut selections = Vec::new(); let field_map = field_map(&type_); // Get fields of the AggregateType (count, sum, avg, etc.) for item in query_field.selection_set.items.iter() { - match item { + match item { Selection::Field(query_sub_field) => { let field_name = query_sub_field.name.as_ref(); let sub_field = field_map.get(field_name).ok_or(format!( @@ -1569,7 +1597,11 @@ where field_name, type_.name().unwrap_or_default() ))?; - let sub_alias = query_sub_field.alias.as_ref().map_or(sub_field.name_.as_str(), |x| x.as_ref()).to_string(); + let sub_alias = query_sub_field + .alias + .as_ref() + .map_or(sub_field.name_.as_str(), |x| x.as_ref()) + .to_string(); match field_name { "count" => selections.push(AggregateSelection::Count { alias: sub_alias }), @@ -1579,13 +1611,25 @@ where query_sub_field, fragment_definitions, variables, - variable_definitions + variable_definitions, )?; match field_name { - "sum" => selections.push(AggregateSelection::Sum { alias: sub_alias, selections: col_selections }), - "avg" => selections.push(AggregateSelection::Avg { alias: sub_alias, selections: col_selections }), - "min" => selections.push(AggregateSelection::Min { alias: sub_alias, selections: col_selections }), - "max" => selections.push(AggregateSelection::Max { alias: sub_alias, selections: col_selections }), + "sum" => selections.push(AggregateSelection::Sum { + alias: sub_alias, + selections: col_selections, + }), + "avg" => selections.push(AggregateSelection::Avg { + alias: sub_alias, + selections: col_selections, + }), + "min" => selections.push(AggregateSelection::Min { + alias: sub_alias, + selections: col_selections, + }), + "max" => selections.push(AggregateSelection::Max { + alias: sub_alias, + selections: col_selections, + }), _ => unreachable!(), // Should not happen due to outer match } } @@ -1598,23 +1642,26 @@ where } Selection::FragmentSpread(_spread) => { // TODO: Handle fragment spreads within aggregate selection if needed - return Err("Fragment spreads within aggregate selections are not yet supported".to_string()); + return Err( + "Fragment spreads within aggregate selections are not yet supported" + .to_string(), + ); } Selection::InlineFragment(_inline_frag) => { // TODO: Handle inline fragments within aggregate selection if needed - return Err("Inline fragments within aggregate selections are not yet supported".to_string()); + return Err( + "Inline fragments within aggregate selections are not yet supported" + .to_string(), + ); } - } + } } - Ok(AggregateBuilder { - alias, - selections, - }) + Ok(AggregateBuilder { alias, selections }) } fn parse_aggregate_numeric_selections<'a, T>( - field: &__Field, // The sum/avg/min/max field itself + field: &__Field, // The sum/avg/min/max field itself query_field: &graphql_parser::query::Field<'a, T>, // The query field for sum/avg/min/max _fragment_definitions: &Vec>, _variables: &serde_json::Value, @@ -1625,8 +1672,8 @@ where T::Value: Hash, { let type_ = field.type_().unmodified_type(); - let __Type::AggregateNumeric(ref _agg_numeric_type) = type_ else { - return Err("Internal Error: Expected AggregateNumericType".to_string()); + let __Type::AggregateNumeric(ref agg_numeric_type) = type_ else { + return Err("Internal Error: Expected AggregateNumericType".to_string()); }; let mut col_selections = Vec::new(); @@ -1636,34 +1683,71 @@ where match item { Selection::Field(col_field) => { let col_name = col_field.name.as_ref(); - let sub_field = field_map.get(col_name).ok_or(format!( + + let sub_field = field_map.get(col_name); + + if sub_field.is_none() + && (matches!(agg_numeric_type.aggregate_op, AggregateOperation::Min) + || matches!(agg_numeric_type.aggregate_op, AggregateOperation::Max)) + { + // Check if this is a UUID field by looking at the table columns + for col in agg_numeric_type.table.columns.iter() { + let column_name = &col.name; + if col_name == column_name + || col_name.to_lowercase() == column_name.to_lowercase() + { + if let Some(ref type_) = col.type_ { + if type_.name == "uuid" { + return Err(format!( + "UUID fields (like \"{}\") are not supported for min/max aggregation because they don't have a meaningful natural ordering", + col_name + )); + } + } + } + } + } + + // Regular error if field not found + let sub_field = sub_field.ok_or(format!( "Unknown field \"{}\" selected on type \"{}\"", col_name, type_.name().unwrap_or_default() ))?; - // Ensure the selected field is actually a column - let __Type::Scalar(_) = sub_field.type_().unmodified_type() else { - return Err(format!("Field \"{}\" on type \"{}\" is not a scalar column", col_name, type_.name().unwrap_or_default())); - }; - // We expect the sql_type to be set for columns within the numeric aggregate type's fields - // This might require adjustment in how AggregateNumericType fields are created in graphql.rs if sql_type isn't populated there - let Some(NodeSQLType::Column(column)) = sub_field.sql_type.clone() else { + // Ensure the selected field is actually a column + let __Type::Scalar(_) = sub_field.type_().unmodified_type() else { + return Err(format!( + "Field \"{}\" on type \"{}\" is not a scalar column", + col_name, + type_.name().unwrap_or_default() + )); + }; + // We expect the sql_type to be set for columns within the numeric aggregate type's fields + // This might require adjustment in how AggregateNumericType fields are created in graphql.rs if sql_type isn't populated there + let Some(NodeSQLType::Column(column)) = sub_field.sql_type.clone() else { // We need the Arc! It should be available via the __Field's sql_type. // If it's not, the creation of AggregateNumericType fields in graphql.rs needs adjustment. - return Err(format!("Internal error: Missing column info for aggregate field '{}'", col_name)); - }; + return Err(format!( + "Internal error: Missing column info for aggregate field '{}'", + col_name + )); + }; - let alias = col_field.alias.as_ref().map_or(col_name, |x| x.as_ref()).to_string(); + let alias = col_field + .alias + .as_ref() + .map_or(col_name, |x| x.as_ref()) + .to_string(); - col_selections.push(ColumnBuilder { - alias, - column, - }); + col_selections.push(ColumnBuilder { alias, column }); } Selection::FragmentSpread(_) | Selection::InlineFragment(_) => { // TODO: Support fragments if needed within numeric aggregates - return Err("Fragments within numeric aggregate selections are not yet supported".to_string()); + return Err( + "Fragments within numeric aggregate selections are not yet supported" + .to_string(), + ); } } } From d9b000aac9e26fe0293b66a03e8bc18e8bbd09f9 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 00:45:05 -0400 Subject: [PATCH 19/53] Add docs --- docs/api.md | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/docs/api.md b/docs/api.md index 7f4ad124..114f77f1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -167,6 +167,9 @@ Connections wrap a result set with some additional metadata. # Result set edges: [BlogEdge!]! + # Aggregate functions + aggregate: BlogAggregate + } ``` @@ -264,8 +267,174 @@ Connections wrap a result set with some additional metadata. The `totalCount` field is disabled by default because it can be expensive on large tables. To enable it use a [comment directive](configuration.md#totalcount) +#### Aggregates + +Aggregate functions are available on the collection's `aggregate` field. These allow you to perform calculations on the collection of records that match your filter criteria. + +The supported aggregate operations are: +- **count**: Always available, returns the number of records matching the query +- **sum**: Available for numeric fields, returns the sum of values +- **avg**: Available for numeric fields, returns the average (mean) of values +- **min**: Available for numeric, string, boolean, and date/time fields, returns the minimum value +- **max**: Available for numeric, string, boolean, and date/time fields, returns the maximum value + +**Example** + +=== "Query" + + ```graphql + { + blogCollection( + filter: { rating: { gt: 3 } } + ) { + aggregate { + count + sum { + rating + visits + } + avg { + rating + } + min { + createdAt + title + } + max { + rating + updatedAt + } + } + } + } + ``` + +=== "Response" + + ```json + { + "data": { + "blogCollection": { + "aggregate": { + "count": 5, + "sum": { + "rating": 23, + "visits": 1250 + }, + "avg": { + "rating": 4.6 + }, + "min": { + "createdAt": "2022-01-15T08:30:00Z", + "title": "A Blog Post" + }, + "max": { + "rating": 5, + "updatedAt": "2023-04-22T14:15:30Z" + } + } + } + } + } + ``` + +**GraphQL Types** +=== "BlogAggregate" + + ```graphql + """Aggregate results for `Blog`""" + type BlogAggregate { + """The number of records matching the query""" + count: Int! + + """Summation aggregates for `Blog`""" + sum: BlogSumAggregateResult + + """Average aggregates for `Blog`""" + avg: BlogAvgAggregateResult + + """Minimum aggregates for comparable fields""" + min: BlogMinAggregateResult + + """Maximum aggregates for comparable fields""" + max: BlogMaxAggregateResult + } + ``` + +=== "BlogSumAggregateResult" + + ```graphql + """Result of summation aggregation for `Blog`""" + type BlogSumAggregateResult { + """Sum of rating values""" + rating: BigFloat + + """Sum of visits values""" + visits: BigInt + + # Other numeric fields... + } + ``` + +=== "BlogAvgAggregateResult" + + ```graphql + """Result of average aggregation for `Blog`""" + type BlogAvgAggregateResult { + """Average of rating values""" + rating: BigFloat + + """Average of visits values""" + visits: BigFloat + + # Other numeric fields... + } + ``` + +=== "BlogMinAggregateResult" + + ```graphql + """Result of minimum aggregation for `Blog`""" + type BlogMinAggregateResult { + """Minimum rating value""" + rating: Float + + """Minimum title value""" + title: String + + """Minimum createdAt value""" + createdAt: Datetime + + # Other comparable fields... + } + ``` + +=== "BlogMaxAggregateResult" + + ```graphql + """Result of maximum aggregation for `Blog`""" + type BlogMaxAggregateResult { + """Maximum rating value""" + rating: Float + + """Maximum title value""" + title: String + + """Maximum updatedAt value""" + updatedAt: Datetime + + # Other comparable fields... + } + ``` + +!!! note + - The `sum` and `avg` operations are only available for numeric fields. + - The `min` and `max` operations are available for numeric, string, boolean, and date/time fields. + - The return type for `sum` depends on the input type: integer fields return `BigInt`, while other numeric fields return `BigFloat`. + - The return type for `avg` is always `BigFloat`. + - The return types for `min` and `max` match the original field types. #### Pagination From 2015b6034349b34d3b375e890478c40cb4339552 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 00:45:18 -0400 Subject: [PATCH 20/53] Add changelog --- docs/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.md b/docs/changelog.md index 5b585180..f55f68c7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -95,3 +95,4 @@ - bugfix: qualify schema refs ## master +- feature: Add support for aggregate functions (count, sum, avg, min, max) on collection types From 3c02ab08f0417c2f69033ff48e2121653c899b5f Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 00:45:43 -0400 Subject: [PATCH 21/53] Remove dumb comments --- src/graphql.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 48dd67db..d7084c37 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4408,29 +4408,26 @@ fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { return false; }; - // Helper to check if a type name is numeric based on common PostgreSQL numeric types let is_numeric = |name: &str| { matches!( name, "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money" ) }; - // Helper for common string types let is_string = |name: &str| { matches!( name, "text" | "varchar" | "char" | "bpchar" | "name" | "citext" ) }; - // Helper for common date/time types let is_datetime = |name: &str| { matches!( name, "date" | "time" | "timetz" | "timestamp" | "timestamptz" ) }; - // Helper for boolean let is_boolean = |name: &str| matches!(name, "bool"); + let is_uuid = |name: &str| matches!(name, "uuid"); match op { // Sum/Avg only make sense for numeric types From 84ae4aadfdc2dd435af5158a6d8819bfecc73e27 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 01:06:51 -0400 Subject: [PATCH 22/53] Add more tests for aggregate --- test/expected/aggregate.out | 303 ++++++++++++++++++++++++++++++++++-- test/sql/aggregate.sql | 258 ++++++++++++++++++++++++++++-- 2 files changed, 530 insertions(+), 31 deletions(-) diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out index 7d2548b8..15894bca 100644 --- a/test/expected/aggregate.out +++ b/test/expected/aggregate.out @@ -24,25 +24,25 @@ begin; -- 5 Accounts insert into public.account(email, created_at) values - ('aardvark@x.com', now()), - ('bat@x.com', now()), - ('cat@x.com', now()), - ('dog@x.com', now()), - ('elephant@x.com', now()); + ('aardvark@x.com', NOW() - INTERVAL '5 days'), + ('bat@x.com', NOW() - INTERVAL '4 days'), + ('cat@x.com', NOW() - INTERVAL '3 days'), + ('dog@x.com', NOW() - INTERVAL '2 days'), + ('elephant@x.com', NOW() - INTERVAL '1 day'); insert into blog(owner_id, name, description, created_at) values - ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', now()), - ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', now()), - ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', now()), - ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', now()); + ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', NOW() - INTERVAL '10 days'), + ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', NOW() - INTERVAL '9 days'), + ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', NOW() - INTERVAL '8 days'), + ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', NOW() - INTERVAL '7 days'); insert into blog_post (blog_id, title, body, tags, status, created_at) values - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW()), - ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW() - INTERVAL '30 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW() - INTERVAL '25 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW() - INTERVAL '20 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW() - INTERVAL '15 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW() - INTERVAL '10 days'), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW() - INTERVAL '5 days'), ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', NOW()); comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; -- Test Case 1: Basic Count on accountCollection @@ -220,9 +220,280 @@ begin; } } } - $$); + $$); + resolve +--------------------------------------------------------------- + {"data": {"blogPostCollection": {"aggregate": {"count": 7}}}} +(1 row) + + -- Test Case 10: Min/Max on non-numeric fields (string, datetime) + select graphql.resolve($$ + query { + blogCollection { + aggregate { + min { + name + description + createdAt + } + max { + name + description + createdAt + } + } + } + } + $$); + resolve +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"aggregate": {"max": {"name": "B: Blog 3", "createdAt": "2025-04-25T22:04:39.666373", "description": "b desc1"}, "min": {"name": "A: Blog 1", "createdAt": "2025-04-22T22:04:39.666373", "description": "a desc1"}}}}} +(1 row) + + -- Test Case 11: Aggregation with relationships (nested queries) + select graphql.resolve($$ + query { + accountCollection { + edges { + node { + email + blogCollection { + aggregate { + count + sum { + id + } + } + } + } + } + } + } + $$); + resolve +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"accountCollection": {"edges": [{"node": {"email": "aardvark@x.com", "blogCollection": {"aggregate": {"sum": {"id": 6}, "count": 3}}}}, {"node": {"email": "bat@x.com", "blogCollection": {"aggregate": {"sum": {"id": 4}, "count": 1}}}}, {"node": {"email": "cat@x.com", "blogCollection": {"aggregate": {"sum": {"id": null}, "count": 0}}}}, {"node": {"email": "dog@x.com", "blogCollection": {"aggregate": {"sum": {"id": null}, "count": 0}}}}, {"node": {"email": "elephant@x.com", "blogCollection": {"aggregate": {"sum": {"id": null}, "count": 0}}}}]}}} +(1 row) + + -- Test Case 12: Combination of aggregates in a complex query + select graphql.resolve($$ + query { + blogCollection { + edges { + node { + name + blogPostCollection { + aggregate { + count + min { + createdAt + } + max { + createdAt + } + } + } + } + } + aggregate { + count + min { + id + createdAt + } + max { + id + createdAt + } + sum { + id + } + avg { + id + } + } + } + } + $$); + resolve +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-07T22:04:39.666373"}, "min": {"createdAt": "2025-04-02T22:04:39.666373"}, "count": 2}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-17T22:04:39.666373"}, "min": {"createdAt": "2025-04-12T22:04:39.666373"}, "count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-22T22:04:39.666373"}, "min": {"createdAt": "2025-04-22T22:04:39.666373"}, "count": 1}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-05-02T22:04:39.666373"}, "min": {"createdAt": "2025-04-27T22:04:39.666373"}, "count": 2}}}}], "aggregate": {"avg": {"id": 2.5}, "max": {"id": 4, "createdAt": "2025-04-25T22:04:39.666373"}, "min": {"id": 1, "createdAt": "2025-04-22T22:04:39.666373"}, "sum": {"id": 10}, "count": 4}}}} +(1 row) + + -- Test Case 13: Complex filters with aggregates using AND/OR/NOT + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + or: [ + {status: {eq: RELEASED}}, + {title: {startsWith: "Post"}} + ] + } + ) { + aggregate { + count + } + } + } + $$); resolve --------------------------------------------------------------- {"data": {"blogPostCollection": {"aggregate": {"count": 7}}}} (1 row) + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + and: [ + {status: {eq: PENDING}}, + {not: {blogId: {eq: 4}}} + ] + } + ) { + aggregate { + count + } + } + } + $$); + resolve +--------------------------------------------------------------- + {"data": {"blogPostCollection": {"aggregate": {"count": 2}}}} +(1 row) + + -- Test Case 14: Array field aggregation (on tags array) + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + tags: {contains: "tech"} + } + ) { + aggregate { + count + } + } + } + $$); + resolve +--------------------------------------------------------------- + {"data": {"blogPostCollection": {"aggregate": {"count": 3}}}} +(1 row) + + -- Test Case 15: UUID field aggregation + -- This test verifies that UUID fields are intentionally excluded from min/max aggregation. + -- UUIDs don't have a meaningful natural ordering for aggregation purposes, so they're explicitly + -- excluded from the list of types that can be aggregated with min/max. + select graphql.resolve($$ + query { + blogPostCollection { + aggregate { + min { + id + } + max { + id + } + } + } + } + $$); + resolve +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": null, "errors": [{"message": "UUID fields (like \"id\") are not supported for min/max aggregation because they don't have a meaningful natural ordering"}]} +(1 row) + + -- Test Case 16: Edge case - Empty result set with aggregates + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + title: {eq: "This title does not exist"} + } + ) { + aggregate { + count + min { + createdAt + } + max { + createdAt + } + } + } + } + $$); + resolve +----------------------------------------------------------------------------------------------------------------------- + {"data": {"blogPostCollection": {"aggregate": {"max": {"createdAt": null}, "min": {"createdAt": null}, "count": 0}}}} +(1 row) + + -- Test Case 17: Filtering on aggregate results (verify all posts with RELEASED status) + select graphql.resolve($$ + query { + blogPostCollection( + filter: {status: {eq: RELEASED}} + ) { + aggregate { + count + } + } + } + $$); + resolve +--------------------------------------------------------------- + {"data": {"blogPostCollection": {"aggregate": {"count": 4}}}} +(1 row) + + -- Test Case 18: Aggregates on filtered relationships + select graphql.resolve($$ + query { + blogCollection { + edges { + node { + name + blogPostCollection( + filter: {status: {eq: RELEASED}} + ) { + aggregate { + count + } + } + } + } + } + } + $$); + resolve +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"count": 1}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"count": 0}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"count": 1}}}}]}}} +(1 row) + + -- Test Case 19: Check aggregates work with pagination (should ignore pagination for aggregates) + select graphql.resolve($$ + query { + blogPostCollection(first: 2, offset: 1) { + edges { + node { + title + } + } + aggregate { + count + min { + createdAt + } + max { + createdAt + } + } + } + } + $$); + resolve +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + {"data": {"blogPostCollection": {"edges": [{"node": {"title": "Post 2 in A Blog 1"}}, {"node": {"title": "Post 1 in A Blog 3"}}], "aggregate": {"max": {"createdAt": "2025-05-02T22:04:39.666373"}, "min": {"createdAt": "2025-04-02T22:04:39.666373"}, "count": 7}}}} +(1 row) + diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index 12b7e793..a959a24a 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -33,27 +33,27 @@ begin; -- 5 Accounts insert into public.account(email, created_at) values - ('aardvark@x.com', now()), - ('bat@x.com', now()), - ('cat@x.com', now()), - ('dog@x.com', now()), - ('elephant@x.com', now()); + ('aardvark@x.com', NOW() - INTERVAL '5 days'), + ('bat@x.com', NOW() - INTERVAL '4 days'), + ('cat@x.com', NOW() - INTERVAL '3 days'), + ('dog@x.com', NOW() - INTERVAL '2 days'), + ('elephant@x.com', NOW() - INTERVAL '1 day'); insert into blog(owner_id, name, description, created_at) values - ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', now()), - ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', now()), - ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', now()), - ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', now()); + ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', NOW() - INTERVAL '10 days'), + ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', NOW() - INTERVAL '9 days'), + ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', NOW() - INTERVAL '8 days'), + ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', NOW() - INTERVAL '7 days'); insert into blog_post (blog_id, title, body, tags, status, created_at) values - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW()), - ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW()), - ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW()), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW() - INTERVAL '30 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW() - INTERVAL '25 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW() - INTERVAL '20 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW() - INTERVAL '15 days'), + ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW() - INTERVAL '10 days'), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW() - INTERVAL '5 days'), ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', NOW()); @@ -206,4 +206,232 @@ begin; } } } + $$); + + -- Test Case 10: Min/Max on non-numeric fields (string, datetime) + select graphql.resolve($$ + query { + blogCollection { + aggregate { + min { + name + description + createdAt + } + max { + name + description + createdAt + } + } + } + } + $$); + + -- Test Case 11: Aggregation with relationships (nested queries) + select graphql.resolve($$ + query { + accountCollection { + edges { + node { + email + blogCollection { + aggregate { + count + sum { + id + } + } + } + } + } + } + } + $$); + + -- Test Case 12: Combination of aggregates in a complex query + select graphql.resolve($$ + query { + blogCollection { + edges { + node { + name + blogPostCollection { + aggregate { + count + min { + createdAt + } + max { + createdAt + } + } + } + } + } + aggregate { + count + min { + id + createdAt + } + max { + id + createdAt + } + sum { + id + } + avg { + id + } + } + } + } + $$); + + -- Test Case 13: Complex filters with aggregates using AND/OR/NOT + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + or: [ + {status: {eq: RELEASED}}, + {title: {startsWith: "Post"}} + ] + } + ) { + aggregate { + count + } + } + } + $$); + + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + and: [ + {status: {eq: PENDING}}, + {not: {blogId: {eq: 4}}} + ] + } + ) { + aggregate { + count + } + } + } + $$); + + -- Test Case 14: Array field aggregation (on tags array) + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + tags: {contains: "tech"} + } + ) { + aggregate { + count + } + } + } + $$); + + -- Test Case 15: UUID field aggregation + -- This test verifies that UUID fields are intentionally excluded from min/max aggregation. + -- UUIDs don't have a meaningful natural ordering for aggregation purposes, so they're explicitly + -- excluded from the list of types that can be aggregated with min/max. + select graphql.resolve($$ + query { + blogPostCollection { + aggregate { + min { + id + } + max { + id + } + } + } + } + $$); + + -- Test Case 16: Edge case - Empty result set with aggregates + select graphql.resolve($$ + query { + blogPostCollection( + filter: { + title: {eq: "This title does not exist"} + } + ) { + aggregate { + count + min { + createdAt + } + max { + createdAt + } + } + } + } + $$); + + -- Test Case 17: Filtering on aggregate results (verify all posts with RELEASED status) + select graphql.resolve($$ + query { + blogPostCollection( + filter: {status: {eq: RELEASED}} + ) { + aggregate { + count + } + } + } + $$); + + -- Test Case 18: Aggregates on filtered relationships + select graphql.resolve($$ + query { + blogCollection { + edges { + node { + name + blogPostCollection( + filter: {status: {eq: RELEASED}} + ) { + aggregate { + count + } + } + } + } + } + } + $$); + + + -- Test Case 19: Check aggregates work with pagination (should ignore pagination for aggregates) + select graphql.resolve($$ + query { + blogPostCollection(first: 2, offset: 1) { + edges { + node { + title + } + } + aggregate { + count + min { + createdAt + } + max { + createdAt + } + } + } + } $$); \ No newline at end of file From 247e4e452843dc29223825d03fe89134ae7eccc8 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 01:09:28 -0400 Subject: [PATCH 23/53] Revert changes to installcheck --- bin/installcheck | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bin/installcheck b/bin/installcheck index 62d3734d..9ab8e846 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -11,7 +11,7 @@ export PGDATABASE=postgres export PGTZ=UTC export PG_COLOR=auto -PATH=~/.pgrx/16.8/pgrx-install/bin/:$PATH +# PATH=~/.pgrx/15.1/pgrx-install/bin/:$PATH #################### # Ensure Clean Env # @@ -31,8 +31,6 @@ pg_ctl start -o "-F -c listen_addresses=\"\" -c log_min_messages=WARNING -k $PGD # Create the test db createdb contrib_regression -cargo pgrx install - ######### # Tests # ######### From 07d53b52b857b241849a63a3bed831f9b6b01154 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Sat, 3 May 2025 01:26:35 -0400 Subject: [PATCH 24/53] Remove pagination test (for now) --- test/expected/aggregate.out | 70 ++++++++++++------------------------- test/sql/aggregate.sql | 54 +++++++++------------------- 2 files changed, 38 insertions(+), 86 deletions(-) diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out index 15894bca..95115516 100644 --- a/test/expected/aggregate.out +++ b/test/expected/aggregate.out @@ -24,26 +24,26 @@ begin; -- 5 Accounts insert into public.account(email, created_at) values - ('aardvark@x.com', NOW() - INTERVAL '5 days'), - ('bat@x.com', NOW() - INTERVAL '4 days'), - ('cat@x.com', NOW() - INTERVAL '3 days'), - ('dog@x.com', NOW() - INTERVAL '2 days'), - ('elephant@x.com', NOW() - INTERVAL '1 day'); + ('aardvark@x.com', '2025-04-27 12:00:00'), + ('bat@x.com', '2025-04-28 12:00:00'), + ('cat@x.com', '2025-04-29 12:00:00'), + ('dog@x.com', '2025-04-30 12:00:00'), + ('elephant@x.com', '2025-05-01 12:00:00'); insert into blog(owner_id, name, description, created_at) values - ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', NOW() - INTERVAL '10 days'), - ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', NOW() - INTERVAL '9 days'), - ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', NOW() - INTERVAL '8 days'), - ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', NOW() - INTERVAL '7 days'); + ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', '2025-04-22 12:00:00'), + ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', '2025-04-23 12:00:00'), + ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', '2025-04-24 12:00:00'), + ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', '2025-04-25 12:00:00'); insert into blog_post (blog_id, title, body, tags, status, created_at) values - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW() - INTERVAL '30 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW() - INTERVAL '25 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW() - INTERVAL '20 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW() - INTERVAL '15 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW() - INTERVAL '10 days'), - ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW() - INTERVAL '5 days'), - ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', NOW()); + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', '2025-04-02 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', '2025-04-07 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', '2025-04-12 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', '2025-04-17 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', '2025-04-22 12:00:00'), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', '2025-04-27 12:00:00'), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', '2025-05-02 12:00:00'); comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; -- Test Case 1: Basic Count on accountCollection select graphql.resolve($$ @@ -245,9 +245,9 @@ begin; } } $$); - resolve ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ - {"data": {"blogCollection": {"aggregate": {"max": {"name": "B: Blog 3", "createdAt": "2025-04-25T22:04:39.666373", "description": "b desc1"}, "min": {"name": "A: Blog 1", "createdAt": "2025-04-22T22:04:39.666373", "description": "a desc1"}}}}} + resolve +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"aggregate": {"max": {"name": "B: Blog 3", "createdAt": "2025-04-25T12:00:00", "description": "b desc1"}, "min": {"name": "A: Blog 1", "createdAt": "2025-04-22T12:00:00", "description": "a desc1"}}}}} (1 row) -- Test Case 11: Aggregation with relationships (nested queries) @@ -315,9 +315,9 @@ begin; } } $$); - resolve ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ - {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-07T22:04:39.666373"}, "min": {"createdAt": "2025-04-02T22:04:39.666373"}, "count": 2}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-17T22:04:39.666373"}, "min": {"createdAt": "2025-04-12T22:04:39.666373"}, "count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-22T22:04:39.666373"}, "min": {"createdAt": "2025-04-22T22:04:39.666373"}, "count": 1}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-05-02T22:04:39.666373"}, "min": {"createdAt": "2025-04-27T22:04:39.666373"}, "count": 2}}}}], "aggregate": {"avg": {"id": 2.5}, "max": {"id": 4, "createdAt": "2025-04-25T22:04:39.666373"}, "min": {"id": 1, "createdAt": "2025-04-22T22:04:39.666373"}, "sum": {"id": 10}, "count": 4}}}} + resolve +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-07T12:00:00"}, "min": {"createdAt": "2025-04-02T12:00:00"}, "count": 2}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-17T12:00:00"}, "min": {"createdAt": "2025-04-12T12:00:00"}, "count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-04-22T12:00:00"}, "min": {"createdAt": "2025-04-22T12:00:00"}, "count": 1}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"max": {"createdAt": "2025-05-02T12:00:00"}, "min": {"createdAt": "2025-04-27T12:00:00"}, "count": 2}}}}], "aggregate": {"avg": {"id": 2.5}, "max": {"id": 4, "createdAt": "2025-04-25T12:00:00"}, "min": {"id": 1, "createdAt": "2025-04-22T12:00:00"}, "sum": {"id": 10}, "count": 4}}}} (1 row) -- Test Case 13: Complex filters with aggregates using AND/OR/NOT @@ -471,29 +471,3 @@ begin; {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"count": 1}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"count": 0}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"count": 1}}}}]}}} (1 row) - -- Test Case 19: Check aggregates work with pagination (should ignore pagination for aggregates) - select graphql.resolve($$ - query { - blogPostCollection(first: 2, offset: 1) { - edges { - node { - title - } - } - aggregate { - count - min { - createdAt - } - max { - createdAt - } - } - } - } - $$); - resolve ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - {"data": {"blogPostCollection": {"edges": [{"node": {"title": "Post 2 in A Blog 1"}}, {"node": {"title": "Post 1 in A Blog 3"}}], "aggregate": {"max": {"createdAt": "2025-05-02T22:04:39.666373"}, "min": {"createdAt": "2025-04-02T22:04:39.666373"}, "count": 7}}}} -(1 row) - diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index a959a24a..aa139ab3 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -33,28 +33,28 @@ begin; -- 5 Accounts insert into public.account(email, created_at) values - ('aardvark@x.com', NOW() - INTERVAL '5 days'), - ('bat@x.com', NOW() - INTERVAL '4 days'), - ('cat@x.com', NOW() - INTERVAL '3 days'), - ('dog@x.com', NOW() - INTERVAL '2 days'), - ('elephant@x.com', NOW() - INTERVAL '1 day'); + ('aardvark@x.com', '2025-04-27 12:00:00'), + ('bat@x.com', '2025-04-28 12:00:00'), + ('cat@x.com', '2025-04-29 12:00:00'), + ('dog@x.com', '2025-04-30 12:00:00'), + ('elephant@x.com', '2025-05-01 12:00:00'); insert into blog(owner_id, name, description, created_at) values - ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', NOW() - INTERVAL '10 days'), - ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', NOW() - INTERVAL '9 days'), - ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', NOW() - INTERVAL '8 days'), - ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', NOW() - INTERVAL '7 days'); + ((select id from account where email ilike 'a%'), 'A: Blog 1', 'a desc1', '2025-04-22 12:00:00'), + ((select id from account where email ilike 'a%'), 'A: Blog 2', 'a desc2', '2025-04-23 12:00:00'), + ((select id from account where email ilike 'a%'), 'A: Blog 3', 'a desc3', '2025-04-24 12:00:00'), + ((select id from account where email ilike 'b%'), 'B: Blog 3', 'b desc1', '2025-04-25 12:00:00'); insert into blog_post (blog_id, title, body, tags, status, created_at) values - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', NOW() - INTERVAL '30 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', NOW() - INTERVAL '25 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', NOW() - INTERVAL '20 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', NOW() - INTERVAL '15 days'), - ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', NOW() - INTERVAL '10 days'), - ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', NOW() - INTERVAL '5 days'), - ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', NOW()); + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 1 in A Blog 1', 'Content for post 1 in A Blog 1', '{"tech", "update"}', 'RELEASED', '2025-04-02 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 1'), 'Post 2 in A Blog 1', 'Content for post 2 in A Blog 1', '{"announcement", "tech"}', 'PENDING', '2025-04-07 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 1 in A Blog 2', 'Content for post 1 in A Blog 2', '{"personal"}', 'RELEASED', '2025-04-12 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 2'), 'Post 2 in A Blog 2', 'Content for post 2 in A Blog 2', '{"update"}', 'RELEASED', '2025-04-17 12:00:00'), + ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', '2025-04-22 12:00:00'), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', '2025-04-27 12:00:00'), + ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', '2025-05-02 12:00:00'); comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; @@ -413,25 +413,3 @@ begin; } $$); - - -- Test Case 19: Check aggregates work with pagination (should ignore pagination for aggregates) - select graphql.resolve($$ - query { - blogPostCollection(first: 2, offset: 1) { - edges { - node { - title - } - } - aggregate { - count - min { - createdAt - } - max { - createdAt - } - } - } - } - $$); \ No newline at end of file From 3f70ecc9015bb55d9b13587d78d2813bf04b42ae Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 14:12:43 +0530 Subject: [PATCH 25/53] Update api.md Fix whitespace errors reported by pre-commit hook --- docs/api.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/api.md b/docs/api.md index 114f77f1..72062822 100644 --- a/docs/api.md +++ b/docs/api.md @@ -347,16 +347,16 @@ The supported aggregate operations are: type BlogAggregate { """The number of records matching the query""" count: Int! - + """Summation aggregates for `Blog`""" sum: BlogSumAggregateResult - + """Average aggregates for `Blog`""" avg: BlogAvgAggregateResult - + """Minimum aggregates for comparable fields""" min: BlogMinAggregateResult - + """Maximum aggregates for comparable fields""" max: BlogMaxAggregateResult } @@ -369,10 +369,10 @@ The supported aggregate operations are: type BlogSumAggregateResult { """Sum of rating values""" rating: BigFloat - + """Sum of visits values""" visits: BigInt - + # Other numeric fields... } ``` @@ -384,10 +384,10 @@ The supported aggregate operations are: type BlogAvgAggregateResult { """Average of rating values""" rating: BigFloat - + """Average of visits values""" visits: BigFloat - + # Other numeric fields... } ``` @@ -399,13 +399,13 @@ The supported aggregate operations are: type BlogMinAggregateResult { """Minimum rating value""" rating: Float - + """Minimum title value""" title: String - + """Minimum createdAt value""" createdAt: Datetime - + # Other comparable fields... } ``` @@ -417,13 +417,13 @@ The supported aggregate operations are: type BlogMaxAggregateResult { """Maximum rating value""" rating: Float - + """Maximum title value""" title: String - + """Maximum updatedAt value""" updatedAt: Datetime - + # Other comparable fields... } ``` From e7846af1116cdafd878fa9d87931b091db4b58e0 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 14:14:21 +0530 Subject: [PATCH 26/53] Update aggregate.sql Fix whitespace errors reported by pre-commit hook --- test/sql/aggregate.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index aa139ab3..9ed97116 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -340,7 +340,7 @@ begin; } $$); - -- Test Case 15: UUID field aggregation + -- Test Case 15: UUID field aggregation -- This test verifies that UUID fields are intentionally excluded from min/max aggregation. -- UUIDs don't have a meaningful natural ordering for aggregation purposes, so they're explicitly -- excluded from the list of types that can be aggregated with min/max. From 125a4d0f739d4eaef88cf95bf1826839fc456386 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 14:15:45 +0530 Subject: [PATCH 27/53] Update transpile.rs Fix whitespace errors reported by pre-commit hook --- src/transpile.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 79f2b83a..37645b23 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1197,9 +1197,9 @@ impl ConnectionBuilder { cross join __has_previous_page cross join __has_records left join __records {quoted_block_name} on true - group by - __total_count.___total_count, - __has_next_page.___has_next_page, + group by + __total_count.___total_count, + __has_next_page.___has_next_page, __has_previous_page.___has_previous_page, __has_records.has_records ) From 7f4a8674d541d3ae085ffb04749e589b379db640 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 14:22:41 +0530 Subject: [PATCH 28/53] remove repetitive information in docs Information regarding the aggregate being available for only certain types is already there in the beginning of the aggregates section. --- docs/api.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 72062822..19830faa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -430,8 +430,6 @@ The supported aggregate operations are: !!! note - - The `sum` and `avg` operations are only available for numeric fields. - - The `min` and `max` operations are available for numeric, string, boolean, and date/time fields. - The return type for `sum` depends on the input type: integer fields return `BigInt`, while other numeric fields return `BigFloat`. - The return type for `avg` is always `BigFloat`. - The return types for `min` and `max` match the original field types. From bb3d4857076a59c170b8308f254a09352d543c9f Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 14:29:18 +0530 Subject: [PATCH 29/53] add rollback at the end of aggregate.sql test file --- test/expected/aggregate.out | 3 ++- test/sql/aggregate.sql | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out index 95115516..66e5bb9f 100644 --- a/test/expected/aggregate.out +++ b/test/expected/aggregate.out @@ -382,7 +382,7 @@ begin; {"data": {"blogPostCollection": {"aggregate": {"count": 3}}}} (1 row) - -- Test Case 15: UUID field aggregation + -- Test Case 15: UUID field aggregation -- This test verifies that UUID fields are intentionally excluded from min/max aggregation. -- UUIDs don't have a meaningful natural ordering for aggregation purposes, so they're explicitly -- excluded from the list of types that can be aggregated with min/max. @@ -471,3 +471,4 @@ begin; {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"count": 1}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"count": 0}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"count": 1}}}}]}}} (1 row) +rollback; diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index 9ed97116..7e3955de 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -413,3 +413,4 @@ begin; } $$); +rollback; From deaa386b3284f6fa2cd3d13a8b7954ac4e4b223b Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 14:50:13 +0530 Subject: [PATCH 30/53] rename aggregate_selection to aggregate_builder --- src/builder.rs | 4 ++-- src/transpile.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 2c20f9ae..504b5569 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -791,7 +791,7 @@ pub struct ConnectionBuilder { //fields pub selections: Vec, - pub aggregate_selection: Option, + pub aggregate_builder: Option, pub max_rows: u64, } @@ -1553,7 +1553,7 @@ where filter, order_by, selections: builder_fields, - aggregate_selection: aggregate_builder, + aggregate_builder, max_rows, }) } diff --git a/src/transpile.rs b/src/transpile.rs index 37645b23..4b560d1a 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -917,7 +917,7 @@ impl ConnectionBuilder { quoted_block_name: &str, // param_context: &mut ParamContext, // No longer needed here ) -> Result, String> { - let Some(ref agg_builder) = self.aggregate_selection else { + let Some(ref agg_builder) = self.aggregate_builder else { return Ok(None); }; @@ -1055,7 +1055,7 @@ impl ConnectionBuilder { // Determine if aggregates are requested based on if we generated a select list let requested_aggregates = - self.aggregate_selection.is_some() && aggregate_select_list.is_some(); + self.aggregate_builder.is_some() && aggregate_select_list.is_some(); // initialized assuming forwards pagination let mut has_next_page_query = format!( @@ -1141,7 +1141,7 @@ impl ConnectionBuilder { // Clause to merge the aggregate result if requested let aggregate_merge_clause = if requested_aggregates { let agg_alias = self - .aggregate_selection + .aggregate_builder .as_ref() .map_or("aggregate".to_string(), |b| b.alias.clone()); format!( From 28b69c2dfdaab74f1fcbb23a2b54d042dc90e26b Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 15:45:50 +0530 Subject: [PATCH 31/53] rename has_numeric to has_sum_avgable --- src/graphql.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index d7084c37..e1beb248 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4546,7 +4546,7 @@ impl ___Type for AggregateType { }); // Add fields for Sum, Avg, Min, Max if there are any aggregatable columns - let has_numeric = self + let has_sum_avgable = self .table .columns .iter() @@ -4557,7 +4557,7 @@ impl ___Type for AggregateType { .iter() .any(|c| is_aggregatable(c, &AggregateOperation::Min)); - if has_numeric { + if has_sum_avgable { fields.push(__Field { name_: "sum".to_string(), type_: __Type::AggregateNumeric(AggregateNumericType { From db152a7414efd861bed019a7854fb8505b1dd03b Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 16:07:04 +0530 Subject: [PATCH 32/53] remove commented out code --- src/transpile.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 4b560d1a..5423a358 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -912,11 +912,7 @@ impl ConnectionBuilder { } // Generates the *contents* of the aggregate jsonb_build_object - fn aggregate_select_list( - &self, - quoted_block_name: &str, - // param_context: &mut ParamContext, // No longer needed here - ) -> Result, String> { + fn aggregate_select_list(&self, quoted_block_name: &str) -> Result, String> { let Some(ref agg_builder) = self.aggregate_builder else { return Ok(None); }; From 18e0402bb690d3eeabe8eb46829d5e6cfd395c30 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Tue, 6 May 2025 16:41:25 +0530 Subject: [PATCH 33/53] use lowercase sql --- src/transpile.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 5423a358..89c4536b 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1251,13 +1251,13 @@ impl PageInfoSelection { Ok(match self { Self::StartCursor { alias } => { format!( - "{}, CASE WHEN __has_records.has_records THEN (array_agg({cursor_clause} order by {order_by_clause}))[1] ELSE NULL END", + "{}, case when __has_records.has_records then (array_agg({cursor_clause} order by {order_by_clause}))[1] else null end", quote_literal(alias) ) } Self::EndCursor { alias } => { format!( - "{}, CASE WHEN __has_records.has_records THEN (array_agg({cursor_clause} order by {order_by_clause_reversed}))[1] ELSE NULL END", + "{}, case when __has_records.has_records then (array_agg({cursor_clause} order by {order_by_clause_reversed}))[1] else null end", quote_literal(alias) ) } @@ -1339,7 +1339,7 @@ impl EdgeBuilder { // Create a filter clause that checks if any primary key column is not NULL let filter_clause = if let Some(pk_col) = first_pk_col { format!( - "FILTER (WHERE {}.{} IS NOT NULL)", + "filter (where {}.{} is not null)", block_name, quote_ident(pk_col) ) From e7ff9e2595cc15646feb12d9b06591ff3b2418e7 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Tue, 6 May 2025 09:59:00 -0400 Subject: [PATCH 34/53] Minor changes and fixes --- src/graphql.rs | 94 +++++++++++++++++++++--------------------------- src/transpile.rs | 17 ++++++--- 2 files changed, 53 insertions(+), 58 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index e1beb248..61871a56 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1735,7 +1735,6 @@ impl ___Type for ConnectionType { type_: Box::new(__Type::Scalar(Scalar::Int)), }), args: vec![], - // Using description from the user's provided removed block description: Some( "The total number of records matching the `filter` criteria".to_string(), ), @@ -4408,33 +4407,14 @@ fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { return false; }; - let is_numeric = |name: &str| { - matches!( - name, - "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money" - ) - }; - let is_string = |name: &str| { - matches!( - name, - "text" | "varchar" | "char" | "bpchar" | "name" | "citext" - ) - }; - let is_datetime = |name: &str| { - matches!( - name, - "date" | "time" | "timetz" | "timestamp" | "timestamptz" - ) - }; - let is_boolean = |name: &str| matches!(name, "bool"); - let is_uuid = |name: &str| matches!(name, "uuid"); + // Removed duplicated closures, will use helper functions below match op { // Sum/Avg only make sense for numeric types AggregateOperation::Sum | AggregateOperation::Avg => { // Check category first for arrays/enums, then check name for base types match type_.category { - TypeCategory::Other => is_numeric(&type_.name), + TypeCategory::Other => is_pg_numeric_type(&type_.name), _ => false, // Only allow sum/avg on base numeric types for now } } @@ -4442,10 +4422,10 @@ fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { AggregateOperation::Min | AggregateOperation::Max => { match type_.category { TypeCategory::Other => { - is_numeric(&type_.name) - || is_string(&type_.name) - || is_datetime(&type_.name) - || is_boolean(&type_.name) + is_pg_numeric_type(&type_.name) + || is_pg_string_type(&type_.name) + || is_pg_datetime_type(&type_.name) + || is_pg_boolean_type(&type_.name) } _ => false, // Don't allow min/max on composites, arrays, tables, pseudo } @@ -4459,54 +4439,36 @@ fn aggregate_result_type(column: &Column, op: &AggregateOperation) -> Option { - // SUM of integers often results in bigint, SUM of float/numeric results in numeric/bigfloat + // SUM of integers often results in bigint + // SUM of float/numeric results in bigfloat // Let's simplify and return BigInt for int-like, BigFloat otherwise if matches!(type_.name.as_str(), "int2" | "int4" | "int8") { Some(Scalar::BigInt) - } else if is_numeric(&type_.name) { + } else if is_pg_numeric_type(&type_.name) { Some(Scalar::BigFloat) } else { None } } AggregateOperation::Avg => { - if is_numeric(&type_.name) { + if is_pg_numeric_type(&type_.name) { Some(Scalar::BigFloat) } else { None } } AggregateOperation::Min | AggregateOperation::Max => { - if is_numeric(&type_.name) { + if is_pg_numeric_type(&type_.name) { sql_type_to_scalar(&type_.name, column.max_characters) - } else if is_string(&type_.name) { + } else if is_pg_string_type(&type_.name) { Some(Scalar::String(column.max_characters)) - } else if is_datetime(&type_.name) { + } else if is_pg_datetime_type(&type_.name) { sql_type_to_scalar(&type_.name, column.max_characters) - } else if is_boolean(&type_.name) { + } else if is_pg_boolean_type(&type_.name) { Some(Scalar::Boolean) } else { None @@ -4697,3 +4659,29 @@ fn sql_type_to_scalar(sql_type_name: &str, typmod: Option) -> Option Some(Scalar::Opaque), // Fallback for unknown types } } + +// Helper functions for PostgreSQL type checking (extracted to deduplicate) +fn is_pg_numeric_type(name: &str) -> bool { + matches!( + name, + "int2" | "int4" | "int8" | "float4" | "float8" | "numeric" | "decimal" | "money" + ) +} + +fn is_pg_string_type(name: &str) -> bool { + matches!( + name, + "text" | "varchar" | "char" | "bpchar" | "name" | "citext" + ) +} + +fn is_pg_datetime_type(name: &str) -> bool { + matches!( + name, + "date" | "time" | "timetz" | "timestamp" | "timestamptz" + ) +} + +fn is_pg_boolean_type(name: &str) -> bool { + matches!(name, "bool") +} diff --git a/src/transpile.rs b/src/transpile.rs index 89c4536b..fa740c11 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -934,7 +934,12 @@ impl ConnectionBuilder { AggregateSelection::Avg { .. } => "avg", AggregateSelection::Min { .. } => "min", AggregateSelection::Max { .. } => "max", - _ => unreachable!(), + AggregateSelection::Count { .. } => { + unreachable!("Count should be handled by its own arm") + } + AggregateSelection::Typename { .. } => { + unreachable!("Typename should be handled by its own arm") + } }; let mut field_selections = vec![]; @@ -946,7 +951,7 @@ impl ConnectionBuilder { let col_sql_casted = if pg_func == "avg" { format!("{}::numeric", col_sql) } else { - col_sql.clone() + col_sql }; // Produces: 'col_alias', agg_func(col) field_selections.push(format!( @@ -1050,8 +1055,7 @@ impl ConnectionBuilder { let offset = self.offset.unwrap_or(0); // Determine if aggregates are requested based on if we generated a select list - let requested_aggregates = - self.aggregate_builder.is_some() && aggregate_select_list.is_some(); + let requested_aggregates = aggregate_select_list.is_some(); // initialized assuming forwards pagination let mut has_next_page_query = format!( @@ -1105,7 +1109,10 @@ impl ConnectionBuilder { // Build aggregate CTE if requested let aggregate_cte = if requested_aggregates { - let select_list_str = aggregate_select_list.unwrap_or_default(); // Safe unwrap due to requested_aggregates check + let select_list_str = match aggregate_select_list { + Some(list) => list, + None => String::new(), + }; format!( r#" ,__aggregates(agg_result) as ( From 0e011290bd27164da83511aec96658933a9ffffd Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Tue, 6 May 2025 10:06:59 -0400 Subject: [PATCH 35/53] Helper methods for AggregateOperation --- src/graphql.rs | 61 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 61871a56..af3dea2b 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4401,6 +4401,39 @@ pub enum AggregateOperation { // Count is handled directly in AggregateType } +impl AggregateOperation { + // Helper for descriptive terms used in descriptions + fn descriptive_term(&self) -> &str { + match self { + AggregateOperation::Sum => "summation", + AggregateOperation::Avg => "average", + AggregateOperation::Min => "minimum", + AggregateOperation::Max => "maximum", + } + } + + // Helper for capitalized descriptive terms used in field descriptions + fn capitalized_descriptive_term(&self) -> &str { + match self { + AggregateOperation::Sum => "Sum", + AggregateOperation::Avg => "Average", + AggregateOperation::Min => "Minimum", + AggregateOperation::Max => "Maximum", + } + } +} + +impl std::fmt::Display for AggregateOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AggregateOperation::Sum => write!(f, "Sum"), + AggregateOperation::Avg => write!(f, "Avg"), // GraphQL schema uses "Avg" for the type name part + AggregateOperation::Min => write!(f, "Min"), + AggregateOperation::Max => write!(f, "Max"), + } + } +} + /// Determines if a column's type is suitable for a given aggregate operation. fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { let Some(ref type_) = column.type_ else { @@ -4583,25 +4616,18 @@ impl ___Type for AggregateNumericType { fn name(&self) -> Option { let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); - let op_name = match self.aggregate_op { - AggregateOperation::Sum => "Sum", - AggregateOperation::Avg => "Avg", - AggregateOperation::Min => "Min", - AggregateOperation::Max => "Max", - }; - Some(format!("{table_base_type_name}{op_name}AggregateResult")) + // Use Display trait for op_name + Some(format!( + "{table_base_type_name}{}AggregateResult", + self.aggregate_op + )) } fn description(&self) -> Option { let table_base_type_name = &self.schema.graphql_table_base_type_name(&self.table); - let op_desc = match self.aggregate_op { - AggregateOperation::Sum => "summation", - AggregateOperation::Avg => "average", - AggregateOperation::Min => "minimum", - AggregateOperation::Max => "maximum", - }; Some(format!( - "Result of {op_desc} aggregation for `{table_base_type_name}`" + "Result of {} aggregation for `{table_base_type_name}`", + self.aggregate_op.descriptive_term() )) } @@ -4618,12 +4644,7 @@ impl ___Type for AggregateNumericType { args: vec![], description: Some(format!( "{} of {} across all matching records", - match self.aggregate_op { - AggregateOperation::Sum => "Sum", - AggregateOperation::Avg => "Average", - AggregateOperation::Min => "Minimum", - AggregateOperation::Max => "Maximum", - }, + self.aggregate_op.capitalized_descriptive_term(), field_name )), deprecation_reason: None, From 5dbeedc69e92c0d06920cf52bc7910dbf7b19fff Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Tue, 6 May 2025 10:33:21 -0400 Subject: [PATCH 36/53] - Create Aggregate variant in ConnnectionSelection - Fix AggregateSelection typename to use field instead of sub_field - Create helper function for integer type matching --- src/builder.rs | 68 +++++++++++++++++++++++++++--------------------- src/graphql.rs | 6 ++++- src/transpile.rs | 22 ++++++++++++---- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 504b5569..524e0d13 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -791,8 +791,6 @@ pub struct ConnectionBuilder { //fields pub selections: Vec, - pub aggregate_builder: Option, - pub max_rows: u64, } @@ -961,6 +959,7 @@ pub enum ConnectionSelection { Edge(EdgeBuilder), PageInfo(PageInfoBuilder), Typename { alias: String, typename: String }, + Aggregate(AggregateBuilder), } #[derive(Clone, Debug)] @@ -1463,7 +1462,6 @@ where read_argument_order_by(field, query_field, variables, variable_definitions)?; let mut builder_fields: Vec = vec![]; - let mut aggregate_builder: Option = None; let selection_fields = normalize_selection_set( &query_field.selection_set, @@ -1475,38 +1473,45 @@ where for selection_field in selection_fields { match field_map.get(selection_field.name.as_ref()) { None => return Err("unknown field in connection".to_string()), - Some(f) => builder_fields.push(match &f.type_.unmodified_type() { - __Type::Edge(_) => ConnectionSelection::Edge(to_edge_builder( - f, - &selection_field, - fragment_definitions, - variables, - variable_definitions, - )?), - __Type::PageInfo(_) => ConnectionSelection::PageInfo(to_page_info_builder( - f, - &selection_field, - fragment_definitions, - variables, - )?), - __Type::Aggregate(_) => { - aggregate_builder = Some(to_aggregate_builder( + Some(f) => match &f.type_.unmodified_type() { + __Type::Edge(_) => { + builder_fields.push(ConnectionSelection::Edge(to_edge_builder( f, &selection_field, fragment_definitions, variables, variable_definitions, - )?); - ConnectionSelection::Typename { - alias: alias_or_name(&selection_field), - typename: xtype.name().expect("connection type should have a name"), + )?)) + } + __Type::PageInfo(_) => builder_fields.push(ConnectionSelection::PageInfo( + to_page_info_builder( + f, + &selection_field, + fragment_definitions, + variables, + )?, + )), + __Type::Aggregate(_) => { + if builder_fields + .iter() + .any(|sel| matches!(sel, ConnectionSelection::Aggregate(_))) + { + return Err("Multiple aggregate selections on a single connection are not supported.".to_string()); } + let agg_builder = to_aggregate_builder( + f, + &selection_field, + fragment_definitions, + variables, + variable_definitions, + )?; + builder_fields.push(ConnectionSelection::Aggregate(agg_builder)); } __Type::Scalar(Scalar::Int) => { if selection_field.name.as_ref() == "totalCount" { - ConnectionSelection::TotalCount { + builder_fields.push(ConnectionSelection::TotalCount { alias: alias_or_name(&selection_field), - } + }); } else { return Err(format!( "Unsupported field type for connection field {}", @@ -1516,12 +1521,12 @@ where } __Type::Scalar(Scalar::String(None)) => { if selection_field.name.as_ref() == "__typename" { - ConnectionSelection::Typename { + builder_fields.push(ConnectionSelection::Typename { alias: alias_or_name(&selection_field), typename: xtype .name() .expect("connection type should have a name"), - } + }); } else { return Err(format!( "Unsupported field type for connection field {}", @@ -1535,7 +1540,7 @@ where selection_field.name.as_ref() )) } - }), + }, } } @@ -1553,7 +1558,6 @@ where filter, order_by, selections: builder_fields, - aggregate_builder, max_rows, }) } @@ -1635,7 +1639,11 @@ where } "__typename" => selections.push(AggregateSelection::Typename { alias: sub_alias, - typename: sub_field.type_().name().ok_or("Typename missing")?, + typename: field + .type_() + .name() + .ok_or("Name for aggregate field's type not found")? + .to_string(), }), _ => return Err(format!("Unknown aggregate field: {}", field_name)), } diff --git a/src/graphql.rs b/src/graphql.rs index af3dea2b..38b91b7e 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4479,7 +4479,7 @@ fn aggregate_result_type(column: &Column, op: &AggregateOperation) -> Option bool { fn is_pg_boolean_type(name: &str) -> bool { matches!(name, "bool") } + +fn is_pg_small_integer_type(name: &str) -> bool { + matches!(name, "int2" | "int4" | "int8") +} diff --git a/src/transpile.rs b/src/transpile.rs index fa740c11..06751fc1 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -879,7 +879,9 @@ impl ConnectionBuilder { }) .collect::, _>>()?; - Ok(frags.join(", ")) + // Filter out empty strings from ConnectionSelection::Aggregate to_sql + let non_empty_frags: Vec = frags.into_iter().filter(|s| !s.is_empty()).collect(); + Ok(non_empty_frags.join(", ")) } fn limit_clause(&self) -> u64 { @@ -913,7 +915,10 @@ impl ConnectionBuilder { // Generates the *contents* of the aggregate jsonb_build_object fn aggregate_select_list(&self, quoted_block_name: &str) -> Result, String> { - let Some(ref agg_builder) = self.aggregate_builder else { + let Some(agg_builder) = self.selections.iter().find_map(|sel| match sel { + ConnectionSelection::Aggregate(builder) => Some(builder), + _ => None, + }) else { return Ok(None); }; @@ -1144,9 +1149,15 @@ impl ConnectionBuilder { // Clause to merge the aggregate result if requested let aggregate_merge_clause = if requested_aggregates { let agg_alias = self - .aggregate_builder - .as_ref() - .map_or("aggregate".to_string(), |b| b.alias.clone()); + .selections + .iter() + .find_map(|sel| match sel { + ConnectionSelection::Aggregate(builder) => Some(builder.alias.clone()), + _ => None, + }) + .ok_or( + "Internal Error: Aggregate builder not found when requested_aggregates is true", + )?; format!( "|| jsonb_build_object({}, coalesce(__aggregates.agg_result, '{{}}'::jsonb))", quote_literal(&agg_alias) @@ -1319,6 +1330,7 @@ impl ConnectionSelection { Self::Typename { alias, typename } => { format!("{}, {}", quote_literal(alias), quote_literal(typename)) } + Self::Aggregate(_) => String::new(), // Aggregate is handled in the ConnectionBuilder }) } } From 75f68d623f13b4fd21df6b064c1798cd94bacf72 Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Tue, 6 May 2025 11:05:41 -0400 Subject: [PATCH 37/53] - Add support for fragment spreads and inline fragments to aggregates - Refactoring & simplification - Remove defensive UUID check - Better log & error strings --- src/builder.rs | 233 ++++++++++++++---------------------- src/transpile.rs | 14 ++- test/expected/aggregate.out | 6 +- 3 files changed, 104 insertions(+), 149 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 524e0d13..b81dd2d1 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1584,84 +1584,66 @@ where return Err("Internal Error: Expected AggregateType in to_aggregate_builder".to_string()); }; - let alias = query_field - .alias - .as_ref() - .map_or(field.name_.as_str(), |x| x.as_ref()) - .to_string(); + let alias = alias_or_name(query_field); let mut selections = Vec::new(); let field_map = field_map(&type_); // Get fields of the AggregateType (count, sum, avg, etc.) - for item in query_field.selection_set.items.iter() { - match item { - Selection::Field(query_sub_field) => { - let field_name = query_sub_field.name.as_ref(); - let sub_field = field_map.get(field_name).ok_or(format!( - "Unknown field \"{}\" selected on type \"{}\"", - field_name, - type_.name().unwrap_or_default() - ))?; - let sub_alias = query_sub_field - .alias - .as_ref() - .map_or(sub_field.name_.as_str(), |x| x.as_ref()) - .to_string(); + let type_name = type_.name().ok_or("Aggregate type has no name")?; + + let selection_fields = normalize_selection_set( + &query_field.selection_set, + fragment_definitions, + &type_name, + variables, + )?; + for selection_field in selection_fields { + let field_name = selection_field.name.as_ref(); + let sub_field = field_map.get(field_name).ok_or(format!( + "Unknown field \"{}\" selected on type \"{}\"", + field_name, type_name + ))?; + let sub_alias = alias_or_name(&selection_field); + + match field_name { + "count" => selections.push(AggregateSelection::Count { alias: sub_alias }), + "sum" | "avg" | "min" | "max" => { + let col_selections = parse_aggregate_numeric_selections( + sub_field, + &selection_field, + fragment_definitions, + variables, + variable_definitions, + )?; match field_name { - "count" => selections.push(AggregateSelection::Count { alias: sub_alias }), - "sum" | "avg" | "min" | "max" => { - let col_selections = parse_aggregate_numeric_selections( - sub_field, - query_sub_field, - fragment_definitions, - variables, - variable_definitions, - )?; - match field_name { - "sum" => selections.push(AggregateSelection::Sum { - alias: sub_alias, - selections: col_selections, - }), - "avg" => selections.push(AggregateSelection::Avg { - alias: sub_alias, - selections: col_selections, - }), - "min" => selections.push(AggregateSelection::Min { - alias: sub_alias, - selections: col_selections, - }), - "max" => selections.push(AggregateSelection::Max { - alias: sub_alias, - selections: col_selections, - }), - _ => unreachable!(), // Should not happen due to outer match - } - } - "__typename" => selections.push(AggregateSelection::Typename { + "sum" => selections.push(AggregateSelection::Sum { alias: sub_alias, - typename: field - .type_() - .name() - .ok_or("Name for aggregate field's type not found")? - .to_string(), + selections: col_selections, + }), + "avg" => selections.push(AggregateSelection::Avg { + alias: sub_alias, + selections: col_selections, + }), + "min" => selections.push(AggregateSelection::Min { + alias: sub_alias, + selections: col_selections, + }), + "max" => selections.push(AggregateSelection::Max { + alias: sub_alias, + selections: col_selections, }), - _ => return Err(format!("Unknown aggregate field: {}", field_name)), + _ => unreachable!("Outer match should cover all field names"), } } - Selection::FragmentSpread(_spread) => { - // TODO: Handle fragment spreads within aggregate selection if needed - return Err( - "Fragment spreads within aggregate selections are not yet supported" - .to_string(), - ); - } - Selection::InlineFragment(_inline_frag) => { - // TODO: Handle inline fragments within aggregate selection if needed - return Err( - "Inline fragments within aggregate selections are not yet supported" - .to_string(), - ); - } + "__typename" => selections.push(AggregateSelection::Typename { + alias: sub_alias, + typename: field + .type_() + .name() + .ok_or("Name for aggregate field's type not found")? + .to_string(), + }), + _ => return Err(format!("Unknown aggregate field: {}", field_name)), } } @@ -1669,10 +1651,10 @@ where } fn parse_aggregate_numeric_selections<'a, T>( - field: &__Field, // The sum/avg/min/max field itself - query_field: &graphql_parser::query::Field<'a, T>, // The query field for sum/avg/min/max - _fragment_definitions: &Vec>, - _variables: &serde_json::Value, + field: &__Field, + query_field: &graphql_parser::query::Field<'a, T>, + fragment_definitions: &Vec>, + variables: &serde_json::Value, _variable_definitions: &Vec>, ) -> Result, String> where @@ -1680,84 +1662,45 @@ where T::Value: Hash, { let type_ = field.type_().unmodified_type(); - let __Type::AggregateNumeric(ref agg_numeric_type) = type_ else { + let __Type::AggregateNumeric(_) = type_ else { return Err("Internal Error: Expected AggregateNumericType".to_string()); }; - let mut col_selections = Vec::new(); - let field_map = field_map(&type_); // Fields of AggregateNumericType (numeric columns) - - for item in query_field.selection_set.items.iter() { - match item { - Selection::Field(col_field) => { - let col_name = col_field.name.as_ref(); - - let sub_field = field_map.get(col_name); - - if sub_field.is_none() - && (matches!(agg_numeric_type.aggregate_op, AggregateOperation::Min) - || matches!(agg_numeric_type.aggregate_op, AggregateOperation::Max)) - { - // Check if this is a UUID field by looking at the table columns - for col in agg_numeric_type.table.columns.iter() { - let column_name = &col.name; - if col_name == column_name - || col_name.to_lowercase() == column_name.to_lowercase() - { - if let Some(ref type_) = col.type_ { - if type_.name == "uuid" { - return Err(format!( - "UUID fields (like \"{}\") are not supported for min/max aggregation because they don't have a meaningful natural ordering", - col_name - )); - } - } - } - } - } - - // Regular error if field not found - let sub_field = sub_field.ok_or(format!( - "Unknown field \"{}\" selected on type \"{}\"", - col_name, - type_.name().unwrap_or_default() - ))?; + let field_map = field_map(&type_); + let type_name = type_.name().ok_or("AggregateNumeric type has no name")?; + let selection_fields = normalize_selection_set( + &query_field.selection_set, + fragment_definitions, + &type_name, + variables, + )?; - // Ensure the selected field is actually a column - let __Type::Scalar(_) = sub_field.type_().unmodified_type() else { - return Err(format!( - "Field \"{}\" on type \"{}\" is not a scalar column", - col_name, - type_.name().unwrap_or_default() - )); - }; - // We expect the sql_type to be set for columns within the numeric aggregate type's fields - // This might require adjustment in how AggregateNumericType fields are created in graphql.rs if sql_type isn't populated there - let Some(NodeSQLType::Column(column)) = sub_field.sql_type.clone() else { - // We need the Arc! It should be available via the __Field's sql_type. - // If it's not, the creation of AggregateNumericType fields in graphql.rs needs adjustment. - return Err(format!( - "Internal error: Missing column info for aggregate field '{}'", - col_name - )); - }; + for selection_field in selection_fields { + let col_name = selection_field.name.as_ref(); + let sub_field = field_map.get(col_name).ok_or_else(|| { + format!( + "Unknown or invalid field \"{}\" selected on type \"{}\"", + col_name, type_name + ) + })?; + + let __Type::Scalar(_) = sub_field.type_().unmodified_type() else { + return Err(format!( + "Field \"{}\" on type \"{}\" is not a scalar column", + col_name, type_name + )); + }; + let Some(NodeSQLType::Column(column)) = sub_field.sql_type.clone() else { + // We need the Arc! It should be available via the __Field's sql_type. + return Err(format!( + "Internal error: Missing column info for aggregate field '{}'", + col_name + )); + }; - let alias = col_field - .alias - .as_ref() - .map_or(col_name, |x| x.as_ref()) - .to_string(); + let alias = alias_or_name(&selection_field); - col_selections.push(ColumnBuilder { alias, column }); - } - Selection::FragmentSpread(_) | Selection::InlineFragment(_) => { - // TODO: Support fragments if needed within numeric aggregates - return Err( - "Fragments within numeric aggregate selections are not yet supported" - .to_string(), - ); - } - } + col_selections.push(ColumnBuilder { alias, column }); } Ok(col_selections) } diff --git a/src/transpile.rs b/src/transpile.rs index 06751fc1..6a30472d 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1330,7 +1330,7 @@ impl ConnectionSelection { Self::Typename { alias, typename } => { format!("{}, {}", quote_literal(alias), quote_literal(typename)) } - Self::Aggregate(_) => String::new(), // Aggregate is handled in the ConnectionBuilder + Self::Aggregate(builder) => builder.to_sql(block_name, param_context)?, }) } } @@ -1913,6 +1913,18 @@ impl Serialize for __EnumValueBuilder { } } +impl AggregateBuilder { + pub fn to_sql( + &self, + _block_name: &str, + _param_context: &mut ParamContext, + ) -> Result { + // SQL generation is handled by ConnectionBuilder::aggregate_select_list + // and the results are merged in later in the process + Ok(String::new()) + } +} + #[cfg(any(test, feature = "pg_test"))] #[pgrx::pg_schema] mod tests { diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out index 66e5bb9f..54bdea21 100644 --- a/test/expected/aggregate.out +++ b/test/expected/aggregate.out @@ -400,9 +400,9 @@ begin; } } $$); - resolve ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- - {"data": null, "errors": [{"message": "UUID fields (like \"id\") are not supported for min/max aggregation because they don't have a meaningful natural ordering"}]} + resolve +---------------------------------------------------------------------------------------------------------------------------- + {"data": null, "errors": [{"message": "Unknown or invalid field \"id\" selected on type \"BlogPostMinAggregateResult\""}]} (1 row) -- Test Case 16: Edge case - Empty result set with aggregates From d2a5869bd37db88c1fc0eb00282c3a34998dd21d Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Tue, 6 May 2025 11:08:59 -0400 Subject: [PATCH 38/53] Remove unused variable_definitions args --- src/builder.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index b81dd2d1..f20e6708 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1503,7 +1503,6 @@ where &selection_field, fragment_definitions, variables, - variable_definitions, )?; builder_fields.push(ConnectionSelection::Aggregate(agg_builder)); } @@ -1573,7 +1572,6 @@ fn to_aggregate_builder<'a, T>( 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, @@ -1613,7 +1611,6 @@ where &selection_field, fragment_definitions, variables, - variable_definitions, )?; match field_name { "sum" => selections.push(AggregateSelection::Sum { @@ -1655,7 +1652,6 @@ fn parse_aggregate_numeric_selections<'a, T>( query_field: &graphql_parser::query::Field<'a, T>, fragment_definitions: &Vec>, variables: &serde_json::Value, - _variable_definitions: &Vec>, ) -> Result, String> where T: Text<'a> + Eq + AsRef + Clone, From 35d83d3fbe04ff66ccc923d51cac0e176733d56b Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 12:06:03 +0530 Subject: [PATCH 39/53] keep using the old method of pushing to builder_fields --- src/builder.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index f20e6708..74dc6f2f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1473,24 +1473,24 @@ where for selection_field in selection_fields { match field_map.get(selection_field.name.as_ref()) { None => return Err("unknown field in connection".to_string()), - Some(f) => match &f.type_.unmodified_type() { + Some(f) => builder_fields.push(match &f.type_.unmodified_type() { __Type::Edge(_) => { - builder_fields.push(ConnectionSelection::Edge(to_edge_builder( + ConnectionSelection::Edge(to_edge_builder( f, &selection_field, fragment_definitions, variables, variable_definitions, - )?)) + )?) } - __Type::PageInfo(_) => builder_fields.push(ConnectionSelection::PageInfo( + __Type::PageInfo(_) => ConnectionSelection::PageInfo( to_page_info_builder( f, &selection_field, fragment_definitions, variables, )?, - )), + ), __Type::Aggregate(_) => { if builder_fields .iter() @@ -1498,19 +1498,18 @@ where { return Err("Multiple aggregate selections on a single connection are not supported.".to_string()); } - let agg_builder = to_aggregate_builder( + ConnectionSelection::Aggregate(to_aggregate_builder( f, &selection_field, fragment_definitions, variables, - )?; - builder_fields.push(ConnectionSelection::Aggregate(agg_builder)); + )?) } __Type::Scalar(Scalar::Int) => { if selection_field.name.as_ref() == "totalCount" { - builder_fields.push(ConnectionSelection::TotalCount { + ConnectionSelection::TotalCount { alias: alias_or_name(&selection_field), - }); + } } else { return Err(format!( "Unsupported field type for connection field {}", @@ -1520,12 +1519,12 @@ where } __Type::Scalar(Scalar::String(None)) => { if selection_field.name.as_ref() == "__typename" { - builder_fields.push(ConnectionSelection::Typename { + ConnectionSelection::Typename { alias: alias_or_name(&selection_field), typename: xtype .name() .expect("connection type should have a name"), - }); + } } else { return Err(format!( "Unsupported field type for connection field {}", @@ -1539,7 +1538,7 @@ where selection_field.name.as_ref() )) } - }, + }), } } From 3fdaa4e79c7a05ed9c0456ebd03574fdad0247f0 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 12:09:06 +0530 Subject: [PATCH 40/53] remove special check for duplicate aggregate field --- src/builder.rs | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 74dc6f2f..a4ee0f72 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1474,30 +1474,20 @@ where match field_map.get(selection_field.name.as_ref()) { None => return Err("unknown field in connection".to_string()), Some(f) => builder_fields.push(match &f.type_.unmodified_type() { - __Type::Edge(_) => { - ConnectionSelection::Edge(to_edge_builder( - f, - &selection_field, - fragment_definitions, - variables, - variable_definitions, - )?) - } - __Type::PageInfo(_) => ConnectionSelection::PageInfo( - to_page_info_builder( - f, - &selection_field, - fragment_definitions, - variables, - )?, - ), + __Type::Edge(_) => ConnectionSelection::Edge(to_edge_builder( + f, + &selection_field, + fragment_definitions, + variables, + variable_definitions, + )?), + __Type::PageInfo(_) => ConnectionSelection::PageInfo(to_page_info_builder( + f, + &selection_field, + fragment_definitions, + variables, + )?), __Type::Aggregate(_) => { - if builder_fields - .iter() - .any(|sel| matches!(sel, ConnectionSelection::Aggregate(_))) - { - return Err("Multiple aggregate selections on a single connection are not supported.".to_string()); - } ConnectionSelection::Aggregate(to_aggregate_builder( f, &selection_field, From 047215e112b3514c6666080a5faef27066ac99cf Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 13:17:12 +0530 Subject: [PATCH 41/53] use Arc::clone on column instead of on Option --- src/builder.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index a4ee0f72..df2a74b4 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1675,7 +1675,7 @@ where col_name, type_name )); }; - let Some(NodeSQLType::Column(column)) = sub_field.sql_type.clone() else { + let Some(NodeSQLType::Column(column)) = &sub_field.sql_type else { // We need the Arc! It should be available via the __Field's sql_type. return Err(format!( "Internal error: Missing column info for aggregate field '{}'", @@ -1685,7 +1685,10 @@ where let alias = alias_or_name(&selection_field); - col_selections.push(ColumnBuilder { alias, column }); + col_selections.push(ColumnBuilder { + alias, + column: Arc::clone(column), + }); } Ok(col_selections) } From d53e383c5c4101c95801847166dd8efb3a7b7ae4 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 13:17:50 +0530 Subject: [PATCH 42/53] rename a function --- src/builder.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index df2a74b4..5cbcb4eb 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1595,7 +1595,7 @@ where match field_name { "count" => selections.push(AggregateSelection::Count { alias: sub_alias }), "sum" | "avg" | "min" | "max" => { - let col_selections = parse_aggregate_numeric_selections( + let col_selections = to_aggregate_column_builders( sub_field, &selection_field, fragment_definitions, @@ -1636,7 +1636,7 @@ where Ok(AggregateBuilder { alias, selections }) } -fn parse_aggregate_numeric_selections<'a, T>( +fn to_aggregate_column_builders<'a, T>( field: &__Field, query_field: &graphql_parser::query::Field<'a, T>, fragment_definitions: &Vec>, From 7785bb7bea9bef733eae497c220a726252f41da9 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 13:19:50 +0530 Subject: [PATCH 43/53] rename selections to column_builders --- src/builder.rs | 22 +++++++++++----------- src/transpile.rs | 20 ++++++++++++++++---- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 5cbcb4eb..66ab9396 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -23,19 +23,19 @@ pub enum AggregateSelection { }, Sum { alias: String, - selections: Vec, + column_builders: Vec, }, Avg { alias: String, - selections: Vec, + column_builders: Vec, }, Min { alias: String, - selections: Vec, + column_builders: Vec, }, Max { alias: String, - selections: Vec, + column_builders: Vec, }, Typename { alias: String, @@ -1604,19 +1604,19 @@ where match field_name { "sum" => selections.push(AggregateSelection::Sum { alias: sub_alias, - selections: col_selections, + column_builders: col_selections, }), "avg" => selections.push(AggregateSelection::Avg { alias: sub_alias, - selections: col_selections, + column_builders: col_selections, }), "min" => selections.push(AggregateSelection::Min { alias: sub_alias, - selections: col_selections, + column_builders: col_selections, }), "max" => selections.push(AggregateSelection::Max { alias: sub_alias, - selections: col_selections, + column_builders: col_selections, }), _ => unreachable!("Outer match should cover all field names"), } @@ -1650,7 +1650,7 @@ where let __Type::AggregateNumeric(_) = type_ else { return Err("Internal Error: Expected AggregateNumericType".to_string()); }; - let mut col_selections = Vec::new(); + let mut column_builers = Vec::new(); let field_map = field_map(&type_); let type_name = type_.name().ok_or("AggregateNumeric type has no name")?; let selection_fields = normalize_selection_set( @@ -1685,12 +1685,12 @@ where let alias = alias_or_name(&selection_field); - col_selections.push(ColumnBuilder { + column_builers.push(ColumnBuilder { alias, column: Arc::clone(column), }); } - Ok(col_selections) + Ok(column_builers) } fn to_page_info_builder<'a, T>( diff --git a/src/transpile.rs b/src/transpile.rs index 6a30472d..534f48e3 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -930,10 +930,22 @@ impl ConnectionBuilder { // Produces: 'count_alias', count(*) agg_selections.push(format!("{}, count(*)", quote_literal(alias))); } - AggregateSelection::Sum { alias, selections } - | AggregateSelection::Avg { alias, selections } - | AggregateSelection::Min { alias, selections } - | AggregateSelection::Max { alias, selections } => { + AggregateSelection::Sum { + alias, + column_builders: selections, + } + | AggregateSelection::Avg { + alias, + column_builders: selections, + } + | AggregateSelection::Min { + alias, + column_builders: selections, + } + | AggregateSelection::Max { + alias, + column_builders: selections, + } => { let pg_func = match selection { AggregateSelection::Sum { .. } => "sum", AggregateSelection::Avg { .. } => "avg", From 44bc0941d90dfe7345e93ce71ecf6584032a6d07 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 13:30:19 +0530 Subject: [PATCH 44/53] simplify nested match statements --- src/builder.rs | 68 ++++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 66ab9396..fd020da7 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1592,45 +1592,49 @@ where ))?; let sub_alias = alias_or_name(&selection_field); - match field_name { - "count" => selections.push(AggregateSelection::Count { alias: sub_alias }), - "sum" | "avg" | "min" | "max" => { - let col_selections = to_aggregate_column_builders( - sub_field, - &selection_field, - fragment_definitions, - variables, - )?; - match field_name { - "sum" => selections.push(AggregateSelection::Sum { - alias: sub_alias, - column_builders: col_selections, - }), - "avg" => selections.push(AggregateSelection::Avg { - alias: sub_alias, - column_builders: col_selections, - }), - "min" => selections.push(AggregateSelection::Min { - alias: sub_alias, - column_builders: col_selections, - }), - "max" => selections.push(AggregateSelection::Max { - alias: sub_alias, - column_builders: col_selections, - }), - _ => unreachable!("Outer match should cover all field names"), - } - } - "__typename" => selections.push(AggregateSelection::Typename { + let col_selections = if field_name == "sum" + || field_name == "avg" + || field_name == "min" + || field_name == "max" + { + to_aggregate_column_builders( + sub_field, + &selection_field, + fragment_definitions, + variables, + )? + } else { + vec![] + }; + + selections.push(match field_name { + "count" => AggregateSelection::Count { alias: sub_alias }, + "sum" => AggregateSelection::Sum { + alias: sub_alias, + column_builders: col_selections, + }, + "avg" => AggregateSelection::Avg { + alias: sub_alias, + column_builders: col_selections, + }, + "min" => AggregateSelection::Min { + alias: sub_alias, + column_builders: col_selections, + }, + "max" => AggregateSelection::Max { + alias: sub_alias, + column_builders: col_selections, + }, + "__typename" => AggregateSelection::Typename { alias: sub_alias, typename: field .type_() .name() .ok_or("Name for aggregate field's type not found")? .to_string(), - }), + }, _ => return Err(format!("Unknown aggregate field: {}", field_name)), - } + }) } Ok(AggregateBuilder { alias, selections }) From 2ddbce6cff51b7ecb08d15226f6b241485008d4c Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 13:31:05 +0530 Subject: [PATCH 45/53] remove a comment --- src/builder.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/builder.rs b/src/builder.rs index fd020da7..931d7fe6 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1680,7 +1680,6 @@ where )); }; let Some(NodeSQLType::Column(column)) = &sub_field.sql_type else { - // We need the Arc! It should be available via the __Field's sql_type. return Err(format!( "Internal error: Missing column info for aggregate field '{}'", col_name From 401782fcb89b525c9d858e60ff0be7765465895a Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 13:57:27 +0530 Subject: [PATCH 46/53] fix a couple of clippy warnings --- src/graphql.rs | 4 +--- src/transpile.rs | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 38b91b7e..5e8770e5 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4468,9 +4468,7 @@ fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { /// Returns the appropriate GraphQL scalar type for an aggregate result. fn aggregate_result_type(column: &Column, op: &AggregateOperation) -> Option { - let Some(ref type_) = column.type_ else { - return None; - }; + let type_ = column.type_.as_ref()?; // Removed duplicated closures, will use helper functions below diff --git a/src/transpile.rs b/src/transpile.rs index 534f48e3..85e67d84 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -1126,10 +1126,7 @@ impl ConnectionBuilder { // Build aggregate CTE if requested let aggregate_cte = if requested_aggregates { - let select_list_str = match aggregate_select_list { - Some(list) => list, - None => String::new(), - }; + let select_list_str = aggregate_select_list.unwrap_or_default(); format!( r#" ,__aggregates(agg_result) as ( From 57ff018b3e4440b62fe1d7573c2392e67ff7bd21 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 14:17:40 +0530 Subject: [PATCH 47/53] remove a comment --- src/graphql.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/graphql.rs b/src/graphql.rs index 5e8770e5..ab190d82 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4470,8 +4470,6 @@ fn is_aggregatable(column: &Column, op: &AggregateOperation) -> bool { fn aggregate_result_type(column: &Column, op: &AggregateOperation) -> Option { let type_ = column.type_.as_ref()?; - // Removed duplicated closures, will use helper functions below - match op { AggregateOperation::Sum => { // SUM of integers often results in bigint From 9ac53610a8387ffa42ffd62f118fbab71e303e87 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 18:03:37 +0530 Subject: [PATCH 48/53] return Option instead of empty strings to represent missing values --- src/transpile.rs | 68 ++++++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/src/transpile.rs b/src/transpile.rs index 85e67d84..062f3a80 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -869,19 +869,17 @@ impl ConnectionBuilder { let frags: Vec = self .selections .iter() - .map(|x| { + .filter_map(|x| { x.to_sql( quoted_block_name, &self.order_by, &self.source.table, param_context, - ) + ).transpose() }) .collect::, _>>()?; - // Filter out empty strings from ConnectionSelection::Aggregate to_sql - let non_empty_frags: Vec = frags.into_iter().filter(|s| !s.is_empty()).collect(); - Ok(non_empty_frags.join(", ")) + Ok(frags.join(", ")) } fn limit_clause(&self) -> u64 { @@ -1314,32 +1312,30 @@ impl ConnectionSelection { order_by: &OrderByBuilder, table: &Table, param_context: &mut ParamContext, - ) -> Result { + ) -> Result, String> { Ok(match self { - Self::Edge(x) => { - format!( - "{}, {}", - quote_literal(&x.alias), - x.to_sql(block_name, order_by, table, param_context)? - ) - } - Self::PageInfo(x) => { - format!( - "{}, {}", - quote_literal(&x.alias), - x.to_sql(block_name, order_by, table)? - ) - } - Self::TotalCount { alias } => { - format!( - "{}, coalesce(__total_count.___total_count, 0)", - quote_literal(alias), - ) - } - Self::Typename { alias, typename } => { - format!("{}, {}", quote_literal(alias), quote_literal(typename)) - } - Self::Aggregate(builder) => builder.to_sql(block_name, param_context)?, + Self::Edge(x) => Some(format!( + "{}, {}", + quote_literal(&x.alias), + x.to_sql(block_name, order_by, table, param_context)? + )), + Self::PageInfo(x) => Some(format!( + "{}, {}", + quote_literal(&x.alias), + x.to_sql(block_name, order_by, table)? + )), + Self::TotalCount { alias } => Some(format!( + "{}, coalesce(__total_count.___total_count, 0)", + quote_literal(alias), + )), + Self::Typename { alias, typename } => Some(format!( + "{}, {}", + quote_literal(alias), + quote_literal(typename) + )), + // SQL generation is handled by ConnectionBuilder::aggregate_select_list + // and the results are merged in later in the process + Self::Aggregate(_) => None, }) } } @@ -1922,18 +1918,6 @@ impl Serialize for __EnumValueBuilder { } } -impl AggregateBuilder { - pub fn to_sql( - &self, - _block_name: &str, - _param_context: &mut ParamContext, - ) -> Result { - // SQL generation is handled by ConnectionBuilder::aggregate_select_list - // and the results are merged in later in the process - Ok(String::new()) - } -} - #[cfg(any(test, feature = "pg_test"))] #[pgrx::pg_schema] mod tests { From 5b28d7398ee94f64dc5af375a303bfe8d471929f Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 7 May 2025 19:34:02 +0530 Subject: [PATCH 49/53] add a test for aliases in aggregates --- test/expected/aggregate.out | 27 +++++++++++++++++++++++++++ test/sql/aggregate.sql | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out index 54bdea21..4e9cff35 100644 --- a/test/expected/aggregate.out +++ b/test/expected/aggregate.out @@ -471,4 +471,31 @@ begin; {"data": {"blogCollection": {"edges": [{"node": {"name": "A: Blog 1", "blogPostCollection": {"aggregate": {"count": 1}}}}, {"node": {"name": "A: Blog 2", "blogPostCollection": {"aggregate": {"count": 2}}}}, {"node": {"name": "A: Blog 3", "blogPostCollection": {"aggregate": {"count": 0}}}}, {"node": {"name": "B: Blog 3", "blogPostCollection": {"aggregate": {"count": 1}}}}]}}} (1 row) + -- Test Case 19: aliases test case + select graphql.resolve($$ + query { + blogCollection { + agg: aggregate { + cnt: count + total: sum { + identifier: id + } + average: avg { + identifier: id + } + minimum: min { + identifier: id + } + maximum: max { + identifier: id + } + } + } + } + $$); + resolve +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"blogCollection": {"agg": {"cnt": 4, "total": {"identifier": 10}, "average": {"identifier": 2.5}, "maximum": {"identifier": 4}, "minimum": {"identifier": 1}}}}} +(1 row) + rollback; diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index 7e3955de..9a7d1d1f 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -413,4 +413,28 @@ begin; } $$); + + -- Test Case 19: aliases test case + select graphql.resolve($$ + query { + blogCollection { + agg: aggregate { + cnt: count + total: sum { + identifier: id + } + average: avg { + identifier: id + } + minimum: min { + identifier: id + } + maximum: max { + identifier: id + } + } + } + } + $$); + rollback; From 401fd6f67e33632c58135a61a45f392ba046221a Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Wed, 7 May 2025 16:37:36 -0400 Subject: [PATCH 50/53] Put aggregates behind comment directive --- docs/api.md | 7 +- docs/configuration.md | 35 ++++++++ sql/load_sql_context.sql | 8 ++ src/graphql.rs | 112 ++++++++++++++------------ src/sql_types.rs | 8 ++ test/expected/aggregate_directive.out | 64 +++++++++++++++ test/sql/aggregate_directive.sql | 56 +++++++++++++ 7 files changed, 237 insertions(+), 53 deletions(-) create mode 100644 test/expected/aggregate_directive.out create mode 100644 test/sql/aggregate_directive.sql diff --git a/docs/api.md b/docs/api.md index 19830faa..66a6450f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,3 @@ - In our API, each SQL table is reflected as a set of GraphQL types. At a high level, tables become types and columns/foreign keys become fields on those types. @@ -269,7 +268,7 @@ Connections wrap a result set with some additional metadata. #### Aggregates -Aggregate functions are available on the collection's `aggregate` field. These allow you to perform calculations on the collection of records that match your filter criteria. +Aggregate functions are available on the collection's `aggregate` field when enabled via [comment directive](configuration.md#aggregate). These allow you to perform calculations on the collection of records that match your filter criteria. The supported aggregate operations are: @@ -434,6 +433,10 @@ The supported aggregate operations are: - The return type for `avg` is always `BigFloat`. - The return types for `min` and `max` match the original field types. +!!! note + + The `aggregate` field is disabled by default because it can be expensive on large tables. To enable it use a [comment directive](configuration.md#Aggregate) + #### Pagination ##### Keyset Pagination diff --git a/docs/configuration.md b/docs/configuration.md index b40479fd..53993476 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,6 +95,41 @@ create table "BlogPost"( comment on table "BlogPost" is e'@graphql({"totalCount": {"enabled": true}})'; ``` +### Aggregate + +The `aggregate` field is an opt-in field that extends a table's Connection type. It provides various aggregate functions like count, sum, avg, min, and max that operate on the collection of records that match the query's filters. + +```graphql +type BlogPostConnection { + edges: [BlogPostEdge!]! + pageInfo: PageInfo! + + """Aggregate functions calculated on the collection of `BlogPost`""" + aggregate: BlogPostAggregate # this field +} +``` + +To enable the `aggregate` field for a table, use the directive: + +```sql +comment on table "BlogPost" is e'@graphql({"aggregate": {"enabled": true}})'; +``` + +For example: +```sql +create table "BlogPost"( + id serial primary key, + title varchar(255) not null, + rating int not null +); +comment on table "BlogPost" is e'@graphql({"aggregate": {"enabled": true}})'; +``` + +You can combine both totalCount and aggregate directives: + +```sql +comment on table "BlogPost" is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; +``` ### Renaming diff --git a/sql/load_sql_context.sql b/sql/load_sql_context.sql index 6f92c567..b6040788 100644 --- a/sql/load_sql_context.sql +++ b/sql/load_sql_context.sql @@ -256,6 +256,14 @@ select false ) ), + 'aggregate', jsonb_build_object( + 'enabled', coalesce( + ( + d.directive -> 'aggregate' ->> 'enabled' = 'true' + ), + false + ) + ), 'primary_key_columns', d.directive -> 'primary_key_columns', 'foreign_keys', d.directive -> 'foreign_keys' ) diff --git a/src/graphql.rs b/src/graphql.rs index ab190d82..06147774 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1745,21 +1745,26 @@ impl ___Type for ConnectionType { } } - let aggregate = __Field { - name_: "aggregate".to_string(), - type_: __Type::Aggregate(AggregateType { - table: Arc::clone(&self.table), - schema: self.schema.clone(), - }), - args: vec![], - description: Some(format!( - "Aggregate functions calculated on the collection of `{table_base_type_name}`" - )), - deprecation_reason: None, - sql_type: None, - }; + // Conditionally add aggregate based on the directive + if let Some(aggregate_directive) = self.table.directives.aggregate.as_ref() { + if aggregate_directive.enabled { + let aggregate = __Field { + name_: "aggregate".to_string(), + type_: __Type::Aggregate(AggregateType { + table: Arc::clone(&self.table), + schema: self.schema.clone(), + }), + args: vec![], + description: Some(format!( + "Aggregate functions calculated on the collection of `{table_base_type_name}`" + )), + deprecation_reason: None, + sql_type: None, + }; + fields.push(aggregate); + } + } - fields.push(aggregate); // Add aggregate last Some(fields) } } @@ -4222,43 +4227,48 @@ impl __Schema { // Add Aggregate types if the table is selectable if self.graphql_table_select_types_are_valid(table) { - types_.push(__Type::Aggregate(AggregateType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - })); - // Check if there are any columns aggregatable by sum/avg - if table - .columns - .iter() - .any(|c| is_aggregatable(c, &AggregateOperation::Sum)) - { - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Sum, - })); - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Avg, - })); - } - // Check if there are any columns aggregatable by min/max - if table - .columns - .iter() - .any(|c| is_aggregatable(c, &AggregateOperation::Min)) - { - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Min, - })); - types_.push(__Type::AggregateNumeric(AggregateNumericType { - table: Arc::clone(table), - schema: Arc::clone(&schema_rc), - aggregate_op: AggregateOperation::Max, - })); + // Only add aggregate types if the directive is enabled + if let Some(aggregate_directive) = table.directives.aggregate.as_ref() { + if aggregate_directive.enabled { + types_.push(__Type::Aggregate(AggregateType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + })); + // Check if there are any columns aggregatable by sum/avg + if table + .columns + .iter() + .any(|c| is_aggregatable(c, &AggregateOperation::Sum)) + { + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Sum, + })); + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Avg, + })); + } + // Check if there are any columns aggregatable by min/max + if table + .columns + .iter() + .any(|c| is_aggregatable(c, &AggregateOperation::Min)) + { + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Min, + })); + types_.push(__Type::AggregateNumeric(AggregateNumericType { + table: Arc::clone(table), + schema: Arc::clone(&schema_rc), + aggregate_op: AggregateOperation::Max, + })); + } + } } } } diff --git a/src/sql_types.rs b/src/sql_types.rs index 1645feec..9faff16b 100644 --- a/src/sql_types.rs +++ b/src/sql_types.rs @@ -447,6 +447,11 @@ pub struct TableDirectiveTotalCount { pub enabled: bool, } +#[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] +pub struct TableDirectiveAggregate { + pub enabled: bool, +} + #[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct TableDirectiveForeignKey { // Equivalent to ForeignKeyDirectives.local_name @@ -471,6 +476,9 @@ pub struct TableDirectives { // @graphql({"totalCount": { "enabled": true } }) pub total_count: Option, + // @graphql({"aggregate": { "enabled": true } }) + pub aggregate: Option, + // @graphql({"primary_key_columns": ["id"]}) pub primary_key_columns: Option>, diff --git a/test/expected/aggregate_directive.out b/test/expected/aggregate_directive.out new file mode 100644 index 00000000..8fa8120f --- /dev/null +++ b/test/expected/aggregate_directive.out @@ -0,0 +1,64 @@ +begin; + +-- Create a simple table without any directives +create table product( + id serial primary key, + name text not null, + price numeric not null, + stock int not null +); + +insert into product(name, price, stock) +values + ('Widget', 9.99, 100), + ('Gadget', 19.99, 50), + ('Gizmo', 29.99, 25); + +-- Try to query aggregate without enabling the directive - should fail +select graphql.resolve($$ +{ + productCollection { + aggregate { + count + } + } +} +$$); + resolve +------------------------------------------------------------------------ + {"data": null, "errors": [{"message": "unknown field in connection"}]} +(1 row) + +-- Enable aggregates +comment on table product is e'@graphql({"aggregate": {"enabled": true}})'; + +-- Now aggregates should be available - should succeed +select graphql.resolve($$ +{ + productCollection { + aggregate { + count + sum { + price + stock + } + avg { + price + } + max { + price + name + } + min { + stock + } + } + } +} +$$); + resolve +-------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"productCollection": {"aggregate": {"avg": {"price": 19.99}, "count": 3, "max": {"name": "Widget", "price": 29.99}, "min": {"stock": 25}, "sum": {"price": 59.97, "stock": 175}}}}} +(1 row) + +rollback; \ No newline at end of file diff --git a/test/sql/aggregate_directive.sql b/test/sql/aggregate_directive.sql new file mode 100644 index 00000000..0a7bdc72 --- /dev/null +++ b/test/sql/aggregate_directive.sql @@ -0,0 +1,56 @@ +begin; + +-- Create a simple table without any directives +create table product( + id serial primary key, + name text not null, + price numeric not null, + stock int not null +); + +insert into product(name, price, stock) +values + ('Widget', 9.99, 100), + ('Gadget', 19.99, 50), + ('Gizmo', 29.99, 25); + +-- Try to query aggregate without enabling the directive - should fail +select graphql.resolve($$ +{ + productCollection { + aggregate { + count + } + } +} +$$); + +-- Enable aggregates +comment on table product is e'@graphql({"aggregate": {"enabled": true}})'; + +-- Now aggregates should be available - should succeed +select graphql.resolve($$ +{ + productCollection { + aggregate { + count + sum { + price + stock + } + avg { + price + } + max { + price + name + } + min { + stock + } + } + } +} +$$); + +rollback; \ No newline at end of file From 84448584801407597a57b2e085731eb721e1474c Mon Sep 17 00:00:00 2001 From: Dale Lakes <6843636+spitfire55@users.noreply.github.com> Date: Wed, 7 May 2025 16:52:50 -0400 Subject: [PATCH 51/53] Fix tests --- test/expected/aggregate.out | 4 +- test/expected/aggregate_directive.out | 12 +- test/expected/inflection_types.out | 44 +- test/expected/omit_exotic_types.out | 370 ++--- test/expected/override_type_name.out | 11 +- test/expected/resolve___schema.out | 60 - test/expected/resolve_graphiql_schema.out | 1550 ++------------------- test/sql/aggregate.sql | 5 +- 8 files changed, 320 insertions(+), 1736 deletions(-) diff --git a/test/expected/aggregate.out b/test/expected/aggregate.out index 4e9cff35..a1cd654a 100644 --- a/test/expected/aggregate.out +++ b/test/expected/aggregate.out @@ -44,7 +44,9 @@ begin; ((SELECT id FROM blog WHERE name = 'A: Blog 3'), 'Post 1 in A Blog 3', 'Content for post 1 in A Blog 3', '{"travel", "adventure"}', 'PENDING', '2025-04-22 12:00:00'), ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', '2025-04-27 12:00:00'), ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', '2025-05-02 12:00:00'); - comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; + comment on table account is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; + comment on table blog is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; + comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; -- Test Case 1: Basic Count on accountCollection select graphql.resolve($$ query { diff --git a/test/expected/aggregate_directive.out b/test/expected/aggregate_directive.out index 8fa8120f..3e88fdd2 100644 --- a/test/expected/aggregate_directive.out +++ b/test/expected/aggregate_directive.out @@ -1,5 +1,4 @@ begin; - -- Create a simple table without any directives create table product( id serial primary key, @@ -7,13 +6,11 @@ create table product( price numeric not null, stock int not null ); - insert into product(name, price, stock) values ('Widget', 9.99, 100), ('Gadget', 19.99, 50), ('Gizmo', 29.99, 25); - -- Try to query aggregate without enabling the directive - should fail select graphql.resolve($$ { @@ -31,7 +28,6 @@ $$); -- Enable aggregates comment on table product is e'@graphql({"aggregate": {"enabled": true}})'; - -- Now aggregates should be available - should succeed select graphql.resolve($$ { @@ -56,9 +52,9 @@ select graphql.resolve($$ } } $$); - resolve --------------------------------------------------------------------------------------------------------------------------------------------------------------- - {"data": {"productCollection": {"aggregate": {"avg": {"price": 19.99}, "count": 3, "max": {"name": "Widget", "price": 29.99}, "min": {"stock": 25}, "sum": {"price": 59.97, "stock": 175}}}}} + resolve +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + {"data": {"productCollection": {"aggregate": {"avg": {"price": 19.99}, "max": {"name": "Widget", "price": 29.99}, "min": {"stock": 25}, "sum": {"price": 59.97, "stock": 175}, "count": 3}}}} (1 row) -rollback; \ No newline at end of file +rollback; diff --git a/test/expected/inflection_types.out b/test/expected/inflection_types.out index 2c46059a..33d9012e 100644 --- a/test/expected/inflection_types.out +++ b/test/expected/inflection_types.out @@ -20,24 +20,19 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "blog")' ) ); - jsonb_pretty -------------------------------- + jsonb_pretty +--------------------------- "blog_post" - "blog_postAggregate" - "blog_postAvgAggregateResult" "blog_postConnection" "blog_postDeleteResponse" "blog_postEdge" "blog_postFilter" "blog_postInsertInput" "blog_postInsertResponse" - "blog_postMaxAggregateResult" - "blog_postMinAggregateResult" "blog_postOrderBy" - "blog_postSumAggregateResult" "blog_postUpdateInput" "blog_postUpdateResponse" -(15 rows) +(10 rows) -- Inflection off, Overrides: on comment on table blog_post is e'@graphql({"name": "BlogZZZ"})'; @@ -55,24 +50,19 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "Blog")' ) ); - jsonb_pretty ------------------------------ + jsonb_pretty +------------------------- "BlogZZZ" - "BlogZZZAggregate" - "BlogZZZAvgAggregateResult" "BlogZZZConnection" "BlogZZZDeleteResponse" "BlogZZZEdge" "BlogZZZFilter" "BlogZZZInsertInput" "BlogZZZInsertResponse" - "BlogZZZMaxAggregateResult" - "BlogZZZMinAggregateResult" "BlogZZZOrderBy" - "BlogZZZSumAggregateResult" "BlogZZZUpdateInput" "BlogZZZUpdateResponse" -(15 rows) +(10 rows) rollback to savepoint a; -- Inflection on, Overrides: off @@ -91,24 +81,19 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "Blog")' ) ); - jsonb_pretty ------------------------------- + jsonb_pretty +-------------------------- "BlogPost" - "BlogPostAggregate" - "BlogPostAvgAggregateResult" "BlogPostConnection" "BlogPostDeleteResponse" "BlogPostEdge" "BlogPostFilter" "BlogPostInsertInput" "BlogPostInsertResponse" - "BlogPostMaxAggregateResult" - "BlogPostMinAggregateResult" "BlogPostOrderBy" - "BlogPostSumAggregateResult" "BlogPostUpdateInput" "BlogPostUpdateResponse" -(15 rows) +(10 rows) -- Inflection on, Overrides: on comment on table blog_post is e'@graphql({"name": "BlogZZZ"})'; @@ -126,23 +111,18 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "Blog")' ) ); - jsonb_pretty ------------------------------ + jsonb_pretty +------------------------- "BlogZZZ" - "BlogZZZAggregate" - "BlogZZZAvgAggregateResult" "BlogZZZConnection" "BlogZZZDeleteResponse" "BlogZZZEdge" "BlogZZZFilter" "BlogZZZInsertInput" "BlogZZZInsertResponse" - "BlogZZZMaxAggregateResult" - "BlogZZZMinAggregateResult" "BlogZZZOrderBy" - "BlogZZZSumAggregateResult" "BlogZZZUpdateInput" "BlogZZZUpdateResponse" -(15 rows) +(10 rows) rollback; diff --git a/test/expected/omit_exotic_types.out b/test/expected/omit_exotic_types.out index 1df359b6..4eb94751 100644 --- a/test/expected/omit_exotic_types.out +++ b/test/expected/omit_exotic_types.out @@ -34,233 +34,167 @@ begin; '$.data.__schema.types[*] ? (@.name starts with "Something")' ) ); - jsonb_pretty --------------------------------------------- - { + - "name": "Something", + - "fields": [ + - { + - "name": "nodeId" + - }, + - { + - "name": "id" + - }, + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "js" + - }, + - { + - "name": "jsb" + - } + - ], + - "inputFields": null + + jsonb_pretty +---------------------------------------- + { + + "name": "Something", + + "fields": [ + + { + + "name": "nodeId" + + }, + + { + + "name": "id" + + }, + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "js" + + }, + + { + + "name": "jsb" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingAggregate", + - "fields": [ + - { + - "name": "count" + - }, + - { + - "name": "sum" + - }, + - { + - "name": "avg" + - }, + - { + - "name": "min" + - }, + - { + - "name": "max" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingConnection", + + "fields": [ + + { + + "name": "edges" + + }, + + { + + "name": "pageInfo" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingAvgAggregateResult",+ - "fields": [ + - { + - "name": "id" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingDeleteResponse",+ + "fields": [ + + { + + "name": "affectedCount" + + }, + + { + + "name": "records" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingConnection", + - "fields": [ + - { + - "name": "edges" + - }, + - { + - "name": "pageInfo" + - }, + - { + - "name": "aggregate" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingEdge", + + "fields": [ + + { + + "name": "cursor" + + }, + + { + + "name": "node" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingDeleteResponse", + - "fields": [ + - { + - "name": "affectedCount" + - }, + - { + - "name": "records" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingFilter", + + "fields": null, + + "inputFields": [ + + { + + "name": "id" + + }, + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "nodeId" + + }, + + { + + "name": "and" + + }, + + { + + "name": "or" + + }, + + { + + "name": "not" + + } + + ] + } - { + - "name": "SomethingEdge", + - "fields": [ + - { + - "name": "cursor" + - }, + - { + - "name": "node" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingInsertInput", + + "fields": null, + + "inputFields": [ + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "js" + + }, + + { + + "name": "jsb" + + } + + ] + } - { + - "name": "SomethingFilter", + - "fields": null, + - "inputFields": [ + - { + - "name": "id" + - }, + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "nodeId" + - }, + - { + - "name": "and" + - }, + - { + - "name": "or" + - }, + - { + - "name": "not" + - } + - ] + + { + + "name": "SomethingInsertResponse",+ + "fields": [ + + { + + "name": "affectedCount" + + }, + + { + + "name": "records" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingInsertInput", + - "fields": null, + - "inputFields": [ + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "js" + - }, + - { + - "name": "jsb" + - } + - ] + + { + + "name": "SomethingOrderBy", + + "fields": null, + + "inputFields": [ + + { + + "name": "id" + + }, + + { + + "name": "name" + + } + + ] + } - { + - "name": "SomethingInsertResponse", + - "fields": [ + - { + - "name": "affectedCount" + - }, + - { + - "name": "records" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingUpdateInput", + + "fields": null, + + "inputFields": [ + + { + + "name": "name" + + }, + + { + + "name": "tags" + + }, + + { + + "name": "js" + + }, + + { + + "name": "jsb" + + } + + ] + } - { + - "name": "SomethingMaxAggregateResult",+ - "fields": [ + - { + - "name": "id" + - }, + - { + - "name": "name" + - } + - ], + - "inputFields": null + + { + + "name": "SomethingUpdateResponse",+ + "fields": [ + + { + + "name": "affectedCount" + + }, + + { + + "name": "records" + + } + + ], + + "inputFields": null + } - { + - "name": "SomethingMinAggregateResult",+ - "fields": [ + - { + - "name": "id" + - }, + - { + - "name": "name" + - } + - ], + - "inputFields": null + - } - { + - "name": "SomethingOrderBy", + - "fields": null, + - "inputFields": [ + - { + - "name": "id" + - }, + - { + - "name": "name" + - } + - ] + - } - { + - "name": "SomethingSumAggregateResult",+ - "fields": [ + - { + - "name": "id" + - } + - ], + - "inputFields": null + - } - { + - "name": "SomethingUpdateInput", + - "fields": null, + - "inputFields": [ + - { + - "name": "name" + - }, + - { + - "name": "tags" + - }, + - { + - "name": "js" + - }, + - { + - "name": "jsb" + - } + - ] + - } - { + - "name": "SomethingUpdateResponse", + - "fields": [ + - { + - "name": "affectedCount" + - }, + - { + - "name": "records" + - } + - ], + - "inputFields": null + - } -(15 rows) +(10 rows) rollback; diff --git a/test/expected/override_type_name.out b/test/expected/override_type_name.out index 6e17441b..88a3eab4 100644 --- a/test/expected/override_type_name.out +++ b/test/expected/override_type_name.out @@ -18,23 +18,18 @@ begin; '$.data.__schema.types[*].name ? (@ starts with "UserAccount")' ) ); - jsonb_pretty ---------------------------------- + jsonb_pretty +----------------------------- "UserAccount" - "UserAccountAggregate" - "UserAccountAvgAggregateResult" "UserAccountConnection" "UserAccountDeleteResponse" "UserAccountEdge" "UserAccountFilter" "UserAccountInsertInput" "UserAccountInsertResponse" - "UserAccountMaxAggregateResult" - "UserAccountMinAggregateResult" "UserAccountOrderBy" - "UserAccountSumAggregateResult" "UserAccountUpdateInput" "UserAccountUpdateResponse" -(15 rows) +(10 rows) rollback; diff --git a/test/expected/resolve___schema.out b/test/expected/resolve___schema.out index ba6344d8..5a8e4a19 100644 --- a/test/expected/resolve___schema.out +++ b/test/expected/resolve___schema.out @@ -57,14 +57,6 @@ begin; "kind": "OBJECT", + "name": "Account" + }, + - { + - "kind": "OBJECT", + - "name": "AccountAggregate" + - }, + - { + - "kind": "OBJECT", + - "name": "AccountAvgAggregateResult" + - }, + { + "kind": "OBJECT", + "name": "AccountConnection" + @@ -89,22 +81,10 @@ begin; "kind": "OBJECT", + "name": "AccountInsertResponse" + }, + - { + - "kind": "OBJECT", + - "name": "AccountMaxAggregateResult" + - }, + - { + - "kind": "OBJECT", + - "name": "AccountMinAggregateResult" + - }, + { + "kind": "INPUT_OBJECT", + "name": "AccountOrderBy" + }, + - { + - "kind": "OBJECT", + - "name": "AccountSumAggregateResult" + - }, + { + "kind": "INPUT_OBJECT", + "name": "AccountUpdateInput" + @@ -141,14 +121,6 @@ begin; "kind": "OBJECT", + "name": "Blog" + }, + - { + - "kind": "OBJECT", + - "name": "BlogAggregate" + - }, + - { + - "kind": "OBJECT", + - "name": "BlogAvgAggregateResult" + - }, + { + "kind": "OBJECT", + "name": "BlogConnection" + @@ -173,14 +145,6 @@ begin; "kind": "OBJECT", + "name": "BlogInsertResponse" + }, + - { + - "kind": "OBJECT", + - "name": "BlogMaxAggregateResult" + - }, + - { + - "kind": "OBJECT", + - "name": "BlogMinAggregateResult" + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogOrderBy" + @@ -189,14 +153,6 @@ begin; "kind": "OBJECT", + "name": "BlogPost" + }, + - { + - "kind": "OBJECT", + - "name": "BlogPostAggregate" + - }, + - { + - "kind": "OBJECT", + - "name": "BlogPostAvgAggregateResult" + - }, + { + "kind": "OBJECT", + "name": "BlogPostConnection" + @@ -221,14 +177,6 @@ begin; "kind": "OBJECT", + "name": "BlogPostInsertResponse" + }, + - { + - "kind": "OBJECT", + - "name": "BlogPostMaxAggregateResult" + - }, + - { + - "kind": "OBJECT", + - "name": "BlogPostMinAggregateResult" + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostOrderBy" + @@ -241,10 +189,6 @@ begin; "kind": "INPUT_OBJECT", + "name": "BlogPostStatusFilter" + }, + - { + - "kind": "OBJECT", + - "name": "BlogPostSumAggregateResult" + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostUpdateInput" + @@ -253,10 +197,6 @@ begin; "kind": "OBJECT", + "name": "BlogPostUpdateResponse" + }, + - { + - "kind": "OBJECT", + - "name": "BlogSumAggregateResult" + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogUpdateInput" + diff --git a/test/expected/resolve_graphiql_schema.out b/test/expected/resolve_graphiql_schema.out index 8a6f6f61..454f9465 100644 --- a/test/expected/resolve_graphiql_schema.out +++ b/test/expected/resolve_graphiql_schema.out @@ -371,114 +371,6 @@ begin; "inputFields": null, + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "AccountAggregate", + - "fields": [ + - { + - "args": [ + - ], + - "name": "count", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - "description": "The number of records matching the query", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "sum", + - "type": { + - "kind": "OBJECT", + - "name": "AccountSumAggregateResult", + - "ofType": null + - }, + - "description": "Summation aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "avg", + - "type": { + - "kind": "OBJECT", + - "name": "AccountAvgAggregateResult", + - "ofType": null + - }, + - "description": "Average aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "min", + - "type": { + - "kind": "OBJECT", + - "name": "AccountMinAggregateResult", + - "ofType": null + - }, + - "description": "Minimum aggregates for comparable fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "max", + - "type": { + - "kind": "OBJECT", + - "name": "AccountMaxAggregateResult", + - "ofType": null + - }, + - "description": "Maximum aggregates for comparable fields", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Aggregate results for `Account`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "AccountAvgAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "BigFloat", + - "ofType": null + - }, + - "description": "Average of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of average aggregation for `Account`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "OBJECT", + "name": "AccountConnection", + @@ -541,19 +433,6 @@ begin; "description": "The total number of records matching the `filter` criteria", + "isDeprecated": false, + "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "aggregate", + - "type": { + - "kind": "OBJECT", + - "name": "AccountAggregate", + - "ofType": null + - }, + - "description": "Aggregate functions calculated on the collection of `Account`", + - "isDeprecated": false, + - "deprecationReason": null + } + ], + "enumValues": [ + @@ -894,149 +773,164 @@ begin; "possibleTypes": null + }, + { + - "kind": "OBJECT", + - "name": "AccountMaxAggregateResult", + - "fields": [ + + "kind": "INPUT_OBJECT", + + "name": "AccountOrderBy", + + "fields": null, + + "enumValues": [ + + ], + + "interfaces": [ + + ], + + "description": null, + + "inputFields": [ + { + - "args": [ + - ], + "name": "id", + "type": { + - "kind": "SCALAR", + - "name": "Int", + + "kind": "ENUM", + + "name": "OrderByDirection", + "ofType": null + }, + - "description": "Maximum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + "name": "email", + "type": { + - "kind": "SCALAR", + - "name": "String", + + "kind": "ENUM", + + "name": "OrderByDirection", + "ofType": null + }, + - "description": "Maximum of email across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + "name": "encryptedPassword", + "type": { + - "kind": "SCALAR", + - "name": "String", + + "kind": "ENUM", + + "name": "OrderByDirection", + "ofType": null + }, + - "description": "Maximum of encryptedPassword across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + "name": "createdAt", + "type": { + - "kind": "SCALAR", + - "name": "Datetime", + + "kind": "ENUM", + + "name": "OrderByDirection", + "ofType": null + }, + - "description": "Maximum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + "name": "updatedAt", + "type": { + - "kind": "SCALAR", + - "name": "Datetime", + + "kind": "ENUM", + + "name": "OrderByDirection", + "ofType": null + }, + - "description": "Maximum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + } + ], + + "possibleTypes": null + + }, + + { + + "kind": "INPUT_OBJECT", + + "name": "AccountUpdateInput", + + "fields": null, + "enumValues": [ + ], + "interfaces": [ + ], + - "description": "Result of maximum aggregation for `Account`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "AccountMinAggregateResult", + - "fields": [ + + "description": null, + + "inputFields": [ + { + - "args": [ + - ], + - "name": "id", + + "name": "email", + "type": { + "kind": "SCALAR", + - "name": "Int", + + "name": "String", + "ofType": null + }, + - "description": "Minimum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + - "name": "email", + + "name": "encryptedPassword", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + - "description": "Minimum of email across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + - "name": "encryptedPassword", + + "name": "createdAt", + "type": { + "kind": "SCALAR", + - "name": "String", + + "name": "Datetime", + "ofType": null + }, + - "description": "Minimum of encryptedPassword across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "description": null, + + "defaultValue": null + }, + { + - "args": [ + - ], + - "name": "createdAt", + + "name": "updatedAt", + "type": { + "kind": "SCALAR", + "name": "Datetime", + "ofType": null + }, + - "description": "Minimum of createdAt across all matching records", + + "description": null, + + "defaultValue": null + + } + + ], + + "possibleTypes": null + + }, + + { + + "kind": "OBJECT", + + "name": "AccountUpdateResponse", + + "fields": [ + + { + + "args": [ + + ], + + "name": "affectedCount", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + "description": "Count of the records impacted by the mutation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "args": [ + ], + - "name": "updatedAt", + + "name": "records", + "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "Account", + + "ofType": null + + } + + } + + } + }, + - "description": "Minimum of updatedAt across all matching records", + + "description": "Array of records impacted by the mutation", + "isDeprecated": false, + "deprecationReason": null + } + @@ -1045,210 +939,13 @@ begin; ], + "interfaces": [ + ], + - "description": "Result of minimum aggregation for `Account`", + + "description": null, + "inputFields": null, + "possibleTypes": null + }, + { + - "kind": "INPUT_OBJECT", + - "name": "AccountOrderBy", + - "fields": null, + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": null, + - "inputFields": [ + - { + - "name": "id", + - "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "email", + - "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "encryptedPassword", + - "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "createdAt", + - "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "updatedAt", + - "type": { + - "kind": "ENUM", + - "name": "OrderByDirection", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - } + - ], + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "AccountSumAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "BigInt", + - "ofType": null + - }, + - "description": "Sum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of summation aggregation for `Account`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "INPUT_OBJECT", + - "name": "AccountUpdateInput", + - "fields": null, + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": null, + - "inputFields": [ + - { + - "name": "email", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "encryptedPassword", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - }, + - { + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": null, + - "defaultValue": null + - } + - ], + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "AccountUpdateResponse", + - "fields": [ + - { + - "args": [ + - ], + - "name": "affectedCount", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - "description": "Count of the records impacted by the mutation", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "records", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "Account", + - "ofType": null + - } + - } + - } + - }, + - "description": "Array of records impacted by the mutation", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": null, + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "SCALAR", + - "name": "BigFloat", + + "kind": "SCALAR", + + "name": "BigFloat", + "fields": null, + "enumValues": [ + ], + @@ -1943,74 +1640,47 @@ begin; }, + { + "kind": "OBJECT", + - "name": "BlogAggregate", + + "name": "BlogConnection", + "fields": [ + { + "args": [ + ], + - "name": "count", + + "name": "edges", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "BlogEdge", + + "ofType": null + + } + + } + } + }, + - "description": "The number of records matching the query", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "sum", + - "type": { + - "kind": "OBJECT", + - "name": "BlogSumAggregateResult", + - "ofType": null + - }, + - "description": "Summation aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "avg", + - "type": { + - "kind": "OBJECT", + - "name": "BlogAvgAggregateResult", + - "ofType": null + - }, + - "description": "Average aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "min", + - "type": { + - "kind": "OBJECT", + - "name": "BlogMinAggregateResult", + - "ofType": null + - }, + - "description": "Minimum aggregates for comparable fields", + + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "args": [ + ], + - "name": "max", + + "name": "pageInfo", + "type": { + - "kind": "OBJECT", + - "name": "BlogMaxAggregateResult", + - "ofType": null + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "OBJECT", + + "name": "PageInfo", + + "ofType": null + + } + }, + - "description": "Maximum aggregates for comparable fields", + + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + @@ -2019,125 +1689,18 @@ begin; ], + "interfaces": [ + ], + - "description": "Aggregate results for `Blog`", + + "description": null, + "inputFields": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + - "name": "BlogAvgAggregateResult", + + "name": "BlogDeleteResponse", + "fields": [ + { + "args": [ + ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "BigFloat", + - "ofType": null + - }, + - "description": "Average of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "ownerId", + - "type": { + - "kind": "SCALAR", + - "name": "BigFloat", + - "ofType": null + - }, + - "description": "Average of ownerId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of average aggregation for `Blog`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "BlogConnection", + - "fields": [ + - { + - "args": [ + - ], + - "name": "edges", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "BlogEdge", + - "ofType": null + - } + - } + - } + - }, + - "description": null, + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "pageInfo", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "OBJECT", + - "name": "PageInfo", + - "ofType": null + - } + - }, + - "description": null, + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "aggregate", + - "type": { + - "kind": "OBJECT", + - "name": "BlogAggregate", + - "ofType": null + - }, + - "description": "Aggregate functions calculated on the collection of `Blog`", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": null, + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "BlogDeleteResponse", + - "fields": [ + - { + - "args": [ + - ], + - "name": "affectedCount", + + "name": "affectedCount", + "type": { + "kind": "NON_NULL", + "name": null, + @@ -2503,188 +2066,6 @@ begin; "inputFields": null, + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "BlogMaxAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Maximum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "ownerId", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Maximum of ownerId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "name", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Maximum of name across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "description", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Maximum of description across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Maximum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Maximum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of maximum aggregation for `Blog`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "BlogMinAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Minimum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "ownerId", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Minimum of ownerId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "name", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Minimum of name across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "description", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Minimum of description across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Minimum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Minimum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of minimum aggregation for `Blog`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogOrderBy", + @@ -2925,114 +2306,6 @@ begin; "inputFields": null, + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "BlogPostAggregate", + - "fields": [ + - { + - "args": [ + - ], + - "name": "count", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - } + - }, + - "description": "The number of records matching the query", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "sum", + - "type": { + - "kind": "OBJECT", + - "name": "BlogPostSumAggregateResult", + - "ofType": null + - }, + - "description": "Summation aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "avg", + - "type": { + - "kind": "OBJECT", + - "name": "BlogPostAvgAggregateResult", + - "ofType": null + - }, + - "description": "Average aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "min", + - "type": { + - "kind": "OBJECT", + - "name": "BlogPostMinAggregateResult", + - "ofType": null + - }, + - "description": "Minimum aggregates for comparable fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "max", + - "type": { + - "kind": "OBJECT", + - "name": "BlogPostMaxAggregateResult", + - "ofType": null + - }, + - "description": "Maximum aggregates for comparable fields", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Aggregate results for `BlogPost`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "BlogPostAvgAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "blogId", + - "type": { + - "kind": "SCALAR", + - "name": "BigFloat", + - "ofType": null + - }, + - "description": "Average of blogId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of average aggregation for `BlogPost`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "OBJECT", + "name": "BlogPostConnection", + @@ -3078,19 +2351,6 @@ begin; "description": null, + "isDeprecated": false, + "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "aggregate", + - "type": { + - "kind": "OBJECT", + - "name": "BlogPostAggregate", + - "ofType": null + - }, + - "description": "Aggregate functions calculated on the collection of `BlogPost`", + - "isDeprecated": false, + - "deprecationReason": null + } + ], + "enumValues": [ + @@ -3480,162 +2740,6 @@ begin; "inputFields": null, + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "BlogPostMaxAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "blogId", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Maximum of blogId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "title", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Maximum of title across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "body", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Maximum of body across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Maximum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Maximum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of maximum aggregation for `BlogPost`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "BlogPostMinAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "blogId", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Minimum of blogId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "title", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Minimum of title across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "body", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Minimum of body across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Minimum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Minimum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of minimum aggregation for `BlogPost`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostOrderBy", + @@ -3804,32 +2908,6 @@ begin; ], + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "BlogPostSumAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "blogId", + - "type": { + - "kind": "SCALAR", + - "name": "BigInt", + - "ofType": null + - }, + - "description": "Sum of blogId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of summation aggregation for `BlogPost`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogPostUpdateInput", + @@ -3968,45 +3046,6 @@ begin; "inputFields": null, + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "BlogSumAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "BigInt", + - "ofType": null + - }, + - "description": "Sum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "ownerId", + - "type": { + - "kind": "SCALAR", + - "name": "BigInt", + - "ofType": null + - }, + - "description": "Sum of ownerId across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of summation aggregation for `Blog`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "INPUT_OBJECT", + "name": "BlogUpdateInput", + @@ -6084,119 +5123,32 @@ begin; "description": "Filters to apply to the results set when querying from the collection", + "defaultValue": null + }, + - { + - "name": "orderBy", + - "type": { + - "kind": "LIST", + - "name": null, + - "ofType": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "INPUT_OBJECT", + - "name": "BlogOrderBy", + - "ofType": null + - } + - } + - }, + - "description": "Sort order to apply to the collection", + - "defaultValue": null + - } + - ], + - "name": "blogs", + - "type": { + - "kind": "OBJECT", + - "name": "BlogConnection", + - "ofType": null + - }, + - "description": null, + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - { + - "kind": "INTERFACE", + - "name": "Node", + - "ofType": null + - } + - ], + - "description": null, + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "PersonAggregate", + - "fields": [ + - { + - "args": [ + - ], + - "name": "count", + - "type": { + - "kind": "NON_NULL", + - "name": null, + - "ofType": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + + { + + "name": "orderBy", + + "type": { + + "kind": "LIST", + + "name": null, + + "ofType": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "INPUT_OBJECT", + + "name": "BlogOrderBy", + + "ofType": null + + } + + } + + }, + + "description": "Sort order to apply to the collection", + + "defaultValue": null + } + - }, + - "description": "The number of records matching the query", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "sum", + - "type": { + - "kind": "OBJECT", + - "name": "PersonSumAggregateResult", + - "ofType": null + - }, + - "description": "Summation aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "avg", + - "type": { + - "kind": "OBJECT", + - "name": "PersonAvgAggregateResult", + - "ofType": null + - }, + - "description": "Average aggregates for numeric fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "min", + - "type": { + - "kind": "OBJECT", + - "name": "PersonMinAggregateResult", + - "ofType": null + - }, + - "description": "Minimum aggregates for comparable fields", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + ], + - "name": "max", + + "name": "blogs", + "type": { + "kind": "OBJECT", + - "name": "PersonMaxAggregateResult", + + "name": "BlogConnection", + "ofType": null + }, + - "description": "Maximum aggregates for comparable fields", + + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + @@ -6204,34 +5156,13 @@ begin; "enumValues": [ + ], + "interfaces": [ + - ], + - "description": "Aggregate results for `Person`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "PersonAvgAggregateResult", + - "fields": [ + { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "BigFloat", + - "ofType": null + - }, + - "description": "Average of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + + "kind": "INTERFACE", + + "name": "Node", + + "ofType": null + } + ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of average aggregation for `Person`", + + "description": null, + "inputFields": null, + "possibleTypes": null + }, + @@ -6280,19 +5211,6 @@ begin; "description": null, + "isDeprecated": false, + "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "aggregate", + - "type": { + - "kind": "OBJECT", + - "name": "PersonAggregate", + - "ofType": null + - }, + - "description": "Aggregate functions calculated on the collection of `Person`", + - "isDeprecated": false, + - "deprecationReason": null + } + ], + "enumValues": [ + @@ -6642,162 +5560,6 @@ begin; "inputFields": null, + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "PersonMaxAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Maximum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "email", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Maximum of email across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "encryptedPassword", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Maximum of encryptedPassword across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Maximum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Maximum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of maximum aggregation for `Person`", + - "inputFields": null, + - "possibleTypes": null + - }, + - { + - "kind": "OBJECT", + - "name": "PersonMinAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "Int", + - "ofType": null + - }, + - "description": "Minimum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "email", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Minimum of email across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "encryptedPassword", + - "type": { + - "kind": "SCALAR", + - "name": "String", + - "ofType": null + - }, + - "description": "Minimum of encryptedPassword across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "createdAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Minimum of createdAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - }, + - { + - "args": [ + - ], + - "name": "updatedAt", + - "type": { + - "kind": "SCALAR", + - "name": "Datetime", + - "ofType": null + - }, + - "description": "Minimum of updatedAt across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of minimum aggregation for `Person`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "INPUT_OBJECT", + "name": "PersonOrderBy", + @@ -6861,32 +5623,6 @@ begin; ], + "possibleTypes": null + }, + - { + - "kind": "OBJECT", + - "name": "PersonSumAggregateResult", + - "fields": [ + - { + - "args": [ + - ], + - "name": "id", + - "type": { + - "kind": "SCALAR", + - "name": "BigInt", + - "ofType": null + - }, + - "description": "Sum of id across all matching records", + - "isDeprecated": false, + - "deprecationReason": null + - } + - ], + - "enumValues": [ + - ], + - "interfaces": [ + - ], + - "description": "Result of summation aggregation for `Person`", + - "inputFields": null, + - "possibleTypes": null + - }, + { + "kind": "INPUT_OBJECT", + "name": "PersonUpdateInput", + diff --git a/test/sql/aggregate.sql b/test/sql/aggregate.sql index 9a7d1d1f..41d07ce5 100644 --- a/test/sql/aggregate.sql +++ b/test/sql/aggregate.sql @@ -56,8 +56,9 @@ begin; ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 1 in B Blog 3', 'Content for post 1 in B Blog 3', '{"tech", "review"}', 'RELEASED', '2025-04-27 12:00:00'), ((SELECT id FROM blog WHERE name = 'B: Blog 3'), 'Post 2 in B Blog 3', 'Content for post 2 in B Blog 3', '{"coding", "tutorial"}', 'PENDING', '2025-05-02 12:00:00'); - - comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}})'; + comment on table account is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; + comment on table blog is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; + comment on table blog_post is e'@graphql({"totalCount": {"enabled": true}, "aggregate": {"enabled": true}})'; -- Test Case 1: Basic Count on accountCollection select graphql.resolve($$ From efb7ca4887d87f759c3a70bf2da643a7b23936a1 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Thu, 8 May 2025 08:24:01 +0530 Subject: [PATCH 52/53] fix pre-commit hook errors --- docs/configuration.md | 2 +- test/expected/aggregate_directive.out | 2 +- test/sql/aggregate_directive.sql | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 53993476..395df1f0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,7 +103,7 @@ The `aggregate` field is an opt-in field that extends a table's Connection type. type BlogPostConnection { edges: [BlogPostEdge!]! pageInfo: PageInfo! - + """Aggregate functions calculated on the collection of `BlogPost`""" aggregate: BlogPostAggregate # this field } diff --git a/test/expected/aggregate_directive.out b/test/expected/aggregate_directive.out index 3e88fdd2..5c61e6a1 100644 --- a/test/expected/aggregate_directive.out +++ b/test/expected/aggregate_directive.out @@ -57,4 +57,4 @@ $$); {"data": {"productCollection": {"aggregate": {"avg": {"price": 19.99}, "max": {"name": "Widget", "price": 29.99}, "min": {"stock": 25}, "sum": {"price": 59.97, "stock": 175}, "count": 3}}}} (1 row) -rollback; +rollback; diff --git a/test/sql/aggregate_directive.sql b/test/sql/aggregate_directive.sql index 0a7bdc72..1cd87d42 100644 --- a/test/sql/aggregate_directive.sql +++ b/test/sql/aggregate_directive.sql @@ -53,4 +53,4 @@ select graphql.resolve($$ } $$); -rollback; \ No newline at end of file +rollback; From c021f402a9a9761f8e7d22d6e9dcb26b0cf35531 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Thu, 8 May 2025 08:37:23 +0530 Subject: [PATCH 53/53] improve an error message --- src/builder.rs | 10 +++++++++- test/expected/aggregate_directive.out | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 931d7fe6..adb23a60 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1472,7 +1472,15 @@ where for selection_field in selection_fields { match field_map.get(selection_field.name.as_ref()) { - None => return Err("unknown field in connection".to_string()), + None => { + let error = if selection_field.name.as_ref() == "aggregate" { + "enable the aggregate directive to use aggregates" + } else { + "unknown field in connection" + } + .to_string(); + return Err(error); + } Some(f) => builder_fields.push(match &f.type_.unmodified_type() { __Type::Edge(_) => ConnectionSelection::Edge(to_edge_builder( f, diff --git a/test/expected/aggregate_directive.out b/test/expected/aggregate_directive.out index 5c61e6a1..6f3800d0 100644 --- a/test/expected/aggregate_directive.out +++ b/test/expected/aggregate_directive.out @@ -21,9 +21,9 @@ select graphql.resolve($$ } } $$); - resolve ------------------------------------------------------------------------- - {"data": null, "errors": [{"message": "unknown field in connection"}]} + resolve +--------------------------------------------------------------------------------------------- + {"data": null, "errors": [{"message": "enable the aggregate directive to use aggregates"}]} (1 row) -- Enable aggregates