use serde_json::Value;
use crate::{
error::{FraiseQLError, Result},
graphql::{
types::{GraphQLArgument, VariableDefinition},
value_json,
},
schema::{ArgumentDefinition, CompiledSchema, EnumDefinition, FieldType},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scalar {
Int,
Float,
String,
Boolean,
Id,
Uuid,
DateTime,
Date,
Time,
Decimal,
}
impl Scalar {
const fn from_field_type(field_type: &FieldType) -> Option<Self> {
match field_type {
FieldType::Int => Some(Self::Int),
FieldType::Float => Some(Self::Float),
FieldType::String => Some(Self::String),
FieldType::Boolean => Some(Self::Boolean),
FieldType::Id => Some(Self::Id),
FieldType::Uuid => Some(Self::Uuid),
FieldType::DateTime => Some(Self::DateTime),
FieldType::Date => Some(Self::Date),
FieldType::Time => Some(Self::Time),
FieldType::Decimal => Some(Self::Decimal),
_ => None,
}
}
fn from_type_name(name: &str) -> Option<Self> {
match name {
"Int" => Some(Self::Int),
"Float" => Some(Self::Float),
"String" => Some(Self::String),
"Boolean" => Some(Self::Boolean),
"ID" => Some(Self::Id),
"UUID" => Some(Self::Uuid),
"DateTime" => Some(Self::DateTime),
"Date" => Some(Self::Date),
"Time" => Some(Self::Time),
"Decimal" => Some(Self::Decimal),
_ => None,
}
}
const fn name(self) -> &'static str {
match self {
Self::Int => "Int",
Self::Float => "Float",
Self::String => "String",
Self::Boolean => "Boolean",
Self::Id => "ID",
Self::Uuid => "UUID",
Self::DateTime => "DateTime",
Self::Date => "Date",
Self::Time => "Time",
Self::Decimal => "Decimal",
}
}
fn rejection(self, value: &Value) -> Option<String> {
if self.accepts(value) {
return None;
}
if self == Self::Int && value.is_i64() {
return Some(format!("an Int outside the 32-bit range ({}..={})", i32::MIN, i32::MAX));
}
Some(format!("a {} value", json_shape(value)))
}
fn accepts(self, value: &Value) -> bool {
if value.is_null() {
return true;
}
match self {
Self::Int => value.as_i64().is_some_and(|v| i32::try_from(v).is_ok()),
Self::Float => value.is_number(),
Self::String | Self::Uuid | Self::DateTime | Self::Date | Self::Time => {
value.is_string()
},
Self::Boolean => value.is_boolean(),
Self::Id => value.is_string() || value.as_i64().is_some(),
Self::Decimal => value.is_string() || value.is_number(),
}
}
fn usable_at(self, location: Self) -> bool {
if self == location {
return true;
}
matches!(
(self, location),
(Self::Int, Self::Float | Self::Decimal | Self::Id)
| (Self::Float, Self::Decimal)
| (
Self::String,
Self::Id
| Self::Uuid
| Self::DateTime
| Self::Date
| Self::Time
| Self::Decimal
)
| (Self::Id, Self::String | Self::Uuid)
| (Self::Uuid, Self::String | Self::Id)
| (Self::DateTime | Self::Date | Self::Time | Self::Decimal, Self::String)
)
}
}
fn json_shape(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "Boolean",
Value::Number(n) => {
if n.is_f64() {
"Float"
} else {
"Int"
}
},
Value::String(_) => "String",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
pub fn validate_argument_values(
field_label: &str,
declared: &[ArgumentDefinition],
provided: &[GraphQLArgument],
variable_defs: &[VariableDefinition],
) -> Result<()> {
for arg in provided {
let Some(def) = declared.iter().find(|d| d.name == arg.name) else {
continue;
};
let Some(location) = Scalar::from_field_type(&def.arg_type) else {
continue;
};
if arg.value_type == "enum" {
continue;
}
let value = value_json::decode(&arg.value_json)?;
if let Some(var_name) = value_json::variable_name(&value) {
check_variable_usage(field_label, &arg.name, location, var_name, variable_defs)?;
} else if let Some(reason) = location.rejection(&value) {
return Err(FraiseQLError::Validation {
message: format!(
"Argument `{}` on {field_label} has type `{}`, but the document wrote {reason}",
arg.name,
location.name(),
),
path: Some(arg.name.clone()),
});
}
}
Ok(())
}
fn check_variable_usage(
field_label: &str,
arg_name: &str,
location: Scalar,
var_name: &str,
variable_defs: &[VariableDefinition],
) -> Result<()> {
let Some(def) = variable_defs.iter().find(|v| v.name == var_name) else {
return Ok(());
};
if def.var_type.list {
return Ok(());
}
let Some(declared) = Scalar::from_type_name(&def.var_type.name) else {
return Ok(());
};
if declared.usable_at(location) {
return Ok(());
}
Err(FraiseQLError::Validation {
message: format!(
"Variable `${var_name}` is declared as `{}` but is used for argument `{arg_name}` on \
{field_label}, which has type `{}`",
def.var_type.name,
location.name()
),
path: Some(arg_name.to_string()),
})
}
pub fn validate_variable_values(
operation_name: Option<&str>,
variable_defs: &[VariableDefinition],
values: Option<&Value>,
) -> Result<()> {
let supplied = values.and_then(Value::as_object);
for def in variable_defs {
let value = supplied.and_then(|map| map.get(&def.name));
let Some(value) = value else {
if !def.var_type.nullable && def.default_value.is_none() {
return Err(FraiseQLError::Validation {
message: format!(
"Variable `${}` is declared `{}!`{} but no value was supplied",
def.name,
def.var_type.name,
operation_label(operation_name)
),
path: Some(def.name.clone()),
});
}
continue;
};
if value.is_null() && !def.var_type.nullable {
return Err(FraiseQLError::Validation {
message: format!(
"Variable `${}` is declared `{}!`{} but was supplied as null",
def.name,
def.var_type.name,
operation_label(operation_name)
),
path: Some(def.name.clone()),
});
}
if def.var_type.list {
continue;
}
let Some(declared) = Scalar::from_type_name(&def.var_type.name) else {
continue;
};
if let Some(reason) = declared.rejection(value) {
return Err(FraiseQLError::Validation {
message: format!(
"Variable `${}` is declared `{}`{} but was supplied {reason}",
def.name,
def.var_type.name,
operation_label(operation_name),
),
path: Some(def.name.clone()),
});
}
}
Ok(())
}
const MAX_WALK_DEPTH: usize = 32;
const MAX_MEMBERS_LISTED: usize = 12;
pub fn validate_enum_argument_values(
schema: &CompiledSchema,
field_label: &str,
declared: &[ArgumentDefinition],
provided: Option<&Value>,
) -> Result<()> {
let Some(map) = provided.and_then(Value::as_object) else {
return Ok(());
};
for arg in declared {
let Some(value) = map.get(&arg.name) else {
continue;
};
walk_field_type(schema, field_label, &arg.name, &arg.arg_type, value, 0)?;
}
Ok(())
}
pub fn validate_enum_argument_literals(
schema: &CompiledSchema,
field_label: &str,
declared: &[ArgumentDefinition],
provided: &[GraphQLArgument],
) -> Result<()> {
for arg in provided {
let Some(def) = declared.iter().find(|d| d.name == arg.name) else {
continue;
};
let value = value_json::decode(&arg.value_json)?;
if value_json::variable_name(&value).is_some() {
continue;
}
walk_field_type(schema, field_label, &arg.name, &def.arg_type, &value, 0)?;
}
Ok(())
}
pub fn validate_enum_variable_values(
schema: &CompiledSchema,
operation_name: Option<&str>,
variable_defs: &[VariableDefinition],
values: Option<&Value>,
) -> Result<()> {
let Some(map) = values.and_then(Value::as_object) else {
return Ok(());
};
for def in variable_defs {
let Some(value) = map.get(&def.name) else {
continue;
};
let subject = format!("Variable `${}`{}", def.name, operation_label(operation_name));
walk_type_ref(schema, &subject, "", &def.var_type.name, value, 0)?;
}
Ok(())
}
fn walk_field_type(
schema: &CompiledSchema,
subject: &str,
path: &str,
declared: &FieldType,
value: &Value,
depth: usize,
) -> Result<()> {
if value.is_null() || depth > MAX_WALK_DEPTH {
return Ok(());
}
match declared {
FieldType::List(inner) => walk_list(schema, subject, path, value, depth, |v, p, d| {
walk_field_type(schema, subject, p, inner, v, d)
}),
FieldType::Enum(name) => check_membership(schema, subject, path, name, value),
FieldType::Object(name) | FieldType::Input(name) => {
walk_input_object(schema, subject, path, name, value, depth)
},
_ => Ok(()),
}
}
fn walk_type_ref(
schema: &CompiledSchema,
subject: &str,
path: &str,
declared: &str,
value: &Value,
depth: usize,
) -> Result<()> {
if depth > MAX_WALK_DEPTH {
return Ok(());
}
let declared = declared.trim();
if let Some(inner) = declared.strip_suffix('!') {
return walk_type_ref(schema, subject, path, inner, value, depth);
}
if let Some(inner) = declared.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
return walk_list(schema, subject, path, value, depth, |v, p, d| {
walk_type_ref(schema, subject, p, inner, v, d)
});
}
if value.is_null() {
return Ok(());
}
if schema.find_enum(declared).is_some() {
return check_membership(schema, subject, path, declared, value);
}
walk_input_object(schema, subject, path, declared, value, depth)
}
fn walk_list(
_schema: &CompiledSchema,
_subject: &str,
path: &str,
value: &Value,
depth: usize,
mut walk: impl FnMut(&Value, &str, usize) -> Result<()>,
) -> Result<()> {
match value.as_array() {
Some(items) => {
for (index, item) in items.iter().enumerate() {
walk(item, &format!("{path}[{index}]"), depth + 1)?;
}
Ok(())
},
None => walk(value, path, depth + 1),
}
}
fn walk_input_object(
schema: &CompiledSchema,
subject: &str,
path: &str,
name: &str,
value: &Value,
depth: usize,
) -> Result<()> {
let (Some(input_type), Some(object)) = (schema.find_input_type(name), value.as_object()) else {
return Ok(());
};
for field in &input_type.fields {
let surface = schema.display_name(&field.name);
let value = object
.get(surface.as_str())
.or_else(|| (surface != field.name).then(|| object.get(&field.name)).flatten());
let Some(value) = value else {
continue;
};
let child = if path.is_empty() {
surface
} else {
format!("{path}.{surface}")
};
walk_type_ref(schema, subject, &child, &field.field_type, value, depth + 1)?;
}
Ok(())
}
fn check_membership(
schema: &CompiledSchema,
subject: &str,
path: &str,
enum_name: &str,
value: &Value,
) -> Result<()> {
let Some(def) = schema.find_enum(enum_name) else {
return Ok(());
};
if let Some(written) = value.as_str() {
if def.values.iter().any(|member| member.name == written) {
return Ok(());
}
}
let wrote = if value.is_string() {
"a name that is not one of its members".to_string()
} else {
format!("{} , which is not a name at all", json_shape(value))
};
let at = if path.is_empty() {
String::new()
} else {
format!(" at `{path}`")
};
Err(FraiseQLError::Validation {
message: format!(
"{subject}{at} has enum type `{enum_name}`, but the document wrote {wrote}. \
Valid members: {}",
member_list(def)
),
path: (!path.is_empty()).then(|| path.to_string()),
})
}
fn member_list(def: &EnumDefinition) -> String {
let total = def.values.len();
let shown: Vec<&str> =
def.values.iter().take(MAX_MEMBERS_LISTED).map(|v| v.name.as_str()).collect();
if total > shown.len() {
format!("{} (and {} more)", shown.join(", "), total - shown.len())
} else {
shown.join(", ")
}
}
fn operation_label(operation_name: Option<&str>) -> String {
operation_name.map_or_else(String::new, |name| format!(" in operation `{name}`"))
}
#[cfg(test)]
#[path = "argument_value_validation_tests.rs"]
mod argument_value_validation_tests;