Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: AND/OR query filters #3934

Closed
wants to merge 10 commits into from
27 changes: 25 additions & 2 deletions graphql/src/schema/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,36 @@ fn add_filter_type(
None => {
let mut generated_filter_fields = field_input_values(schema, fields)?;
generated_filter_fields.push(block_changed_filter_argument());

generated_filter_fields.push(InputValue {
position: Pos::default(),
description: None,
name: "AND".to_string(),
value_type: Type::NamedType(filter_type_name.to_owned()),
default_value: None,
directives: vec![],
});
generated_filter_fields.push(InputValue {
position: Pos::default(),
description: None,
name: "AND".to_string(),
value_type: 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::NamedType(filter_type_name.to_owned()),
default_value: None,
directives: vec![],
});
let typedef = TypeDefinition::InputObject(InputObjectType {
position: Pos::default(),
description: None,
name: filter_type_name,
directives: vec![],
fields: generated_filter_fields,
fields: generated_filter_fields.clone(),
saihaj marked this conversation as resolved.
Show resolved Hide resolved
});
let def = Definition::TypeDefinition(typedef);
schema.definitions.push(def);
Expand Down
14 changes: 11 additions & 3 deletions graphql/src/schema/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use graph::prelude::{s, Error, ValueType};

use crate::query::ast as qast;

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
saihaj marked this conversation as resolved.
Show resolved Hide resolved
pub(crate) enum FilterOp {
Not,
GreaterThan,
Expand All @@ -34,6 +35,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`).
Expand Down Expand Up @@ -67,11 +70,16 @@ 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.
Expand Down
139 changes: 92 additions & 47 deletions graphql/src/store/query.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use std::mem::discriminant;

use anyhow::Result;
use graph::data::graphql::ext::DirectiveFinder;
use graph::data::graphql::TypeExt as _;
use graph::data::value::Object;
Expand All @@ -9,7 +10,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;

Expand Down Expand Up @@ -161,6 +162,45 @@ fn parse_change_block_filter(value: &r::Value) -> Result<BlockNumber, QueryExecu
}
}

/// Parses a GraphQL Filter Value into an EntityFilter.
fn build_entity_filter(
field_name: String,
operation: FilterOp,
store_value: Value,
) -> Result<EntityFilter, QueryExecutionError> {
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!(),
};
}

/// Parses a GraphQL input object into an EntityFilter, if present.
fn build_filter_from_object(
entity: ObjectOrInterface,
Expand All @@ -179,68 +219,73 @@ fn build_filter_from_object(
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(),
)
})?;

let ty = &field.field_type;

Ok(match op {
AND => {
return match value {
r::Value::Object(object) => {
return build_filter_from_object(entity, object, schema);
}
_ => Err(QueryExecutionError::InvalidFilterError),
};
}
OR => {
return match value {
r::Value::Object(object) => {
let filters = object
.iter()
.map(|(key, value)| {
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(),
)
})?;
let ty = &field.field_type;
let store_value = Value::from_query_value(value, ty)?;
return build_entity_filter(field_name, op, store_value);
})
.collect::<Result<Vec<EntityFilter>, QueryExecutionError>>();
return match filters {
Ok(f) => Ok(EntityFilter::Or(f)),
Err(e) => Err(e),
};
}
_ => Err(QueryExecutionError::InvalidFilterError),
};
}
Child => match value {
DataValue::Object(obj) => {
build_child_filter_from_object(entity, field_name, obj, schema)?
}
_ => {
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)?;

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!(),
}
return build_entity_filter(field_name, op, store_value);
}
})
})
Expand Down
43 changes: 43 additions & 0 deletions graphql/tests/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1801,3 +1801,46 @@ 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);
})
}
saihaj marked this conversation as resolved.
Show resolved Hide resolved