use std::{
collections::{BTreeMap, HashMap},
sync::Arc,
};
use fraiseql_db::where_clause::{RelationFieldMaps, WhereFieldInfo};
use super::{
CompiledSchema, EnumDefinition, EnumValueDefinition, FieldType, InputFieldDefinition,
InputObjectDefinition,
};
pub const SORT_DIRECTION_ENUM: &str = "SortDirection";
pub const COMPARISON_OPERATORS: &[&str] = &["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin"];
pub const NULL_OPERATORS: &[&str] = &["isnull", "is_not_null"];
pub const STRING_OPERATORS: &[&str] = &[
"contains",
"icontains",
"startswith",
"istartswith",
"endswith",
"iendswith",
"like",
"ilike",
"nlike",
"nilike",
"regex",
"iregex",
"nregex",
"niregex",
];
pub const ARRAY_OPERATORS: &[&str] = &[
"array_contains",
"array_contained_by",
"array_overlaps",
"len_eq",
"len_neq",
"len_gt",
"len_gte",
"len_lt",
"len_lte",
];
pub const CONTAINMENT_OPERATORS: &[&str] = &["strictly_contains"];
pub const VECTOR_OPERATORS: &[&str] = &[
"cosine_distance",
"l2_distance",
"l1_distance",
"hamming_distance",
"inner_product",
"jaccard_distance",
];
pub const COMBINATORS: [&str; 3] = ["_and", "_or", "_not"];
#[derive(Debug, Default, Clone, PartialEq)]
pub struct DerivedInputs {
pub input_types: Vec<InputObjectDefinition>,
pub enums: Vec<EnumDefinition>,
}
#[must_use]
pub fn where_input_type_name(entity: &str) -> String {
format!("{entity}WhereInput")
}
#[must_use]
pub fn order_by_input_type_name(entity: &str) -> String {
format!("{entity}OrderByInput")
}
#[must_use]
pub fn derive(schema: &CompiledSchema) -> DerivedInputs {
let mut leaves: BTreeMap<String, LeafFilter> = BTreeMap::new();
let mut entities: Vec<InputObjectDefinition> = Vec::new();
let mut walked: Vec<String> = Vec::new();
let mut queue: Vec<String> = filterable_entities(schema);
while let Some(entity) = pop_front(&mut queue) {
if walked.contains(&entity) {
continue;
}
let Some(type_def) = adjudicable_type(schema, &entity) else {
continue;
};
walked.push(entity.clone());
let mut fields = Vec::new();
for field in &type_def.fields {
match classify(schema, &field.field_type) {
Slot::Relation(target) => {
queue.push(target.clone());
fields.push(
InputFieldDefinition::new(
field.name.as_str(),
where_input_type_name(&target),
)
.with_description(format!("Filter by the related `{target}`.")),
);
},
Slot::Leaf(leaf) => {
let type_name = leaf.type_name();
fields.push(
InputFieldDefinition::new(field.name.as_str(), type_name.clone())
.with_description(format!("Filter on `{}`.", field.name)),
);
leaves.entry(type_name).or_insert(leaf);
},
Slot::Unfilterable => {},
}
}
let name = where_input_type_name(&entity);
if schema.find_input_type(&name).is_some() {
continue;
}
fields.extend(combinator_fields(&name));
entities.push(InputObjectDefinition::new(name).with_fields(fields).with_description(
format!(
"Filter predicate over `{entity}`. Top-level keys are combined with AND; \
`_and`/`_or`/`_not` nest explicitly."
),
));
}
let mut input_types = entities;
input_types.extend(order_by_inputs(schema));
input_types.extend(
leaves
.into_values()
.filter(|leaf| schema.find_input_type(&leaf.type_name()).is_none())
.map(LeafFilter::into_input_object),
);
let mut enums = Vec::new();
if input_types.iter().any(|i| i.name.ends_with("OrderByInput"))
&& schema.find_enum(SORT_DIRECTION_ENUM).is_none()
{
enums.push(
EnumDefinition::new(SORT_DIRECTION_ENUM)
.with_value(EnumValueDefinition::new("ASC"))
.with_value(EnumValueDefinition::new("DESC"))
.with_description("Sort direction."),
);
}
DerivedInputs { input_types, enums }
}
fn order_by_inputs(schema: &CompiledSchema) -> Vec<InputObjectDefinition> {
let mut out: Vec<InputObjectDefinition> = Vec::new();
for query in &schema.queries {
if query.relay || !query.auto_params.has_order_by {
continue;
}
let name = order_by_input_type_name(&query.return_type);
if schema.find_input_type(&name).is_some() || out.iter().any(|i| i.name == name) {
continue;
}
if adjudicable_type(schema, &query.return_type).is_none() {
continue;
}
out.push(
InputObjectDefinition::new(name)
.with_fields(vec![
InputFieldDefinition::new("field", "String")
.with_nullable(false)
.with_description("Field to sort by."),
InputFieldDefinition::new("direction", SORT_DIRECTION_ENUM)
.with_default_value("ASC")
.with_description("Sort direction; ascending when omitted."),
])
.with_description(format!("One sort key over `{}`.", query.return_type)),
);
}
out
}
fn filterable_entities(schema: &CompiledSchema) -> Vec<String> {
schema
.queries
.iter()
.filter(|q| !q.relay && q.auto_params.has_where)
.map(|q| q.return_type.clone())
.collect()
}
fn adjudicable_type<'a>(
schema: &'a CompiledSchema,
name: &str,
) -> Option<&'a super::TypeDefinition> {
schema.find_type(name).filter(|t| !t.fields.is_empty())
}
fn combinator_fields(self_name: &str) -> Vec<InputFieldDefinition> {
vec![
InputFieldDefinition::new("_and", format!("[{self_name}!]"))
.with_description("Every predicate in the list must match."),
InputFieldDefinition::new("_or", format!("[{self_name}!]"))
.with_description("At least one predicate in the list must match."),
InputFieldDefinition::new("_not", self_name)
.with_description("The predicate must not match."),
]
}
fn pop_front(queue: &mut Vec<String>) -> Option<String> {
if queue.is_empty() {
return None;
}
Some(queue.remove(0))
}
enum Slot {
Relation(String),
Leaf(LeafFilter),
Unfilterable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct Ops {
string: bool,
array: bool,
containment: bool,
vector: bool,
}
impl Ops {
const fn none() -> Self {
Self {
string: false,
array: false,
containment: false,
vector: false,
}
}
const fn string() -> Self {
Self {
string: true,
..Self::none()
}
}
const fn array() -> Self {
Self {
array: true,
..Self::none()
}
}
const fn json() -> Self {
Self {
array: true,
containment: true,
..Self::none()
}
}
const fn vector() -> Self {
Self {
vector: true,
..Self::none()
}
}
fn operator_names(self) -> Vec<&'static str> {
let mut names: Vec<&'static str> = COMPARISON_OPERATORS.to_vec();
names.extend(NULL_OPERATORS);
if self.string {
names.extend(STRING_OPERATORS);
}
if self.array {
names.extend(ARRAY_OPERATORS);
}
if self.containment {
names.extend(CONTAINMENT_OPERATORS);
}
if self.vector {
names.extend(VECTOR_OPERATORS);
}
names
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LeafFilter {
key: String,
operand: String,
ops: Ops,
}
impl LeafFilter {
fn type_name(&self) -> String {
format!("{}Filter", self.key)
}
fn into_input_object(self) -> InputObjectDefinition {
let fields = self
.ops
.operator_names()
.into_iter()
.map(|op| {
InputFieldDefinition::new(op, operand_type(op, &self.operand))
.with_description(operator_description(op))
})
.collect();
InputObjectDefinition::new(self.type_name())
.with_fields(fields)
.with_description(format!(
"Filter operators over `{}`. Every key is ANDed.",
self.operand
))
}
}
fn operand_type(op: &str, operand: &str) -> String {
if matches!(op, "in" | "nin") {
return format!("[{operand}!]");
}
if NULL_OPERATORS.contains(&op) {
return "Boolean".to_string();
}
if STRING_OPERATORS.contains(&op) {
return "String".to_string();
}
if op.starts_with("len_") {
return "Int".to_string();
}
if ARRAY_OPERATORS.contains(&op) || CONTAINMENT_OPERATORS.contains(&op) {
return "JSON".to_string();
}
operand.to_string()
}
fn operator_description(op: &str) -> String {
fraiseql_db::where_clause::operator_spec(op)
.map_or_else(|| format!("`{op}`"), |spec| format!("`{op}` — SQL `{}`.", spec.sql_op))
}
#[must_use]
pub fn nested_filter_entity(schema: &CompiledSchema, field_type: &FieldType) -> Option<String> {
match classify(schema, field_type) {
Slot::Relation(name) => Some(name),
Slot::Leaf(_) | Slot::Unfilterable => None,
}
}
#[must_use]
pub fn relation_field_maps(schema: &CompiledSchema) -> RelationFieldMaps {
Arc::new(
schema
.types
.iter()
.filter(|t| !t.fields.is_empty())
.map(|t| (t.name.to_string(), Arc::new(where_keys_of(schema, t))))
.collect(),
)
}
pub(crate) fn where_keys_of(
schema: &CompiledSchema,
type_def: &super::TypeDefinition,
) -> HashMap<String, WhereFieldInfo> {
type_def
.fields
.iter()
.map(|f| {
let relation_type = nested_filter_entity(schema, &f.field_type);
(
crate::utils::to_snake_case(f.name.as_str()),
WhereFieldInfo {
declared_name: f.name.to_string(),
is_relation: relation_type.is_some(),
relation_type,
cast: Some(crate::runtime::field_type_to_where_type(&f.field_type)),
},
)
})
.collect()
}
fn classify(schema: &CompiledSchema, field_type: &FieldType) -> Slot {
let leaf = |key: &str, operand: &str, ops: Ops| {
Slot::Leaf(LeafFilter {
key: key.to_string(),
operand: operand.to_string(),
ops,
})
};
match field_type {
FieldType::Int => leaf("Int", "Int", Ops::none()),
FieldType::Float => leaf("Float", "Float", Ops::none()),
FieldType::Decimal => leaf("Decimal", "Decimal", Ops::none()),
FieldType::Boolean => leaf("Boolean", "Boolean", Ops::none()),
FieldType::String => leaf("String", "String", Ops::string()),
FieldType::Id => leaf("ID", "ID", Ops::none()),
FieldType::Uuid => leaf("UUID", "UUID", Ops::none()),
FieldType::DateTime => leaf("DateTime", "DateTime", Ops::none()),
FieldType::Date => leaf("Date", "Date", Ops::none()),
FieldType::Time => leaf("Time", "Time", Ops::none()),
FieldType::Json => leaf("JSON", "JSON", Ops::json()),
FieldType::Vector => leaf("Vector", "JSON", Ops::vector()),
FieldType::HalfVector => leaf("HalfVector", "JSON", Ops::vector()),
FieldType::BitVector => leaf("BitVector", "String", Ops::vector()),
FieldType::SparseVector => leaf("SparseVector", "String", Ops::vector()),
FieldType::Scalar(name) => leaf(name, name, Ops::string()),
FieldType::Enum(name) => leaf(name, name, Ops::none()),
FieldType::Object(name) => match adjudicable_type(schema, name) {
Some(_) => Slot::Relation(name.clone()),
None => leaf(name, name, Ops::string()),
},
FieldType::List(inner) => match classify(schema, inner) {
Slot::Leaf(inner_leaf) => Slot::Leaf(LeafFilter {
key: format!("{}List", inner_leaf.key),
operand: format!("[{}!]", inner_leaf.operand),
ops: Ops::array(),
}),
Slot::Relation(name) => Slot::Leaf(LeafFilter {
key: format!("{name}List"),
operand: "JSON".to_string(),
ops: Ops::array(),
}),
Slot::Unfilterable => Slot::Unfilterable,
},
FieldType::Interface(_) | FieldType::Union(_) | FieldType::Input(_) => Slot::Unfilterable,
}
}
#[cfg(test)]
#[path = "derived_inputs_tests.rs"]
mod tests;