Skip to main content

postrust_graphql/input/
order_by.rs

1//! Ordering and distinct input types.
2//!
3//! Hasura orders with `order_by: [<table>_order_by!]` where each entry maps a
4//! column to a direction drawn from one shared enum:
5//!
6//! ```graphql
7//! author(order_by: [{name: asc_nulls_last}, {id: desc}]) { id }
8//! ```
9//!
10//! A list rather than a single object, because ordering is ordered: `{name:
11//! asc, id: desc}` is one object whose two keys have no defined precedence,
12//! and the client that wrote it meant name first.
13//!
14//! The direction enum carries null placement, which the previous
15//! `orderBy: ["name.asc"]` strings could not express at all. In PostgreSQL
16//! nulls sort last ascending and first descending, so `asc` and
17//! `asc_nulls_last` mean the same thing and `asc_nulls_first` does not --
18//! which is exactly the case a client reaches for the enum to say.
19//!
20//! `distinct_on` takes columns from a generated enum rather than strings, for
21//! the same reason the boolean expressions do: an unknown column should be
22//! refused by the client, not by the database.
23
24use crate::schema::object::TableObjectType;
25use crate::schema::relationship::RelationshipField;
26use async_graphql::dynamic::{Enum, EnumItem, InputObject, InputValue, TypeRef};
27use std::collections::{HashMap, HashSet};
28
29/// The name of the shared direction enum.
30pub const DIRECTION_ENUM: &str = "order_by";
31
32/// Every direction, with its SQL.
33const DIRECTIONS: &[(&str, &str)] = &[
34    ("asc", "ASC"),
35    ("asc_nulls_first", "ASC NULLS FIRST"),
36    ("asc_nulls_last", "ASC NULLS LAST"),
37    ("desc", "DESC"),
38    ("desc_nulls_first", "DESC NULLS FIRST"),
39    ("desc_nulls_last", "DESC NULLS LAST"),
40];
41
42/// The name of a table's ordering input.
43pub fn order_by_type_name(base_name: &str) -> String {
44    format!("{}_order_by", base_name)
45}
46
47/// The name of a table's column enum.
48pub fn select_column_type_name(base_name: &str) -> String {
49    format!("{}_select_column", base_name)
50}
51
52/// The name of the input for ordering a row by an aggregate of its children.
53pub fn aggregate_order_by_type_name(base_name: &str) -> String {
54    format!("{}_aggregate_order_by", base_name)
55}
56
57/// The name of the input for ordering by one aggregate function's results.
58pub fn function_order_by_type_name(base_name: &str, function: &str) -> String {
59    format!("{}_{}_order_by", base_name, function)
60}
61
62/// The SQL for a direction, or `None` if it is not one.
63pub fn direction_sql(name: &str) -> Option<&'static str> {
64    DIRECTIONS
65        .iter()
66        .find(|(enum_name, _)| *enum_name == name)
67        .map(|(_, sql)| *sql)
68}
69
70/// The shared direction enum.
71pub fn direction_enum() -> Enum {
72    let mut direction = Enum::new(DIRECTION_ENUM)
73        .description("Sort direction, with where nulls go. `asc` is `asc_nulls_last`.");
74    for (name, _) in DIRECTIONS {
75        direction = direction.item(EnumItem::new(*name));
76    }
77    direction
78}
79
80/// The ordering input and column enum for every table.
81pub fn build_inputs(
82    object_types: &HashMap<String, TableObjectType>,
83    relationship_fields: &HashMap<String, Vec<RelationshipField>>,
84) -> (Vec<InputObject>, Vec<Enum>) {
85    let mut inputs = Vec::new();
86    let mut enums = vec![direction_enum()];
87
88    for (type_name, object) in object_types {
89        // A table with no columns would produce an enum with no members, which
90        // no GraphQL schema may contain.
91        if object.fields.is_empty() {
92            continue;
93        }
94
95        let mut input = InputObject::new(order_by_type_name(type_name)).description(format!(
96            "Order rows of {} by a column, by a related row's column, or by an \
97             aggregate of its children.",
98            type_name
99        ));
100        let mut columns = Enum::new(select_column_type_name(type_name))
101            .description(format!("A column of {}.", type_name));
102
103        let mut taken: HashSet<String> = HashSet::new();
104        for field in &object.fields {
105            if !taken.insert(field.name.clone()) {
106                continue;
107            }
108            input = input.field(InputValue::new(&field.name, TypeRef::named(DIRECTION_ENUM)));
109            columns = columns.item(EnumItem::new(&field.name));
110        }
111
112        // Ordering by something the row points at. One row contributes a
113        // column, so ordering by it is ordering by that column; many rows
114        // contribute a count or a statistic, so ordering by them is ordering
115        // by an aggregate. That is the whole difference, and it is why the two
116        // sides take different inputs.
117        for relationship in relationship_fields.get(type_name).into_iter().flatten() {
118            if relationship.is_list {
119                let field = format!("{}_aggregate", relationship.name);
120                if taken.insert(field.clone()) {
121                    input = input.field(InputValue::new(
122                        &field,
123                        TypeRef::named(aggregate_order_by_type_name(&relationship.target_type)),
124                    ));
125                }
126            } else if taken.insert(relationship.name.clone()) {
127                input = input.field(InputValue::new(
128                    &relationship.name,
129                    TypeRef::named(order_by_type_name(&relationship.target_type)),
130                ));
131            }
132        }
133
134        inputs.push(input);
135        enums.push(columns);
136
137        // What a parent may order its children by.
138        let mut aggregate = InputObject::new(aggregate_order_by_type_name(type_name))
139            .description(format!("Order by an aggregate of {} rows.", type_name))
140            .field(InputValue::new("count", TypeRef::named(DIRECTION_ENUM)));
141
142        for (function, _, columns) in crate::schema::aggregate::functions_for(object) {
143            let function_type = function_order_by_type_name(type_name, function);
144            let mut per_column = InputObject::new(&function_type).description(format!(
145                "Order by the `{}` of a {} column.",
146                function, type_name
147            ));
148            for column in &columns {
149                per_column =
150                    per_column.field(InputValue::new(column, TypeRef::named(DIRECTION_ENUM)));
151            }
152            inputs.push(per_column);
153            aggregate = aggregate.field(InputValue::new(function, TypeRef::named(&function_type)));
154        }
155        inputs.push(aggregate);
156    }
157
158    (inputs, enums)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use pretty_assertions::assert_eq;
165
166    #[test]
167    fn a_tables_ordering_is_named_after_it() {
168        assert_eq!(order_by_type_name("author"), "author_order_by");
169        assert_eq!(select_column_type_name("author"), "author_select_column");
170    }
171
172    #[test]
173    fn plain_asc_places_nulls_where_postgres_does() {
174        // Not `ASC NULLS LAST` spelled out: the point is that the default and
175        // the explicit spelling produce the same order, so the plain form is
176        // left to the database.
177        assert_eq!(direction_sql("asc"), Some("ASC"));
178        assert_eq!(direction_sql("asc_nulls_last"), Some("ASC NULLS LAST"));
179        assert_eq!(direction_sql("asc_nulls_first"), Some("ASC NULLS FIRST"));
180    }
181
182    #[test]
183    fn a_direction_that_is_not_one_has_no_sql() {
184        assert_eq!(direction_sql("sideways"), None);
185        assert_eq!(direction_sql("ASC"), None);
186    }
187}