use fraiseql_error::{FraiseQLError, Result};
use serde::{Deserialize, Serialize};
use crate::utils::to_snake_case;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WhereClause {
Field {
path: Vec<String>,
operator: WhereOperator,
value: serde_json::Value,
},
And(Vec<WhereClause>),
Or(Vec<WhereClause>),
Not(Box<WhereClause>),
NativeField {
column: String,
pg_cast: String,
operator: WhereOperator,
value: serde_json::Value,
},
}
impl WhereClause {
#[must_use]
pub const fn is_empty(&self) -> bool {
match self {
Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
Self::Not(_) | Self::Field { .. } | Self::NativeField { .. } => false,
}
}
#[must_use]
pub fn native_column_names(&self) -> Vec<&str> {
let mut names = Vec::new();
self.collect_native_column_names(&mut names);
names
}
fn collect_native_column_names<'a>(&'a self, out: &mut Vec<&'a str>) {
match self {
Self::And(clauses) | Self::Or(clauses) => {
for c in clauses {
c.collect_native_column_names(out);
}
},
Self::Not(inner) => inner.collect_native_column_names(out),
Self::NativeField { column, .. } => out.push(column),
Self::Field { .. } => {},
}
}
pub fn from_graphql_json(value: &serde_json::Value) -> Result<Self> {
Self::parse_where_object(value, &[])
}
fn parse_where_object(value: &serde_json::Value, path_prefix: &[String]) -> Result<Self> {
let Some(obj) = value.as_object() else {
return Err(FraiseQLError::Validation {
message: "where clause must be a JSON object".to_string(),
path: None,
});
};
let mut conditions = Vec::new();
for (key, val) in obj {
match key.as_str() {
"_and" => {
let arr = val.as_array().ok_or_else(|| FraiseQLError::Validation {
message: "_and must be an array".to_string(),
path: None,
})?;
let sub: Result<Vec<Self>> =
arr.iter().map(|v| Self::parse_where_object(v, path_prefix)).collect();
conditions.push(Self::And(sub?));
},
"_or" => {
let arr = val.as_array().ok_or_else(|| FraiseQLError::Validation {
message: "_or must be an array".to_string(),
path: None,
})?;
let sub: Result<Vec<Self>> =
arr.iter().map(|v| Self::parse_where_object(v, path_prefix)).collect();
conditions.push(Self::Or(sub?));
},
"_not" => {
let sub = Self::parse_where_object(val, path_prefix)?;
conditions.push(Self::Not(Box::new(sub)));
},
field_name => {
let ops = val.as_object().ok_or_else(|| FraiseQLError::Validation {
message: format!(
"where field '{field_name}' must be an object of {{operator: value}}"
),
path: None,
})?;
let mut field_path = path_prefix.to_vec();
field_path.push(to_snake_case(field_name));
for (op_str, op_val) in ops {
match WhereOperator::from_str(op_str) {
Ok(operator) => {
conditions.push(Self::Field {
path: field_path.clone(),
operator,
value: op_val.clone(),
});
},
Err(_) if op_val.is_object() => {
let nested_json = serde_json::json!({ op_str: op_val });
let nested = Self::parse_where_object(&nested_json, &field_path)?;
conditions.push(nested);
},
Err(e) => return Err(e),
}
}
},
}
}
if conditions.len() == 1 {
Ok(conditions.into_iter().next().expect("checked len == 1"))
} else {
Ok(Self::And(conditions))
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WhereOperator {
Eq,
Neq,
Gt,
Gte,
Lt,
Lte,
In,
Nin,
Contains,
Icontains,
Startswith,
Istartswith,
Endswith,
Iendswith,
Like,
Ilike,
Nlike,
Nilike,
Regex,
Iregex,
Nregex,
Niregex,
IsNull,
ArrayContains,
ArrayContainedBy,
ArrayOverlaps,
LenEq,
LenGt,
LenLt,
LenGte,
LenLte,
LenNeq,
CosineDistance,
L2Distance,
L1Distance,
HammingDistance,
InnerProduct,
JaccardDistance,
Matches,
PlainQuery,
PhraseQuery,
WebsearchQuery,
IsIPv4,
IsIPv6,
IsPrivate,
IsPublic,
IsLoopback,
InSubnet,
ContainsSubnet,
ContainsIP,
Overlaps,
StrictlyContains,
AncestorOf,
DescendantOf,
MatchesLquery,
MatchesLtxtquery,
MatchesAnyLquery,
DepthEq,
DepthNeq,
DepthGt,
DepthGte,
DepthLt,
DepthLte,
Lca,
#[serde(skip)]
Extended(crate::filters::ExtendedOperator),
}
impl WhereOperator {
#[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Result<Self> {
match s {
"eq" => Ok(Self::Eq),
"neq" => Ok(Self::Neq),
"gt" => Ok(Self::Gt),
"gte" => Ok(Self::Gte),
"lt" => Ok(Self::Lt),
"lte" => Ok(Self::Lte),
"in" => Ok(Self::In),
"nin" | "notin" => Ok(Self::Nin),
"contains" => Ok(Self::Contains),
"icontains" => Ok(Self::Icontains),
"startswith" => Ok(Self::Startswith),
"istartswith" => Ok(Self::Istartswith),
"endswith" => Ok(Self::Endswith),
"iendswith" => Ok(Self::Iendswith),
"like" => Ok(Self::Like),
"ilike" => Ok(Self::Ilike),
"nlike" => Ok(Self::Nlike),
"nilike" => Ok(Self::Nilike),
"regex" => Ok(Self::Regex),
"iregex" | "imatches" => Ok(Self::Iregex),
"nregex" | "not_matches" => Ok(Self::Nregex),
"niregex" => Ok(Self::Niregex),
"isnull" => Ok(Self::IsNull),
"array_contains" => Ok(Self::ArrayContains),
"array_contained_by" => Ok(Self::ArrayContainedBy),
"array_overlaps" => Ok(Self::ArrayOverlaps),
"len_eq" => Ok(Self::LenEq),
"len_gt" => Ok(Self::LenGt),
"len_lt" => Ok(Self::LenLt),
"len_gte" => Ok(Self::LenGte),
"len_lte" => Ok(Self::LenLte),
"len_neq" => Ok(Self::LenNeq),
"cosine_distance" => Ok(Self::CosineDistance),
"l2_distance" => Ok(Self::L2Distance),
"l1_distance" => Ok(Self::L1Distance),
"hamming_distance" => Ok(Self::HammingDistance),
"inner_product" => Ok(Self::InnerProduct),
"jaccard_distance" => Ok(Self::JaccardDistance),
"matches" => Ok(Self::Matches),
"plain_query" => Ok(Self::PlainQuery),
"phrase_query" => Ok(Self::PhraseQuery),
"websearch_query" => Ok(Self::WebsearchQuery),
"is_ipv4" => Ok(Self::IsIPv4),
"is_ipv6" => Ok(Self::IsIPv6),
"is_private" => Ok(Self::IsPrivate),
"is_public" => Ok(Self::IsPublic),
"is_loopback" => Ok(Self::IsLoopback),
"in_subnet" | "inrange" => Ok(Self::InSubnet),
"contains_subnet" => Ok(Self::ContainsSubnet),
"contains_ip" => Ok(Self::ContainsIP),
"overlaps" => Ok(Self::Overlaps),
"strictly_contains" => Ok(Self::StrictlyContains),
"ancestor_of" => Ok(Self::AncestorOf),
"descendant_of" => Ok(Self::DescendantOf),
"matches_lquery" => Ok(Self::MatchesLquery),
"matches_ltxtquery" => Ok(Self::MatchesLtxtquery),
"matches_any_lquery" => Ok(Self::MatchesAnyLquery),
"depth_eq" => Ok(Self::DepthEq),
"depth_neq" => Ok(Self::DepthNeq),
"depth_gt" => Ok(Self::DepthGt),
"depth_gte" => Ok(Self::DepthGte),
"depth_lt" => Ok(Self::DepthLt),
"depth_lte" => Ok(Self::DepthLte),
"lca" => Ok(Self::Lca),
_ => Err(FraiseQLError::validation(format!("Unknown WHERE operator: {s}"))),
}
}
#[must_use]
pub const fn expects_array(&self) -> bool {
matches!(self, Self::In | Self::Nin)
}
#[must_use]
pub const fn is_case_insensitive(&self) -> bool {
matches!(
self,
Self::Icontains
| Self::Istartswith
| Self::Iendswith
| Self::Ilike
| Self::Nilike
| Self::Iregex
| Self::Niregex
)
}
#[must_use]
pub const fn is_string_operator(&self) -> bool {
matches!(
self,
Self::Contains
| Self::Icontains
| Self::Startswith
| Self::Istartswith
| Self::Endswith
| Self::Iendswith
| Self::Like
| Self::Ilike
| Self::Nlike
| Self::Nilike
| Self::Regex
| Self::Iregex
| Self::Nregex
| Self::Niregex
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum HavingClause {
Aggregate {
aggregate: String,
operator: WhereOperator,
value: serde_json::Value,
},
And(Vec<HavingClause>),
Or(Vec<HavingClause>),
Not(Box<HavingClause>),
}
impl HavingClause {
#[must_use]
pub const fn is_empty(&self) -> bool {
match self {
Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
Self::Not(_) | Self::Aggregate { .. } => false,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use serde_json::json;
use super::*;
#[test]
fn test_where_operator_from_str() {
assert_eq!(WhereOperator::from_str("eq").unwrap(), WhereOperator::Eq);
assert_eq!(WhereOperator::from_str("icontains").unwrap(), WhereOperator::Icontains);
assert_eq!(WhereOperator::from_str("gte").unwrap(), WhereOperator::Gte);
assert!(
matches!(WhereOperator::from_str("unknown"), Err(FraiseQLError::Validation { .. })),
"expected Validation error for unknown operator"
);
}
#[test]
fn test_where_operator_expects_array() {
assert!(WhereOperator::In.expects_array());
assert!(WhereOperator::Nin.expects_array());
assert!(!WhereOperator::Eq.expects_array());
}
#[test]
fn test_where_operator_is_case_insensitive() {
assert!(WhereOperator::Icontains.is_case_insensitive());
assert!(WhereOperator::Ilike.is_case_insensitive());
assert!(!WhereOperator::Contains.is_case_insensitive());
}
#[test]
fn test_where_clause_simple() {
let clause = WhereClause::Field {
path: vec!["email".to_string()],
operator: WhereOperator::Eq,
value: json!("test@example.com"),
};
assert!(!clause.is_empty());
}
#[test]
fn test_where_clause_and() {
let clause = WhereClause::And(vec![
WhereClause::Field {
path: vec!["published".to_string()],
operator: WhereOperator::Eq,
value: json!(true),
},
WhereClause::Field {
path: vec!["views".to_string()],
operator: WhereOperator::Gte,
value: json!(100),
},
]);
assert!(!clause.is_empty());
}
#[test]
fn test_where_clause_empty() {
let clause = WhereClause::And(vec![]);
assert!(clause.is_empty());
}
#[test]
fn test_from_graphql_json_simple_field() {
let json = json!({ "status": { "eq": "active" } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec!["status".to_string()],
operator: WhereOperator::Eq,
value: json!("active"),
}
);
}
#[test]
fn test_from_graphql_json_camelcase_field_normalized_to_snake_case() {
let json = json!({ "ipAddress": { "eq": "10.0.0.1" } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec!["ip_address".to_string()],
operator: WhereOperator::Eq,
value: json!("10.0.0.1"),
}
);
}
#[test]
fn test_from_graphql_json_snake_case_field_unchanged() {
let json = json!({ "ip_address": { "eq": "10.0.0.1" } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec!["ip_address".to_string()],
operator: WhereOperator::Eq,
value: json!("10.0.0.1"),
}
);
}
#[test]
fn test_from_graphql_json_multiple_fields() {
let json = json!({
"status": { "eq": "active" },
"age": { "gte": 18 }
});
let clause = WhereClause::from_graphql_json(&json).unwrap();
match clause {
WhereClause::And(conditions) => assert_eq!(conditions.len(), 2),
_ => panic!("expected And"),
}
}
#[test]
fn test_from_graphql_json_logical_combinators() {
let json = json!({
"_or": [
{ "role": { "eq": "admin" } },
{ "role": { "eq": "superadmin" } }
]
});
let clause = WhereClause::from_graphql_json(&json).unwrap();
match clause {
WhereClause::Or(conditions) => assert_eq!(conditions.len(), 2),
_ => panic!("expected Or"),
}
}
#[test]
fn test_from_graphql_json_not() {
let json = json!({ "_not": { "deleted": { "eq": true } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert!(matches!(clause, WhereClause::Not(_)));
}
#[test]
fn test_from_graphql_json_invalid_operator() {
let json = json!({ "field": { "nonexistent_op": 42 } });
let result = WhereClause::from_graphql_json(&json);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error, got: {result:?}"
);
}
#[test]
fn test_nested_relation_where_builds_path() {
let json = json!({ "machine": { "id": { "eq": "abc" } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec!["machine".to_string(), "id".to_string()],
operator: WhereOperator::Eq,
value: json!("abc"),
}
);
}
#[test]
fn test_nested_relation_where_camelcase_normalized() {
let json = json!({ "machineGroup": { "ipAddress": { "eq": "10.0.0.1" } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec!["machine_group".to_string(), "ip_address".to_string()],
operator: WhereOperator::Eq,
value: json!("10.0.0.1"),
}
);
}
#[test]
fn test_nested_relation_where_multiple_operators() {
let json =
json!({ "machine": { "id": { "eq": "abc" } , "name": { "icontains": "test" } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
match clause {
WhereClause::And(conditions) => {
assert_eq!(conditions.len(), 2);
for cond in &conditions {
match cond {
WhereClause::Field { path, .. } => {
assert_eq!(path[0], "machine");
},
other => panic!("expected Field, got {other:?}"),
}
}
},
_ => panic!("expected And for multiple nested conditions"),
}
}
#[test]
fn test_unknown_operator_still_errors() {
let json = json!({ "name": { "bogus": "value" } });
assert!(WhereClause::from_graphql_json(&json).is_err());
}
#[test]
fn test_new_string_operators_from_str() {
assert_eq!(WhereOperator::from_str("nlike").unwrap(), WhereOperator::Nlike);
assert_eq!(WhereOperator::from_str("nilike").unwrap(), WhereOperator::Nilike);
assert_eq!(WhereOperator::from_str("regex").unwrap(), WhereOperator::Regex);
assert_eq!(WhereOperator::from_str("iregex").unwrap(), WhereOperator::Iregex);
assert_eq!(WhereOperator::from_str("nregex").unwrap(), WhereOperator::Nregex);
assert_eq!(WhereOperator::from_str("niregex").unwrap(), WhereOperator::Niregex);
}
#[test]
fn test_v1_aliases_from_str() {
assert_eq!(WhereOperator::from_str("notin").unwrap(), WhereOperator::Nin);
assert_eq!(WhereOperator::from_str("inrange").unwrap(), WhereOperator::InSubnet);
assert_eq!(WhereOperator::from_str("imatches").unwrap(), WhereOperator::Iregex);
assert_eq!(WhereOperator::from_str("not_matches").unwrap(), WhereOperator::Nregex);
}
#[test]
fn test_new_operators_case_insensitive_flag() {
assert!(WhereOperator::Nilike.is_case_insensitive());
assert!(WhereOperator::Iregex.is_case_insensitive());
assert!(WhereOperator::Niregex.is_case_insensitive());
assert!(!WhereOperator::Nlike.is_case_insensitive());
assert!(!WhereOperator::Regex.is_case_insensitive());
assert!(!WhereOperator::Nregex.is_case_insensitive());
}
#[test]
fn test_nested_relation_filter_builds_multi_segment_path() {
let json = json!({ "machine": { "id": { "eq": "some-uuid" } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec!["machine".to_string(), "id".to_string()],
operator: WhereOperator::Eq,
value: json!("some-uuid"),
}
);
}
#[test]
fn test_nested_relation_filter_multiple_fields() {
let json = json!({ "machine": { "id": { "eq": "uuid" }, "name": { "contains": "x" } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
match clause {
WhereClause::And(conditions) => {
assert_eq!(conditions.len(), 2);
assert!(
conditions.iter().all(|c| matches!(c, WhereClause::Field { .. })),
"all conditions should be Field with multi-segment paths"
);
},
other => panic!("expected And of Fields, got: {other:?}"),
}
}
#[test]
fn test_deeply_nested_filter_builds_three_segment_path() {
let json = json!({ "items": { "product": { "category": { "eq": "electronics" } } } });
let clause = WhereClause::from_graphql_json(&json).unwrap();
assert_eq!(
clause,
WhereClause::Field {
path: vec![
"items".to_string(),
"product".to_string(),
"category".to_string(),
],
operator: WhereOperator::Eq,
value: json!("electronics"),
}
);
}
#[test]
fn test_unknown_operator_scalar_value_still_errors() {
let json = json!({ "field": { "nonexistent_op": 42 } });
let result = WhereClause::from_graphql_json(&json);
match result {
Err(FraiseQLError::Validation { message, .. }) => {
assert!(
message.contains("Unknown WHERE operator"),
"expected unknown operator error, got: {message}"
);
},
other => panic!("expected Validation error, got: {other:?}"),
}
}
#[test]
fn test_new_operators_are_string_operators() {
assert!(WhereOperator::Nlike.is_string_operator());
assert!(WhereOperator::Nilike.is_string_operator());
assert!(WhereOperator::Regex.is_string_operator());
assert!(WhereOperator::Iregex.is_string_operator());
assert!(WhereOperator::Nregex.is_string_operator());
assert!(WhereOperator::Niregex.is_string_operator());
}
}