postrust_graphql/schema/aggregate.rs
1//! Aggregate types.
2//!
3//! Every table gets a second root field alongside its rows:
4//!
5//! ```graphql
6//! author_aggregate(where: {id: {_gt: 2}}) {
7//! aggregate { count, avg { salary }, max { salary } }
8//! nodes { id name }
9//! }
10//! ```
11//!
12//! `nodes` is the same list the plain root field returns, so one request can
13//! ask for a page of rows and the count of the whole set the page came from --
14//! which is the query behind every "showing 1-25 of 380" in a user interface,
15//! and the reason the two live under one field instead of two.
16//!
17//! Which functions a column gets follows from what PostgreSQL will accept.
18//! `sum` and `avg` are offered on numeric columns only; `max` and `min` on
19//! anything that orders, which includes text and timestamps. Offering `avg`
20//! over a text column would put an error in the schema rather than in the
21//! client.
22
23use crate::schema::object::TableObjectType;
24use crate::types::GraphQLType;
25use std::collections::HashMap;
26
27/// Aggregates over numbers, and the GraphQL type each returns.
28///
29/// PostgreSQL widens `sum` and keeps the column's own scale, while the
30/// statistical functions all return double precision whatever they were given.
31pub const NUMERIC_AGGREGATES: &[(&str, Returns)] = &[
32 ("sum", Returns::Column),
33 ("avg", Returns::Float),
34 ("stddev", Returns::Float),
35 ("stddev_samp", Returns::Float),
36 ("stddev_pop", Returns::Float),
37 ("variance", Returns::Float),
38 ("var_samp", Returns::Float),
39 ("var_pop", Returns::Float),
40];
41
42/// Aggregates over anything that orders.
43pub const ORDERED_AGGREGATES: &[(&str, Returns)] =
44 &[("max", Returns::Column), ("min", Returns::Column)];
45
46/// What an aggregate's field is typed as.
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub enum Returns {
49 /// The column's own type: `max(name)` is still text.
50 Column,
51 /// Double precision, whatever went in.
52 Float,
53}
54
55/// The name of a table's aggregate root type.
56pub fn aggregate_type_name(base_name: &str) -> String {
57 format!("{}_aggregate", base_name)
58}
59
60/// The name of a table's aggregate-function type.
61pub fn aggregate_fields_type_name(base_name: &str) -> String {
62 format!("{}_aggregate_fields", base_name)
63}
64
65/// The name of the type holding one aggregate's per-column results.
66pub fn function_fields_type_name(base_name: &str, function: &str) -> String {
67 format!("{}_{}_fields", base_name, function)
68}
69
70/// Whether a column can be added to.
71///
72/// Everything that can be summed, and `money` beside them: PostgreSQL adds to
73/// an amount the way it adds to a number, and Hasura offers `_inc` on one.
74/// It is not in [`is_numeric`] because the aggregates that set answers --
75/// `stddev`, `variance` and their kin -- have no `money` form, and a schema
76/// that offered them would be advertising a call the database refuses.
77pub fn is_incrementable(graphql_type: &GraphQLType) -> bool {
78 is_numeric(graphql_type) || matches!(graphql_type, GraphQLType::Custom(name) if name == "money")
79}
80
81/// Whether a column can be summed and averaged.
82pub fn is_numeric(graphql_type: &GraphQLType) -> bool {
83 matches!(
84 graphql_type,
85 GraphQLType::Int | GraphQLType::Float | GraphQLType::BigInt | GraphQLType::BigDecimal
86 )
87}
88
89/// Whether a column can be ranked, which is what `max` and `min` need.
90///
91/// Excludes the types PostgreSQL has no ordering for. `json` is the one that
92/// matters in practice: `max(details)` on a `jsonb` column is an error, not an
93/// empty result.
94pub fn is_ordered(graphql_type: &GraphQLType) -> bool {
95 match graphql_type {
96 GraphQLType::Json | GraphQLType::List(_) => false,
97 // PostgreSQL has no `max(boolean)`: the question "the largest of these
98 // flags" is asked as `bool_and`/`bool_or` instead, and Hasura leaves
99 // a boolean column out of `min` and `max` for the same reason.
100 GraphQLType::Boolean => false,
101 // A named type orders unless PostgreSQL has no operator class for it.
102 // `json` has none -- `max(details)` is an error, not an empty result.
103 GraphQLType::Custom(name) => !matches!(name.as_str(), "json" | "raster"),
104 _ => true,
105 }
106}
107
108/// The columns each aggregate function applies to, for one table.
109///
110/// Returns `(function, returns, columns)` and omits any function with no
111/// column to apply it to -- a GraphQL type may not have zero fields, so a
112/// table of nothing but text has no `sum` type at all rather than an empty
113/// one.
114pub fn functions_for(object: &TableObjectType) -> Vec<(&'static str, Returns, Vec<String>)> {
115 let mut out = Vec::new();
116
117 for (function, returns) in NUMERIC_AGGREGATES {
118 let columns: Vec<String> = object
119 .fields
120 .iter()
121 .filter(|f| is_numeric(&f.graphql_type))
122 .map(|f| f.name.clone())
123 .collect();
124 if !columns.is_empty() {
125 out.push((*function, *returns, columns));
126 }
127 }
128
129 for (function, returns) in ORDERED_AGGREGATES {
130 let columns: Vec<String> = object
131 .fields
132 .iter()
133 .filter(|f| is_ordered(&f.graphql_type))
134 .map(|f| f.name.clone())
135 .collect();
136 if !columns.is_empty() {
137 out.push((*function, *returns, columns));
138 }
139 }
140
141 out
142}
143
144/// The GraphQL type name for one column under one aggregate.
145pub fn field_type_for(object: &TableObjectType, column: &str, returns: Returns) -> String {
146 match returns {
147 Returns::Float => "Float".to_string(),
148 Returns::Column => object
149 .fields
150 .iter()
151 .find(|f| f.name == column)
152 .map(|f| f.graphql_type.to_string())
153 .unwrap_or_else(|| "String".to_string()),
154 }
155}
156
157/// Which tables get aggregate types.
158///
159/// All of them: a table with no numeric column still has `count`, which is the
160/// aggregate most requests actually want.
161pub fn tables(object_types: &HashMap<String, TableObjectType>) -> Vec<&String> {
162 let mut names: Vec<&String> = object_types.keys().collect();
163 names.sort();
164 names
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use pretty_assertions::assert_eq;
171
172 #[test]
173 fn names_follow_the_table() {
174 assert_eq!(aggregate_type_name("author"), "author_aggregate");
175 assert_eq!(
176 aggregate_fields_type_name("author"),
177 "author_aggregate_fields"
178 );
179 assert_eq!(
180 function_fields_type_name("author", "sum"),
181 "author_sum_fields"
182 );
183 }
184
185 #[test]
186 fn only_numbers_are_summed() {
187 assert!(is_numeric(&GraphQLType::Int));
188 assert!(is_numeric(&GraphQLType::BigDecimal));
189 assert!(!is_numeric(&GraphQLType::String));
190 assert!(!is_numeric(&GraphQLType::Json));
191 }
192
193 #[test]
194 fn json_has_no_maximum() {
195 // `max(details)` on a jsonb column is an error in PostgreSQL, so the
196 // field is not offered rather than offered and failing.
197 assert!(!is_ordered(&GraphQLType::Json));
198 assert!(!is_ordered(&GraphQLType::Custom("json".to_string())));
199 assert!(is_ordered(&GraphQLType::Custom("timestamp".to_string())));
200 assert!(is_ordered(&GraphQLType::String));
201 assert!(is_ordered(&GraphQLType::DateTime));
202 }
203}