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}