pub mod field_types;
pub mod operator_table;
use std::{collections::HashMap, sync::Arc};
use fraiseql_error::{FraiseQLError, Result};
use serde::{Deserialize, Serialize};
pub use self::{
field_types::{
FieldTypeMap, RelationFieldMaps, SharedFieldTypes, WhereFieldInfo, WhereFieldSchema,
},
operator_table::{OperatorCategory, WHERE_OPERATORS, WhereOperatorSpec, operator_spec},
};
use crate::{types::sql_hints::ScalarFieldType, 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,
},
Typed {
types: SharedFieldTypes,
inner: Box<WhereClause>,
},
Guarded {
under: Vec<String>,
guard: Box<WhereClause>,
inner: Box<WhereClause>,
},
KeyIn {
path: Vec<String>,
key_type: ScalarFieldType,
view: String,
target_key: Vec<String>,
predicate: Box<WhereClause>,
},
}
impl WhereClause {
#[must_use]
pub fn is_empty(&self) -> bool {
match self {
Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
Self::Typed { inner, .. } | Self::Guarded { inner, .. } => inner.is_empty(),
Self::Not(_) | Self::Field { .. } | Self::NativeField { .. } | Self::KeyIn { .. } => {
false
},
}
}
#[must_use]
pub fn typed(self, types: SharedFieldTypes) -> Self {
Self::Typed {
types,
inner: Box::new(self),
}
}
#[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) | Self::Typed { inner, .. } => inner.collect_native_column_names(out),
Self::Guarded { guard, inner, .. } => {
guard.collect_native_column_names(out);
inner.collect_native_column_names(out);
},
Self::NativeField { column, .. } => out.push(column),
Self::Field { .. } | Self::KeyIn { .. } => {},
}
}
pub fn from_graphql_json(value: &serde_json::Value, schema: &WhereFieldSchema) -> Result<Self> {
let mut discovered = Vec::new();
let parsed =
Self::parse_where_object(value, &[], schema, schema.root_level(), &mut discovered)?;
let types = schema.casts();
if types.is_empty() && discovered.is_empty() {
return Ok(parsed);
}
Ok(parsed.typed(Arc::new(types.as_ref().clone().with_paths(discovered))))
}
fn parse_where_object(
value: &serde_json::Value,
path_prefix: &[String],
schema: &WhereFieldSchema,
level: Option<&HashMap<String, WhereFieldInfo>>,
discovered: &mut Vec<(String, ScalarFieldType)>,
) -> 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, schema, level, discovered)
})
.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, schema, level, discovered)
})
.collect();
conditions.push(Self::Or(sub?));
},
"_not" => {
let sub =
Self::parse_where_object(val, path_prefix, schema, level, discovered)?;
conditions.push(Self::Not(Box::new(sub)));
},
field_name => {
crate::utils::validate_graphql_identifier(field_name, "where")?;
let snake = to_snake_case(field_name);
if let Some(level) = level {
match level.get(&snake) {
Some(info) if info.declared_name == field_name => {},
Some(info) => {
return Err(undeclared_where_spelling(
field_name,
&info.declared_name,
));
},
None => return Err(unknown_where_field(field_name, level)),
}
}
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(snake.clone());
if let Some(info) = level.and_then(|l| l.get(&snake)) {
if info.is_relation {
let nested_level = info
.relation_type
.as_deref()
.and_then(|target| schema.level_of(target));
conditions.push(Self::parse_where_object(
val,
&field_path,
schema,
nested_level,
discovered,
)?);
continue;
}
}
if !path_prefix.is_empty() {
if let Some(cast) = level.and_then(|l| l.get(&snake)).and_then(|i| i.cast) {
discovered.push((field_path.join("."), cast));
}
}
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() => {
if level
.and_then(|l| l.get(&snake))
.is_some_and(|info| !info.is_relation)
{
return Err(FraiseQLError::Validation {
message: format!(
"Unknown WHERE operator '{op_str}' on field \
'{field_name}'. '{field_name}' is a scalar field, so \
it cannot take a nested filter."
),
path: None,
});
}
let nested_json = serde_json::json!({ op_str: op_val });
let nested_level = level
.and_then(|l| l.get(&snake))
.and_then(|info| info.relation_type.as_deref())
.and_then(|target| schema.level_of(target));
let nested = Self::parse_where_object(
&nested_json,
&field_path,
schema,
nested_level,
discovered,
)?;
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))
}
}
}
fn undeclared_where_spelling(field_name: &str, declared: &str) -> FraiseQLError {
FraiseQLError::Validation {
message: format!(
"Unknown field '{field_name}' in where clause. Use '{declared}', the name this \
schema declares — the underlying storage spelling is not part of the published \
filter input."
),
path: Some(format!("where.{field_name}")),
}
}
fn unknown_where_field(field_name: &str, level: &HashMap<String, WhereFieldInfo>) -> FraiseQLError {
let candidates: Vec<&str> = level.values().map(|i| i.declared_name.as_str()).collect();
let subject = format!("Unknown field '{field_name}' in where clause.");
let message = match crate::utils::suggest_similar(field_name, &candidates).as_slice() {
[s] => format!("{subject} Did you mean '{s}'?"),
[a, b] => format!("{subject} Did you mean '{a}' or '{b}'?"),
[a, b, c, ..] => format!("{subject} Did you mean '{a}', '{b}', or '{c}'?"),
_ => subject,
};
FraiseQLError::Validation {
message,
path: Some(format!("where.{field_name}")),
}
}
#[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,
IsNotNull,
ArrayContains,
ArrayContainedBy,
ArrayOverlaps,
LenEq,
LenGt,
LenLt,
LenGte,
LenLte,
LenNeq,
CosineDistance,
L2Distance,
L1Distance,
HammingDistance,
InnerProduct,
JaccardDistance,
Matches,
PlainQuery,
PhraseQuery,
WebsearchQuery,
IsIPv4,
IsIPv6,
IsPrivate,
IsLoopback,
IsMulticast,
IsLinkLocal,
IsDocumentation,
IsCarrierGrade,
InSubnet,
ContainsSubnet,
ContainsIP,
Overlaps,
StrictlyContains,
AncestorOf,
DescendantOf,
MatchesLquery,
MatchesLtxtquery,
MatchesAnyLquery,
DepthEq,
DepthNeq,
DepthGt,
DepthGte,
DepthLt,
DepthLte,
Lca,
DescendantOfId,
AncestorOfId,
}
impl WhereOperator {
#[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Result<Self> {
if let Some(op) = Self::match_exact(s) {
return Ok(op);
}
if !s.contains('_') && s.chars().any(char::is_uppercase) {
let snake = crate::utils::to_snake_case(s);
if let Some(op) = Self::match_exact(&snake) {
return Ok(op);
}
}
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)]
mod tests;