Skip to main content

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}