diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 3c53d1e126f..5672aeb889b 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -123,6 +123,7 @@ those. - `SILENT_GRAPHQL_VALIDATIONS`: If `ENABLE_GRAPHQL_VALIDATIONS` is enabled, you are also able to just silently print the GraphQL validation errors, without failing the actual query. Note: queries might still fail as part of the later stage validations running, during GraphQL engine execution. +- `GRAPH_GRAPHQL_DISABLE_BOOL_FILTERS`: disables the ability to use AND/OR filters. This is useful if we want to disable filters because of performance reasons. ### GraphQL caching diff --git a/graph/src/env/graphql.rs b/graph/src/env/graphql.rs index 6c06b53dbc1..3a8119d44cb 100644 --- a/graph/src/env/graphql.rs +++ b/graph/src/env/graphql.rs @@ -84,6 +84,9 @@ pub struct EnvVarsGraphQl { /// Set by the flag `GRAPH_GRAPHQL_MAX_OPERATIONS_PER_CONNECTION`. /// Defaults to 1000. pub max_operations_per_connection: usize, + /// Set by the flag `GRAPH_GRAPHQL_DISABLE_BOOL_FILTERS`. Off by default. + /// Disables AND/OR filters + pub disable_bool_filters: bool, } // This does not print any values avoid accidentally leaking any sensitive env vars @@ -128,6 +131,7 @@ impl From for EnvVarsGraphQl { warn_result_size: x.warn_result_size.0 .0, error_result_size: x.error_result_size.0 .0, max_operations_per_connection: x.max_operations_per_connection, + disable_bool_filters: x.disable_bool_filters.0, } } } @@ -173,4 +177,6 @@ pub struct InnerGraphQl { error_result_size: WithDefaultUsize, { usize::MAX }>, #[envconfig(from = "GRAPH_GRAPHQL_MAX_OPERATIONS_PER_CONNECTION", default = "1000")] max_operations_per_connection: usize, + #[envconfig(from = "GRAPH_GRAPHQL_DISABLE_BOOL_FILTERS", default = "false")] + pub disable_bool_filters: EnvVarBoolean, } diff --git a/graphql/src/schema/api.rs b/graphql/src/schema/api.rs index 03d061c4c3e..169c4cc2334 100644 --- a/graphql/src/schema/api.rs +++ b/graphql/src/schema/api.rs @@ -188,6 +188,30 @@ fn add_filter_type( let mut generated_filter_fields = field_input_values(schema, fields)?; generated_filter_fields.push(block_changed_filter_argument()); + if !ENV_VARS.graphql.disable_bool_filters { + generated_filter_fields.push(InputValue { + position: Pos::default(), + description: None, + name: "and".to_string(), + value_type: Type::ListType(Box::new(Type::NamedType( + filter_type_name.to_owned(), + ))), + default_value: None, + directives: vec![], + }); + + generated_filter_fields.push(InputValue { + position: Pos::default(), + description: None, + name: "or".to_string(), + value_type: Type::ListType(Box::new(Type::NamedType( + filter_type_name.to_owned(), + ))), + default_value: None, + directives: vec![], + }); + } + let typedef = TypeDefinition::InputObject(InputObjectType { position: Pos::default(), description: None, @@ -969,7 +993,9 @@ mod tests { "favoritePet_", "leastFavoritePet_", "mostFavoritePets_", - "_change_block" + "_change_block", + "and", + "or" ] .iter() .map(ToString::to_string) @@ -1046,7 +1072,9 @@ mod tests { "mostLovedBy_not_contains", "mostLovedBy_not_contains_nocase", "mostLovedBy_", - "_change_block" + "_change_block", + "and", + "or" ] .iter() .map(ToString::to_string) @@ -1170,7 +1198,9 @@ mod tests { "favoritePet_not_ends_with", "favoritePet_not_ends_with_nocase", "favoritePet_", - "_change_block" + "_change_block", + "and", + "or" ] .iter() .map(ToString::to_string) diff --git a/graphql/src/schema/ast.rs b/graphql/src/schema/ast.rs index 6460f244eac..d5132d2c533 100644 --- a/graphql/src/schema/ast.rs +++ b/graphql/src/schema/ast.rs @@ -34,6 +34,8 @@ pub(crate) enum FilterOp { NotEndsWithNoCase, Equal, Child, + And, + Or, } /// Split a "name_eq" style name into an attribute ("name") and a filter op (`Equal`). @@ -67,11 +69,17 @@ pub(crate) fn parse_field_as_filter(key: &str) -> (String, FilterOp) { k if k.ends_with("_ends_with") => ("_ends_with", FilterOp::EndsWith), k if k.ends_with("_ends_with_nocase") => ("_ends_with_nocase", FilterOp::EndsWithNoCase), k if k.ends_with("_") => ("_", FilterOp::Child), + k if k.eq("and") => ("and", FilterOp::And), + k if k.eq("or") => ("or", FilterOp::Or), _ => ("", FilterOp::Equal), }; - // Strip the operator suffix to get the attribute. - (key.trim_end_matches(suffix).to_owned(), op) + return match op { + FilterOp::And => (key.to_owned(), op), + FilterOp::Or => (key.to_owned(), op), + // Strip the operator suffix to get the attribute. + _ => (key.trim_end_matches(suffix).to_owned(), op), + }; } /// An `ObjectType` with `Hash` and `Eq` derived from the name. diff --git a/graphql/src/store/query.rs b/graphql/src/store/query.rs index c31b00b69cc..3270279e30c 100644 --- a/graphql/src/store/query.rs +++ b/graphql/src/store/query.rs @@ -9,7 +9,7 @@ use graph::prelude::*; use graph::{components::store::EntityType, data::graphql::ObjectOrInterface}; use crate::execution::ast as a; -use crate::schema::ast as sast; +use crate::schema::ast::{self as sast, FilterOp}; use super::prefetch::SelectedAttributes; @@ -118,7 +118,7 @@ fn build_filter( ) -> Result, QueryExecutionError> { match field.argument_value("where") { Some(r::Value::Object(object)) => match build_filter_from_object(entity, object, schema) { - Ok(filter) => Ok(Some(filter)), + Ok(filter) => Ok(Some(EntityFilter::And(filter))), Err(e) => Err(e), }, Some(r::Value::Null) => Ok(None), @@ -161,91 +161,164 @@ fn parse_change_block_filter(value: &r::Value) -> Result Result { + return match operation { + FilterOp::Not => Ok(EntityFilter::Not(field_name, store_value)), + FilterOp::GreaterThan => Ok(EntityFilter::GreaterThan(field_name, store_value)), + FilterOp::LessThan => Ok(EntityFilter::LessThan(field_name, store_value)), + FilterOp::GreaterOrEqual => Ok(EntityFilter::GreaterOrEqual(field_name, store_value)), + FilterOp::LessOrEqual => Ok(EntityFilter::LessOrEqual(field_name, store_value)), + FilterOp::In => Ok(EntityFilter::In( + field_name, + list_values(store_value, "_in")?, + )), + FilterOp::NotIn => Ok(EntityFilter::NotIn( + field_name, + list_values(store_value, "_not_in")?, + )), + FilterOp::Contains => Ok(EntityFilter::Contains(field_name, store_value)), + FilterOp::ContainsNoCase => Ok(EntityFilter::ContainsNoCase(field_name, store_value)), + FilterOp::NotContains => Ok(EntityFilter::NotContains(field_name, store_value)), + FilterOp::NotContainsNoCase => Ok(EntityFilter::NotContainsNoCase(field_name, store_value)), + FilterOp::StartsWith => Ok(EntityFilter::StartsWith(field_name, store_value)), + FilterOp::StartsWithNoCase => Ok(EntityFilter::StartsWithNoCase(field_name, store_value)), + FilterOp::NotStartsWith => Ok(EntityFilter::NotStartsWith(field_name, store_value)), + FilterOp::NotStartsWithNoCase => { + Ok(EntityFilter::NotStartsWithNoCase(field_name, store_value)) + } + FilterOp::EndsWith => Ok(EntityFilter::EndsWith(field_name, store_value)), + FilterOp::EndsWithNoCase => Ok(EntityFilter::EndsWithNoCase(field_name, store_value)), + FilterOp::NotEndsWith => Ok(EntityFilter::NotEndsWith(field_name, store_value)), + FilterOp::NotEndsWithNoCase => Ok(EntityFilter::NotEndsWithNoCase(field_name, store_value)), + FilterOp::Equal => Ok(EntityFilter::Equal(field_name, store_value)), + _ => unreachable!(), + }; +} + +/// Iterate over the list and generate an EntityFilter from it +fn build_list_filter_from_value( + entity: ObjectOrInterface, + schema: &ApiSchema, + value: &r::Value, +) -> Result, QueryExecutionError> { + return match value { + r::Value::List(list) => Ok(list + .iter() + .map(|item| { + return match item { + r::Value::Object(object) => { + Ok(build_filter_from_object(entity, object, schema)?) + } + _ => Err(QueryExecutionError::InvalidFilterError), + }; + }) + .collect::>, QueryExecutionError>>()? + // Flatten all different EntityFilters into one list + .into_iter() + .flatten() + .collect::>()), + _ => Err(QueryExecutionError::InvalidFilterError), + }; +} + +/// build a filter which has list of nested filters +fn build_list_filter_from_object( + entity: ObjectOrInterface, + object: &Object, + schema: &ApiSchema, +) -> Result, QueryExecutionError> { + Ok(object + .iter() + .map(|(_, value)| { + return build_list_filter_from_value(entity, schema, value); + }) + .collect::>, QueryExecutionError>>()? + .into_iter() + // We iterate an object so all entity filters are flattened into one list + .flatten() + .collect::>()) +} + /// Parses a GraphQL input object into an EntityFilter, if present. fn build_filter_from_object( entity: ObjectOrInterface, object: &Object, schema: &ApiSchema, -) -> Result { - Ok(EntityFilter::And({ - object - .iter() - .map(|(key, value)| { - // Special handling for _change_block input filter since its not a - // standard entity filter that is based on entity structure/fields - if key == "_change_block" { - return match parse_change_block_filter(value) { - Ok(block_number) => Ok(EntityFilter::ChangeBlockGte(block_number)), - Err(e) => Err(e), - }; - } - - use self::sast::FilterOp::*; - let (field_name, op) = sast::parse_field_as_filter(key); +) -> Result, QueryExecutionError> { + Ok(object + .iter() + .map(|(key, value)| { + // Special handling for _change_block input filter since its not a + // standard entity filter that is based on entity structure/fields + if key == "_change_block" { + return match parse_change_block_filter(value) { + Ok(block_number) => Ok(EntityFilter::ChangeBlockGte(block_number)), + Err(e) => Err(e), + }; + } + use self::sast::FilterOp::*; + let (field_name, op) = sast::parse_field_as_filter(key); - let field = sast::get_field(entity, &field_name).ok_or_else(|| { - QueryExecutionError::EntityFieldError( - entity.name().to_owned(), - field_name.clone(), - ) - })?; + Ok(match op { + And => { + if ENV_VARS.graphql.disable_bool_filters { + return Err(QueryExecutionError::NotSupported( + "Boolean filters are not supported".to_string(), + )); + } - let ty = &field.field_type; + return Ok(EntityFilter::And(build_list_filter_from_object( + entity, object, schema, + )?)); + } + Or => { + if ENV_VARS.graphql.disable_bool_filters { + return Err(QueryExecutionError::NotSupported( + "Boolean filters are not supported".to_string(), + )); + } - Ok(match op { - Child => match value { - DataValue::Object(obj) => { - build_child_filter_from_object(entity, field_name, obj, schema)? - } - _ => { - return Err(QueryExecutionError::AttributeTypeError( - value.to_string(), - ty.to_string(), - )) - } - }, + return Ok(EntityFilter::Or(build_list_filter_from_object( + entity, object, schema, + )?)); + } + Child => match value { + DataValue::Object(obj) => { + build_child_filter_from_object(entity, field_name, obj, schema)? + } _ => { - let store_value = Value::from_query_value(value, ty)?; - - match op { - Not => EntityFilter::Not(field_name, store_value), - GreaterThan => EntityFilter::GreaterThan(field_name, store_value), - LessThan => EntityFilter::LessThan(field_name, store_value), - GreaterOrEqual => EntityFilter::GreaterOrEqual(field_name, store_value), - LessOrEqual => EntityFilter::LessOrEqual(field_name, store_value), - In => EntityFilter::In(field_name, list_values(store_value, "_in")?), - NotIn => EntityFilter::NotIn( - field_name, - list_values(store_value, "_not_in")?, - ), - Contains => EntityFilter::Contains(field_name, store_value), - ContainsNoCase => EntityFilter::ContainsNoCase(field_name, store_value), - NotContains => EntityFilter::NotContains(field_name, store_value), - NotContainsNoCase => { - EntityFilter::NotContainsNoCase(field_name, store_value) - } - StartsWith => EntityFilter::StartsWith(field_name, store_value), - StartsWithNoCase => { - EntityFilter::StartsWithNoCase(field_name, store_value) - } - NotStartsWith => EntityFilter::NotStartsWith(field_name, store_value), - NotStartsWithNoCase => { - EntityFilter::NotStartsWithNoCase(field_name, store_value) - } - EndsWith => EntityFilter::EndsWith(field_name, store_value), - EndsWithNoCase => EntityFilter::EndsWithNoCase(field_name, store_value), - NotEndsWith => EntityFilter::NotEndsWith(field_name, store_value), - NotEndsWithNoCase => { - EntityFilter::NotEndsWithNoCase(field_name, store_value) - } - Equal => EntityFilter::Equal(field_name, store_value), - _ => unreachable!(), - } + let field = sast::get_field(entity, &field_name).ok_or_else(|| { + QueryExecutionError::EntityFieldError( + entity.name().to_owned(), + field_name.clone(), + ) + })?; + let ty = &field.field_type; + return Err(QueryExecutionError::AttributeTypeError( + value.to_string(), + ty.to_string(), + )); } - }) + }, + _ => { + let field = sast::get_field(entity, &field_name).ok_or_else(|| { + QueryExecutionError::EntityFieldError( + entity.name().to_owned(), + field_name.clone(), + ) + })?; + let ty = &field.field_type; + let store_value = Value::from_query_value(value, ty)?; + return build_entity_filter(field_name, op, store_value); + } }) - .collect::, QueryExecutionError>>()? - })) + }) + .collect::, QueryExecutionError>>()?) } fn build_child_filter_from_object( @@ -261,7 +334,11 @@ fn build_child_filter_from_object( let child_entity = schema .object_or_interface(type_name) .ok_or(QueryExecutionError::InvalidFilterError)?; - let filter = Box::new(build_filter_from_object(child_entity, object, schema)?); + let filter = Box::new(EntityFilter::And(build_filter_from_object( + child_entity, + object, + schema, + )?)); let derived = field.is_derived(); let attr = match derived { true => sast::get_derived_from_field(child_entity, field) diff --git a/graphql/tests/query.rs b/graphql/tests/query.rs index 9365fd22044..42a1da112c8 100644 --- a/graphql/tests/query.rs +++ b/graphql/tests/query.rs @@ -2123,3 +2123,121 @@ fn deterministic_error() { assert_eq!(expected, serde_json::to_value(&result).unwrap()); }) } + +#[test] +fn can_query_with_or_filter() { + const QUERY: &str = " + query { + musicians(where: { or: [{ name: \"John\", id: \"m2\" }] }) { + name + id + } + } + "; + + run_query(QUERY, |result, _| { + let exp = object! { + musicians: vec![ + object! { name: "John", id: "m1" }, + object! { name: "Lisa", id: "m2" }, + ], + }; + let data = extract_data!(result).unwrap(); + assert_eq!(data, exp); + }) +} + +#[test] +fn can_query_with_and_filter() { + const QUERY: &str = " + query { + musicians(where: { and: [{ name: \"John\", id: \"m2\" }] }) { + name + id + } + } + "; + + run_query(QUERY, |result, _| { + let exp = object! { + musicians: r::Value::List(vec![]), + }; + let data = extract_data!(result).unwrap(); + assert_eq!(data, exp); + }) +} + +#[test] +fn can_query_with_or_and_filter() { + const QUERY: &str = " + query { + musicians( + where: { or: [{ name: \"John\", id: \"m1\" }, { mainBand: \"b2\" }] } + ) { + name + id + } + } + "; + + run_query(QUERY, |result, _| { + let exp = object! { + musicians: vec![ + object! { name: "John", id: "m1" }, + object! { name: "Tom", id: "m3" }, + ], + }; + let data = extract_data!(result).unwrap(); + assert_eq!(data, exp); + }) +} + +#[test] +fn can_query_with_or_explicit_and_filter() { + const QUERY: &str = " + query { + musicians( + where: { or: [{ and: [{ name: \"John\", id: \"m1\" }] }, { mainBand: \"b2\" }] } + ) { + name + id + } + } + "; + + run_query(QUERY, |result, _| { + let exp = object! { + musicians: vec![ + object! { name: "John", id: "m1" }, + object! { name: "Tom", id: "m3" }, + ], + }; + let data = extract_data!(result).unwrap(); + assert_eq!(data, exp); + }) +} + +#[test] +fn can_query_with_or_implicit_and_filter() { + const QUERY: &str = " + query { + musicians( + where: { or: [{ name: \"John\", id: \"m1\" }, { name: \"Lisa\", id: \"m2\" }] } + ) { + name + id + } + } + "; + + run_query(QUERY, |result, _| { + let exp = object! { + musicians: vec![ + object! { name: "John", id: "m1" }, + object! { name: "Lisa", id: "m2" }, + ], + }; + let data = extract_data!(result).unwrap(); + assert_eq!(data, exp); + }) +}