Skip to main content

postrust_graphql/input/
bool_exp.rs

1//! Boolean expression input types.
2//!
3//! Hasura's filter argument is `where: <table>_bool_exp` -- a generated input
4//! object with one field per column, one per relationship, and `_and`, `_or`
5//! and `_not` for structure:
6//!
7//! ```graphql
8//! author(where: {_and: [{id: {_gt: 2}},
9//!                       {articles: {title: {_ilike: "%rust%"}}}]}) { id }
10//! ```
11//!
12//! The shape matters more than it looks. A `JSON` argument accepts the same
13//! text, but nothing downstream can read it: a client cannot generate types
14//! from it, an editor cannot complete it, and a typo in an operator name
15//! reaches the server instead of being refused by the client. Every case in
16//! Hasura's introspection corpus turns on these types existing, and so does
17//! every `graphql-codegen` run a migrating client has in its build.
18//!
19//! Two naming rules, both taken from what a client has already compiled
20//! against rather than from taste:
21//!
22//! * The table's expression is `<table>_bool_exp`, named for the table
23//!   exactly as it is spelled in PostgreSQL. It appears in the client's own
24//!   source wherever a query declares a variable.
25//! * A column's expression is named for the column's scalar type, so every
26//!   `text` column in the schema shares one `String_comparison_exp`.
27
28use crate::schema::object::TableObjectType;
29use crate::schema::relationship::RelationshipField;
30use crate::types::GraphQLType;
31use async_graphql::dynamic::{InputObject, InputValue, TypeRef};
32use std::collections::{HashMap, HashSet};
33
34/// Comparisons every type supports.
35const UNIVERSAL: &[&str] = &["_eq", "_neq", "_gt", "_gte", "_lt", "_lte"];
36
37/// Comparisons taking a list of the column's own type.
38///
39/// The items are non-null: a null in `_in` is not a value the column could
40/// equal -- `= NULL` is never true -- so Hasura types the list `[Int!]` and
41/// refuses one rather than answering nothing.
42const LIST_VALUED: &[&str] = &["_in", "_nin"];
43
44/// Pattern matching, for text-shaped columns only.
45const TEXT: &[&str] = &[
46    "_like",
47    "_nlike",
48    "_ilike",
49    "_nilike",
50    "_similar",
51    "_nsimilar",
52    "_regex",
53    "_iregex",
54    "_nregex",
55    "_niregex",
56];
57
58/// Containment and key tests, for `json` and `jsonb` columns only.
59const JSON: &[&str] = &["_contains", "_contained_in"];
60
61/// Tree comparisons, for `ltree` columns.
62///
63/// A label path is compared by where it sits in the tree rather than by
64/// ordering: `_ancestor` asks whether this path is above another, `_matches`
65/// whether it satisfies a query pattern. The `_any` forms take a list and ask
66/// the same question of each.
67const LTREE: &[(&str, &str)] = &[
68    ("_ancestor", "ltree"),
69    ("_ancestor_any", "ltree"),
70    ("_descendant", "ltree"),
71    ("_descendant_any", "ltree"),
72    ("_matches", "lquery"),
73    ("_matches_any", "lquery"),
74    ("_matches_fulltext", "ltxtquery"),
75];
76
77/// The operator each tree comparison becomes, and what its operand is cast to.
78///
79/// `?` is PostgreSQL's "any of these" for label paths, and is the same symbol
80/// jsonb uses for key existence -- different types, one spelling, which is why
81/// the cast is not optional.
82pub fn ltree_operator(comparison: &str) -> Option<(&'static str, &'static str)> {
83    Some(match comparison {
84        "_ancestor" => ("@>", "ltree"),
85        "_ancestor_any" => ("@>", "ltree[]"),
86        "_descendant" => ("<@", "ltree"),
87        "_descendant_any" => ("<@", "ltree[]"),
88        "_matches" => ("~", "lquery"),
89        "_matches_any" => ("?", "lquery[]"),
90        "_matches_fulltext" => ("@", "ltxtquery"),
91        _ => return None,
92    })
93}
94
95/// Spatial relations taking one other shape, for `geometry` columns.
96///
97/// PostGIS names them `ST_Contains` and so on; Hasura spells each in the same
98/// lower-cased shape as every other comparison, and a client sends what Hasura
99/// spelled.
100const GEOMETRY: &[&str] = &[
101    "_st_contains",
102    "_st_crosses",
103    "_st_equals",
104    "_st_intersects",
105    "_st_3d_intersects",
106    "_st_overlaps",
107    "_st_touches",
108    "_st_within",
109];
110
111/// The PostGIS function behind each of those, and behind the ones that take
112/// more than a shape.
113pub fn postgis_function(operator: &str) -> Option<&'static str> {
114    Some(match operator {
115        "_st_contains" => "ST_Contains",
116        "_st_crosses" => "ST_Crosses",
117        "_st_equals" => "ST_Equals",
118        "_st_intersects" => "ST_Intersects",
119        "_st_3d_intersects" => "ST_3DIntersects",
120        "_st_overlaps" => "ST_Overlaps",
121        "_st_touches" => "ST_Touches",
122        "_st_within" => "ST_Within",
123        "_st_d_within" => "ST_DWithin",
124        "_st_3d_d_within" => "ST_3DDWithin",
125        "_st_intersects_rast" => "ST_Intersects",
126        "_st_intersects_geom_nband" => "ST_Intersects",
127        "_st_intersects_nband_geom" => "ST_Intersects",
128        _ => return None,
129    })
130}
131
132/// The name of the comparison input for a scalar.
133pub fn comparison_type_name(scalar: &str) -> String {
134    format!("{}_comparison_exp", scalar)
135}
136
137/// Whether a filter has no way to call this relationship.
138///
139/// A computed relationship with a required argument of its own: the parent row
140/// is not the whole call, and a boolean expression is not a place a client can
141/// write the rest. Every relationship reached by a foreign key answers false,
142/// having no arguments at all.
143fn uncallable_from_a_filter(relationship: &RelationshipField) -> bool {
144    relationship
145        .arguments
146        .iter()
147        .any(|(_, _, required)| *required)
148}
149
150/// The name of a table's boolean expression input.
151pub fn bool_exp_type_name(base_name: &str) -> String {
152    format!("{}_bool_exp", base_name)
153}
154
155/// The name of a table's aggregate boolean expression input.
156///
157/// `where: {articles_aggregate: {count: {predicate: {_gt: 2}}}}` -- the
158/// authors with more than two articles. The question is about the set of
159/// related rows rather than about any one of them, which is why it is a type
160/// of its own rather than another field of the child's filter.
161pub fn aggregate_bool_exp_type_name(base_name: &str) -> String {
162    format!("{}_aggregate_bool_exp", base_name)
163}
164
165/// The name of one aggregate's own input inside that.
166pub fn aggregate_bool_exp_function(base_name: &str, function: &str) -> String {
167    format!("{}_aggregate_bool_exp_{}", base_name, function)
168}
169
170/// The name of the enum naming the columns `bool_and` and `bool_or` may take.
171///
172/// Spelled as Hasura spells it, which is long because it says where it came
173/// from: the select-column enum, narrowed to one aggregate of one table.
174pub fn aggregate_bool_exp_columns(base_name: &str, function: &str) -> String {
175    format!(
176        "{}_select_column_{}_aggregate_bool_exp_{}_arguments_columns",
177        base_name, base_name, function
178    )
179}
180
181/// The aggregates a predicate may be written over.
182///
183/// `count` over any table; `bool_and` and `bool_or` only where there is a
184/// boolean column for them to fold, since a GraphQL enum may not be empty.
185pub const AGGREGATE_PREDICATES: [&str; 3] = ["bool_and", "bool_or", "count"];
186
187/// Build the comparison input for one scalar type.
188///
189/// `_is_null` takes a Boolean whatever the column is, and the list-valued
190/// comparisons take a list of the column's type. Everything else takes one
191/// value of it.
192fn comparison_input(scalar: &str) -> InputObject {
193    let mut input = InputObject::new(comparison_type_name(scalar)).description(format!(
194        "Comparisons against a {} column. All fields are combined with AND.",
195        scalar
196    ));
197
198    for op in UNIVERSAL {
199        input = input.field(InputValue::new(*op, TypeRef::named(scalar)));
200    }
201    for op in LIST_VALUED {
202        input = input.field(InputValue::new(*op, TypeRef::named_nn_list(scalar)));
203    }
204    input = input.field(InputValue::new("_is_null", TypeRef::named("Boolean")));
205
206    // `citext` is text that compares without regard to case. It is its own
207    // GraphQL scalar because a client declaring `$name: citext!` is naming the
208    // column's type, but every pattern question text answers, it answers.
209    if scalar == "String" || scalar == "citext" {
210        for op in TEXT {
211            input = input.field(InputValue::new(*op, TypeRef::named(scalar)));
212        }
213    }
214    // A shape is compared by how it lies against another shape, not by
215    // ordering: `_gt` on a polygon means nothing, and PostGIS answers every
216    // real question with a function instead.
217    if scalar == "geometry" || scalar == "geography" {
218        if scalar == "geometry" {
219            for op in GEOMETRY {
220                input = input.field(InputValue::new(*op, TypeRef::named("geometry")));
221            }
222        } else {
223            input = input.field(InputValue::new(
224                "_st_intersects",
225                TypeRef::named("geography"),
226            ));
227        }
228        input = input.field(InputValue::new(
229            "_st_d_within",
230            TypeRef::named(format!("st_d_within_{}_input", scalar)),
231        ));
232        // A geometry and a geography answer different questions about the same
233        // shape -- one on a plane, one on a sphere -- so a client asks for the
234        // other by casting the column rather than by keeping two of them.
235        input = input.field(InputValue::new(
236            "_cast",
237            TypeRef::named(format!("{}_cast_exp", scalar)),
238        ));
239        if scalar == "geometry" {
240            input = input.field(InputValue::new(
241                "_st_3d_d_within",
242                TypeRef::named("st_d_within_geometry_input"),
243            ));
244        }
245    }
246    if scalar == "raster" {
247        input = input
248            .field(InputValue::new(
249                "_st_intersects_rast",
250                TypeRef::named("raster"),
251            ))
252            .field(InputValue::new(
253                "_st_intersects_geom_nband",
254                TypeRef::named("st_intersects_geom_nband_input"),
255            ))
256            .field(InputValue::new(
257                "_st_intersects_nband_geom",
258                TypeRef::named("st_intersects_nband_geom_input"),
259            ));
260    }
261    if scalar == "ltree" {
262        for (comparison, operand) in LTREE {
263            // The `_any` forms take a list of the same thing.
264            input = input.field(InputValue::new(
265                *comparison,
266                match comparison.ends_with("_any") {
267                    true => TypeRef::named_list(*operand),
268                    false => TypeRef::named(*operand),
269                },
270            ));
271        }
272    }
273    if scalar == "jsonb" || scalar == "json" {
274        for op in JSON {
275            input = input.field(InputValue::new(*op, TypeRef::named(scalar)));
276        }
277        input = input.field(InputValue::new("_has_key", TypeRef::named("String")));
278        input = input.field(InputValue::new(
279            "_has_keys_any",
280            TypeRef::named_list("String"),
281        ));
282        input = input.field(InputValue::new(
283            "_has_keys_all",
284            TypeRef::named_list("String"),
285        ));
286        // A document has no `LIKE`, and the text it renders as does. Casting
287        // is how a client asks the pattern question of a column whose type
288        // cannot answer it.
289        input = input.field(InputValue::new(
290            "_cast",
291            TypeRef::named(format!("{}_cast_exp", scalar)),
292        ));
293    }
294    if scalar == "jsonb" {
295        // The path language, which is a query over the document rather than a
296        // comparison against one: `_exists` asks whether the path selects
297        // anything, `_match` whether the predicate it ends in holds.
298        input = input
299            .field(InputValue::new(
300                "_jsonb_path_exists",
301                TypeRef::named("String"),
302            ))
303            .field(InputValue::new(
304                "_jsonb_path_match",
305                TypeRef::named("String"),
306            ));
307    }
308
309    input
310}
311
312/// The scalar a column is compared against.
313///
314/// An array column is compared against its element type: `_contains` on a
315/// `text[]` takes text, not a list of lists. Everything else compares as
316/// itself, including the types that carry their own PostgreSQL name -- a
317/// `geometry` column takes a `geometry`, and a client that declares
318/// `$area: geometry!` is naming the type this produces.
319fn scalar_for(graphql_type: &GraphQLType) -> String {
320    match graphql_type {
321        GraphQLType::List(inner) => scalar_for(inner),
322        other => other.to_string(),
323    }
324}
325
326/// Every input type the boolean expressions need, ready to register, and every
327/// scalar those inputs name.
328///
329/// The second half is not a convenience. A comparison input names scalars its
330/// own table may not have a column of -- a cast from a geometry names
331/// `geography`, a raster comparison names `geometry` -- and a type the schema
332/// mentions and never registers makes the whole schema unbuildable. Reading
333/// the names off what was actually generated is the only way that cannot drift
334/// from the truth; twice it was patched case by case, and twice it broke again
335/// one operator later.
336///
337/// The inputs come back with the comparisons first and the table expressions
338/// after, though registration order does not matter to the schema builder --
339/// the types refer to each other by name and are resolved once the whole set
340/// is present, which is also what makes a recursive `_and: [author_bool_exp!]`
341/// expressible at all.
342pub fn build_inputs(
343    object_types: &HashMap<String, TableObjectType>,
344    relationship_fields: &HashMap<String, Vec<RelationshipField>>,
345) -> (Vec<InputObject>, HashSet<String>) {
346    let mut scalars: HashSet<String> = HashSet::new();
347    let mut inputs = Vec::new();
348
349    for (type_name, object) in object_types {
350        let local_type_name = object.local_name.as_str();
351        let bool_exp = bool_exp_type_name(local_type_name);
352        let mut input = InputObject::new(&bool_exp).description(format!(
353            "Filter rows of {}. Fields are combined with AND unless _or says otherwise.",
354            local_type_name
355        ));
356
357        input = input
358            .field(InputValue::new("_and", TypeRef::named_nn_list(&bool_exp)))
359            .field(InputValue::new("_or", TypeRef::named_nn_list(&bool_exp)))
360            .field(InputValue::new("_not", TypeRef::named(&bool_exp)));
361
362        // Two fields of one name abort the process rather than returning an
363        // error, and a foreign key column named after its target -- the
364        // ordinary `pizza.crust references crust` -- produces exactly that
365        // clash. The column wins here for the same reason it wins in the
366        // object type: it is the table's own data, and the two have to agree
367        // about which fields exist.
368        let mut taken: HashSet<String> = HashSet::new();
369
370        for field in &object.fields {
371            if !taken.insert(field.name.clone()) {
372                continue;
373            }
374            let scalar = scalar_for(&field.graphql_type);
375            input = input.field(InputValue::new(
376                &field.name,
377                TypeRef::named(comparison_type_name(&scalar)),
378            ));
379            scalars.insert(scalar);
380        }
381
382        // A relationship is filtered by filtering the rows at its other end:
383        // `where: {articles: {…}}` keeps the authors that have a matching
384        // article.
385        //
386        // Except one this server could not call. A computed relationship is a
387        // function of the parent row, and `fetch_articles(search text,
388        // author_row author)` also wants a search term -- which a selection
389        // writes as `get_articles(args: {search: "..."})` and a filter has
390        // nowhere to write at all. Filtering on it used to reach PostgreSQL as
391        // `fetch_articles(author)` and come back `function
392        // public.fetch_articles(author) does not exist`, an answer about a
393        // call this server composed. Hasura's answer is that the field is not
394        // there: `field 'get_articles' not found in type: 'author_bool_exp'`.
395        //
396        // An *optional* argument is not this. The call with the row alone is
397        // the call the function is written to accept, so it stays filterable.
398        for relationship in relationship_fields.get(type_name).into_iter().flatten() {
399            if uncallable_from_a_filter(relationship) {
400                continue;
401            }
402            if !taken.insert(relationship.name.clone()) {
403                continue;
404            }
405            input = input.field(InputValue::new(
406                &relationship.name,
407                TypeRef::named(bool_exp_type_name(&relationship.target_local_name)),
408            ));
409        }
410
411        // And by asking something about the whole set: `where:
412        // {articles_aggregate: {count: {predicate: {_gt: 2}}}}` keeps the
413        // authors with more than two articles, which no filter on one article
414        // can express. Only a relationship to many -- there is nothing to
415        // aggregate over one row.
416        for relationship in relationship_fields.get(type_name).into_iter().flatten() {
417            if !relationship.is_list || uncallable_from_a_filter(relationship) {
418                continue;
419            }
420            let name = format!("{}_aggregate", relationship.name);
421            if object.fields.iter().any(|field| field.name == name) {
422                continue;
423            }
424            if !taken.insert(name.clone()) {
425                continue;
426            }
427            input = input.field(InputValue::new(
428                &name,
429                TypeRef::named(aggregate_bool_exp_type_name(
430                    &relationship.target_local_name,
431                )),
432            ));
433            // `count`'s predicate is an `Int` comparison whether or not any
434            // column is one, and a type the schema names and never registers
435            // makes the whole schema unbuildable.
436            scalars.insert("Int".to_string());
437        }
438
439        inputs.push(input);
440    }
441
442    // A cast from one shape names the other's comparison input, so a schema
443    // with only one of them still needs both.
444    if scalars.contains("geometry") {
445        scalars.insert("geography".to_string());
446    }
447    if scalars.contains("geography") {
448        scalars.insert("geometry".to_string());
449    }
450
451    // Every scalar named by anything generated here, which is more than the
452    // scalars the columns are: a comparison may name another shape's type.
453    let mut named: HashSet<String> = scalars.clone();
454    if scalars.contains("raster") {
455        named.insert("geometry".to_string());
456    }
457    // The tree comparisons name the query types beside the path type.
458    if scalars.contains("ltree") {
459        named.insert("lquery".to_string());
460        named.insert("ltxtquery".to_string());
461    }
462
463    // A document casts to the text it renders as, so a schema carrying one
464    // needs the text comparisons even where no column is text. Added after
465    // `named`, because `String` is a built-in and registering it as a scalar
466    // of our own is a duplicate the schema refuses to build with.
467    if scalars.contains("jsonb") || scalars.contains("json") {
468        scalars.insert("String".to_string());
469    }
470
471    let mut comparisons: Vec<InputObject> = scalars.iter().map(|s| comparison_input(s)).collect();
472
473    // The comparisons that take more than one value need an input of their
474    // own. Registered only where a column of that shape exists, so a schema
475    // without PostGIS carries none of them.
476    if scalars.contains("geometry") || scalars.contains("geography") {
477        for shape in ["geometry", "geography"] {
478            if !scalars.contains(shape) {
479                continue;
480            }
481            let mut within = InputObject::new(format!("st_d_within_{}_input", shape))
482                .description("Within a distance of another shape.")
483                .field(InputValue::new(
484                    "distance",
485                    TypeRef::named_nn(TypeRef::FLOAT),
486                ))
487                .field(InputValue::new("from", TypeRef::named_nn(shape)));
488            // On a sphere the answer depends on which sphere. A plane has no
489            // such question, so only the geography form carries it.
490            if shape == "geography" {
491                within = within.field(
492                    InputValue::new("use_spheroid", TypeRef::named(TypeRef::BOOLEAN))
493                        .default_value(true),
494                );
495            }
496            comparisons.push(within);
497        }
498    }
499    // What each shape may be compared as.
500    if scalars.contains("geometry") {
501        comparisons.push(
502            InputObject::new("geometry_cast_exp")
503                .description("Compare a geometry column as another type.")
504                .field(InputValue::new(
505                    "geography",
506                    TypeRef::named(comparison_type_name("geography")),
507                )),
508        );
509    }
510    if scalars.contains("geography") {
511        comparisons.push(
512            InputObject::new("geography_cast_exp")
513                .description("Compare a geography column as another type.")
514                .field(InputValue::new(
515                    "geometry",
516                    TypeRef::named(comparison_type_name("geometry")),
517                )),
518        );
519    }
520    for document in ["jsonb", "json"] {
521        if scalars.contains(document) {
522            comparisons.push(
523                InputObject::new(format!("{}_cast_exp", document))
524                    .description(format!("Compare a {} column as another type.", document))
525                    .field(InputValue::new(
526                        "String",
527                        TypeRef::named(comparison_type_name("String")),
528                    )),
529            );
530        }
531    }
532    if scalars.contains("raster") {
533        comparisons.push(
534            InputObject::new("st_intersects_geom_nband_input")
535                .description("Intersecting a shape, optionally in one band.")
536                .field(InputValue::new("geommin", TypeRef::named_nn("geometry")))
537                .field(InputValue::new("nband", TypeRef::named(TypeRef::INT))),
538        );
539        comparisons.push(
540            InputObject::new("st_intersects_nband_geom_input")
541                .description("Intersecting a shape in a given band.")
542                .field(InputValue::new("nband", TypeRef::named_nn(TypeRef::INT)))
543                .field(InputValue::new("geommin", TypeRef::named_nn("geometry"))),
544        );
545    }
546    comparisons.extend(inputs);
547    (comparisons, named)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use pretty_assertions::assert_eq;
554
555    #[test]
556    fn a_table_expression_is_named_after_the_table() {
557        assert_eq!(bool_exp_type_name("author"), "author_bool_exp");
558    }
559
560    #[test]
561    fn a_comparison_is_named_after_the_scalar() {
562        assert_eq!(comparison_type_name("String"), "String_comparison_exp");
563    }
564
565    #[test]
566    fn an_array_column_is_compared_against_its_element_type() {
567        let list = GraphQLType::List(Box::new(GraphQLType::String));
568        assert_eq!(scalar_for(&list), "String");
569    }
570
571    #[test]
572    fn a_type_with_its_own_name_compares_as_itself() {
573        let custom = GraphQLType::Custom("geography".to_string());
574        assert_eq!(scalar_for(&custom), "geography");
575    }
576}