use std::collections::HashMap;
use crate::core::{
Assignment, FieldSchema, FieldType, Filter, InsertQuery, ModelSchema, Op, SqlValue,
UpdateQuery, WhereExpr,
};
#[cfg(feature = "csrf")]
pub mod csrf;
#[derive(Debug, Default, thiserror::Error)]
pub struct FormErrors {
fields: HashMap<String, Vec<String>>,
non_field: Vec<String>,
}
impl FormErrors {
pub fn add(&mut self, field: impl Into<String>, msg: impl Into<String>) {
self.fields
.entry(field.into())
.or_default()
.push(msg.into());
}
pub fn add_non_field(&mut self, msg: impl Into<String>) {
self.non_field.push(msg.into());
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty() && self.non_field.is_empty()
}
pub fn fields(&self) -> &HashMap<String, Vec<String>> {
&self.fields
}
pub fn non_field(&self) -> &[String] {
&self.non_field
}
pub fn get(&self, field: &str) -> &[String] {
self.fields.get(field).map(Vec::as_slice).unwrap_or(&[])
}
pub fn first_field(&self) -> Option<&str> {
self.fields.keys().next().map(String::as_str)
}
}
impl std::fmt::Display for FormErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (field, msgs) in &self.fields {
for msg in msgs {
writeln!(f, "{field}: {msg}")?;
}
}
for msg in &self.non_field {
writeln!(f, "__all__: {msg}")?;
}
Ok(())
}
}
pub trait Form: Sized {
fn parse(data: &HashMap<String, String>) -> Result<Self, FormErrors>;
}
#[deprecated(since = "0.16.0", note = "use `Form` and `FormErrors` instead")]
pub trait FormStruct: Sized {
fn parse(form: &HashMap<String, String>) -> Result<Self, FormError>;
}
#[derive(Debug, thiserror::Error)]
pub enum FormError {
#[error("required field `{field}` was missing from the form")]
Missing { field: String },
#[error("field `{field}` has invalid {ty} value `{value}`: {detail}")]
Parse {
field: String,
ty: &'static str,
value: String,
detail: String,
},
#[error("PK field `{field}` of type {ty} is not supported in URL paths")]
UnsupportedPk { field: String, ty: &'static str },
}
impl From<FormError> for FormErrors {
fn from(e: FormError) -> Self {
let mut errors = FormErrors::default();
match &e {
FormError::Missing { field } | FormError::Parse { field, .. } => {
errors.add(field.clone(), e.to_string());
}
FormError::UnsupportedPk { field, .. } => {
errors.add(field.clone(), e.to_string());
}
}
errors
}
}
pub fn parse_pk_string(field: &FieldSchema, raw: &str) -> Result<SqlValue, FormError> {
let make_parse_err = |ty: &'static str, e: &dyn std::fmt::Display| FormError::Parse {
field: field.name.to_owned(),
ty,
value: raw.to_owned(),
detail: e.to_string(),
};
match field.ty {
FieldType::I16 => raw
.parse::<i16>()
.map(SqlValue::I16)
.map_err(|e| make_parse_err("i16", &e)),
FieldType::I32 => raw
.parse::<i32>()
.map(SqlValue::I32)
.map_err(|e| make_parse_err("i32", &e)),
FieldType::I64 => raw
.parse::<i64>()
.map(SqlValue::I64)
.map_err(|e| make_parse_err("i64", &e)),
FieldType::String => Ok(SqlValue::String(raw.to_owned())),
FieldType::Uuid => uuid::Uuid::parse_str(raw)
.map(SqlValue::Uuid)
.map_err(|e| make_parse_err("Uuid", &e)),
FieldType::Bool
| FieldType::F32
| FieldType::F64
| FieldType::DateTime
| FieldType::Date
| FieldType::Json => Err(FormError::UnsupportedPk {
field: field.name.to_owned(),
ty: field.ty.as_str(),
}),
}
}
pub fn parse_form_value(field: &FieldSchema, raw: Option<&str>) -> Result<SqlValue, FormError> {
let Some(raw) = raw else {
return Ok(match field.ty {
FieldType::Bool => SqlValue::Bool(false),
_ if field.nullable => SqlValue::Null,
_ => {
return Err(FormError::Missing {
field: field.name.to_owned(),
});
}
});
};
if field.nullable && raw.is_empty() {
return Ok(SqlValue::Null);
}
if matches!(field.ty, FieldType::String) && !field.nullable && raw.is_empty() {
return Err(FormError::Missing {
field: field.name.to_owned(),
});
}
let make_parse_err = |ty: &'static str, e: &dyn std::fmt::Display| FormError::Parse {
field: field.name.to_owned(),
ty,
value: raw.to_owned(),
detail: e.to_string(),
};
match field.ty {
FieldType::Bool => {
let v = !matches!(
raw.to_ascii_lowercase().as_str(),
"" | "false" | "0" | "off" | "no"
);
Ok(SqlValue::Bool(v))
}
FieldType::I16 => raw
.parse::<i16>()
.map(SqlValue::I16)
.map_err(|e| make_parse_err("i16", &e)),
FieldType::I32 => raw
.parse::<i32>()
.map(SqlValue::I32)
.map_err(|e| make_parse_err("i32", &e)),
FieldType::I64 => raw
.parse::<i64>()
.map(SqlValue::I64)
.map_err(|e| make_parse_err("i64", &e)),
FieldType::F32 => raw
.parse::<f32>()
.map(SqlValue::F32)
.map_err(|e| make_parse_err("f32", &e)),
FieldType::F64 => raw
.parse::<f64>()
.map(SqlValue::F64)
.map_err(|e| make_parse_err("f64", &e)),
FieldType::String => Ok(SqlValue::String(raw.to_owned())),
FieldType::Uuid => uuid::Uuid::parse_str(raw)
.map(SqlValue::Uuid)
.map_err(|e| make_parse_err("Uuid", &e)),
FieldType::Date => chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d")
.map(SqlValue::Date)
.map_err(|e| make_parse_err("Date", &e)),
FieldType::DateTime => {
if let Ok(d) = chrono::DateTime::parse_from_rfc3339(raw) {
return Ok(SqlValue::DateTime(d.with_timezone(&chrono::Utc)));
}
let ndt = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
.map_err(|e| make_parse_err("DateTime", &e))?;
Ok(SqlValue::DateTime(ndt.and_utc()))
}
FieldType::Json => {
if raw.is_empty() {
Ok(SqlValue::Json(serde_json::json!({})))
} else {
serde_json::from_str::<serde_json::Value>(raw)
.map(SqlValue::Json)
.map_err(|e| FormError::Parse {
field: field.name.to_owned(),
ty: "Json",
value: raw.to_owned(),
detail: e.to_string(),
})
}
}
}
}
pub fn collect_values(
model: &'static ModelSchema,
form: &HashMap<String, String>,
skip: &[&str],
) -> Result<Vec<(&'static str, SqlValue)>, FormError> {
let mut out = Vec::new();
for field in model.scalar_fields() {
if field.auto || skip.contains(&field.name) {
continue;
}
let raw = form.get(field.name).map(String::as_str);
let value = parse_form_value(field, raw)?;
out.push((field.column, value));
}
Ok(out)
}
#[derive(Debug, thiserror::Error)]
pub enum ModelFormError {
#[error("form validation failed:\n{0}")]
Validation(FormErrors),
#[error("database error: {0}")]
Database(#[from] crate::sql::ExecError),
}
pub struct ModelForm {
schema: &'static ModelSchema,
data: HashMap<String, String>,
pk_value: Option<SqlValue>,
include_fields: Option<Vec<String>>,
}
impl ModelForm {
pub fn new(schema: &'static ModelSchema, data: HashMap<String, String>) -> Self {
Self {
schema,
data,
pk_value: None,
include_fields: None,
}
}
pub fn for_update(
schema: &'static ModelSchema,
data: HashMap<String, String>,
pk: SqlValue,
) -> Self {
Self {
schema,
data,
pk_value: Some(pk),
include_fields: None,
}
}
pub fn fields(mut self, fields: &[&str]) -> Self {
self.include_fields = Some(fields.iter().map(|&s| s.to_owned()).collect());
self
}
fn should_include(&self, field: &FieldSchema) -> bool {
if field.primary_key || field.auto {
return false;
}
match &self.include_fields {
Some(list) => list.iter().any(|n| n == field.name),
None => true,
}
}
pub fn validate(&self) -> FormErrors {
let mut errors = FormErrors::default();
for field in self.schema.scalar_fields() {
if !self.should_include(field) {
continue;
}
let raw = self.data.get(field.name).map(String::as_str);
if let Err(e) = parse_form_value(field, raw) {
errors.add(field.name, e.to_string());
}
}
errors
}
pub fn is_valid(&self) -> bool {
self.validate().is_empty()
}
pub fn errors(&self) -> FormErrors {
self.validate()
}
pub async fn save(&self, pool: &crate::sql::sqlx::PgPool) -> Result<SqlValue, ModelFormError> {
let errors = self.validate();
if !errors.is_empty() {
return Err(ModelFormError::Validation(errors));
}
let pk_field = self.schema.primary_key().ok_or_else(|| {
ModelFormError::Database(crate::sql::ExecError::Driver(sqlx::Error::Protocol(
"model has no primary key".into(),
)))
})?;
if let Some(pk_val) = &self.pk_value {
let assignments: Vec<Assignment> = self
.schema
.scalar_fields()
.filter(|f| self.should_include(f))
.filter_map(|f| {
let raw = self.data.get(f.name).map(String::as_str);
parse_form_value(f, raw).ok().map(|v| Assignment {
column: f.column,
value: v,
})
})
.collect();
let query = UpdateQuery {
model: self.schema,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val.clone(),
}),
};
crate::sql::update(pool, &query).await?;
Ok(pk_val.clone())
} else {
let mut columns: Vec<&'static str> = Vec::new();
let mut values: Vec<SqlValue> = Vec::new();
for field in self.schema.scalar_fields() {
if !self.should_include(field) {
continue;
}
let raw = self.data.get(field.name).map(String::as_str);
if let Ok(v) = parse_form_value(field, raw) {
columns.push(field.column);
values.push(v);
}
}
let query = InsertQuery {
model: self.schema,
columns,
values,
returning: vec![pk_field.column],
on_conflict: None,
};
let row = crate::sql::insert_returning(pool, &query).await?;
use crate::sql::sqlx::Row as _;
let pk_val: SqlValue = match pk_field.ty {
FieldType::I64 => SqlValue::I64(row.try_get(pk_field.column).unwrap_or(0)),
FieldType::I32 => SqlValue::I32(row.try_get(pk_field.column).unwrap_or(0)),
FieldType::I16 => SqlValue::I16(row.try_get(pk_field.column).unwrap_or(0)),
FieldType::String => {
SqlValue::String(row.try_get(pk_field.column).unwrap_or_default())
}
_ => SqlValue::Null,
};
Ok(pk_val)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DynamicFieldType {
Text,
Textarea,
Integer,
Float,
Boolean,
Date,
Datetime,
Email,
Url,
Select,
MultiSelect,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct DynamicField {
pub name: String,
#[serde(default)]
pub label: String,
pub field_type: DynamicFieldType,
#[serde(default = "bool_true")]
pub required: bool,
pub max_length: Option<usize>,
pub min_length: Option<usize>,
pub min: Option<f64>,
pub max: Option<f64>,
#[serde(default)]
pub choices: Vec<(String, String)>,
#[serde(default)]
pub help_text: String,
}
fn bool_true() -> bool {
true
}
pub struct DynamicForm {
fields: Vec<DynamicField>,
data: Option<HashMap<String, String>>,
}
impl DynamicForm {
pub fn from_schema(fields: Vec<DynamicField>) -> Self {
Self { fields, data: None }
}
pub fn from_json(schema: serde_json::Value) -> Result<Self, serde_json::Error> {
let fields: Vec<DynamicField> = serde_json::from_value(schema)?;
Ok(Self::from_schema(fields))
}
pub fn bind(&mut self, data: HashMap<String, String>) {
self.data = Some(data);
}
pub fn fields(&self) -> &[DynamicField] {
&self.fields
}
pub fn validate(&self) -> FormErrors {
let mut errors = FormErrors::default();
let Some(data) = &self.data else {
return errors;
};
for field in &self.fields {
let raw = data.get(&field.name).map(String::as_str);
match raw {
None | Some("")
if field.required && field.field_type != DynamicFieldType::Boolean =>
{
errors.add(&field.name, "This field is required.");
continue;
}
_ => {}
}
let raw_str = raw.unwrap_or("");
match field.field_type {
DynamicFieldType::Integer => {
if !raw_str.is_empty() {
match raw_str.parse::<i64>() {
Ok(n) => {
if let Some(min) = field.min {
if (n as f64) < min {
errors.add(
&field.name,
format!("Ensure this value is ≥ {min}."),
);
}
}
if let Some(max) = field.max {
if (n as f64) > max {
errors.add(
&field.name,
format!("Ensure this value is ≤ {max}."),
);
}
}
}
Err(_) => errors.add(&field.name, "Enter a whole number."),
}
}
}
DynamicFieldType::Float => {
if !raw_str.is_empty() {
match raw_str.parse::<f64>() {
Ok(n) => {
if let Some(min) = field.min {
if n < min {
errors.add(
&field.name,
format!("Ensure this value is ≥ {min}."),
);
}
}
if let Some(max) = field.max {
if n > max {
errors.add(
&field.name,
format!("Ensure this value is ≤ {max}."),
);
}
}
}
Err(_) => errors.add(&field.name, "Enter a number."),
}
}
}
DynamicFieldType::Text
| DynamicFieldType::Textarea
| DynamicFieldType::Email
| DynamicFieldType::Url => {
if let Some(max) = field.max_length {
if raw_str.len() > max {
errors.add(
&field.name,
format!("Ensure this value has at most {max} characters."),
);
}
}
if let Some(min) = field.min_length {
if !raw_str.is_empty() && raw_str.len() < min {
errors.add(
&field.name,
format!("Ensure this value has at least {min} characters."),
);
}
}
if field.field_type == DynamicFieldType::Email && !raw_str.is_empty() {
if !raw_str.contains('@') {
errors.add(&field.name, "Enter a valid email address.");
}
}
}
DynamicFieldType::Select => {
if !raw_str.is_empty() && !field.choices.iter().any(|(v, _)| v == raw_str) {
errors.add(&field.name, "Select a valid choice.");
}
}
DynamicFieldType::MultiSelect => {
for part in raw_str.split(',').map(str::trim).filter(|s| !s.is_empty()) {
if !field.choices.iter().any(|(v, _)| v == part) {
errors.add(&field.name, format!("'{part}' is not a valid choice."));
}
}
}
DynamicFieldType::Date => {
if !raw_str.is_empty() {
if chrono::NaiveDate::parse_from_str(raw_str, "%Y-%m-%d").is_err() {
errors.add(&field.name, "Enter a valid date (YYYY-MM-DD).");
}
}
}
DynamicFieldType::Datetime => {
if !raw_str.is_empty() {
let ok = chrono::DateTime::parse_from_rfc3339(raw_str).is_ok()
|| chrono::NaiveDateTime::parse_from_str(raw_str, "%Y-%m-%dT%H:%M:%S")
.is_ok()
|| chrono::NaiveDateTime::parse_from_str(raw_str, "%Y-%m-%dT%H:%M")
.is_ok();
if !ok {
errors.add(&field.name, "Enter a valid date/time.");
}
}
}
DynamicFieldType::Boolean => {}
}
}
errors
}
pub fn is_valid(&self) -> bool {
self.data.is_some() && self.validate().is_empty()
}
pub fn errors(&self) -> FormErrors {
self.validate()
}
pub fn cleaned_data(&self) -> Result<HashMap<String, serde_json::Value>, FormErrors> {
let errors = self.validate();
if !errors.is_empty() {
return Err(errors);
}
let data = self.data.as_ref().map_or_else(HashMap::new, Clone::clone);
let mut out = HashMap::new();
for field in &self.fields {
let raw = data.get(&field.name).map(String::as_str).unwrap_or("");
let value = match field.field_type {
DynamicFieldType::Integer => {
if raw.is_empty() {
serde_json::Value::Null
} else {
let n = raw.parse::<i64>().unwrap_or(0);
serde_json::Value::Number(serde_json::Number::from(n))
}
}
DynamicFieldType::Float => {
if raw.is_empty() {
serde_json::Value::Null
} else {
serde_json::json!(raw.parse::<f64>().unwrap_or(0.0))
}
}
DynamicFieldType::Boolean => serde_json::Value::Bool(!matches!(
raw.to_ascii_lowercase().as_str(),
"" | "false" | "0" | "off" | "no"
)),
DynamicFieldType::MultiSelect => {
let parts: Vec<serde_json::Value> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| serde_json::Value::String(s.to_owned()))
.collect();
serde_json::Value::Array(parts)
}
_ => {
if raw.is_empty() && !field.required {
serde_json::Value::Null
} else {
serde_json::Value::String(raw.to_owned())
}
}
};
out.insert(field.name.clone(), value);
}
Ok(out)
}
}
#[derive(Debug)]
pub struct ModelFormFor<T: crate::core::Model> {
columns: Vec<&'static str>,
values: Vec<crate::core::SqlValue>,
_marker: std::marker::PhantomData<T>,
}
impl<T: crate::core::Model> ModelFormFor<T> {
pub fn from_json(value: &serde_json::Value) -> Result<Self, FormErrors> {
let obj = match value.as_object() {
Some(o) => o,
None => {
let mut errors = FormErrors::default();
errors.add_non_field("expected JSON object body");
return Err(errors);
}
};
let mut payload: HashMap<String, String> = HashMap::with_capacity(obj.len());
for (k, v) in obj {
match v {
serde_json::Value::Null => {}
serde_json::Value::String(s) => {
payload.insert(k.clone(), s.clone());
}
other => {
payload.insert(k.clone(), other.to_string());
}
}
}
Self::parse(&payload)
}
pub fn parse(payload: &HashMap<String, String>) -> Result<Self, FormErrors> {
let mut errors = FormErrors::default();
let mut columns = Vec::new();
let mut values = Vec::new();
for field in T::SCHEMA.scalar_fields() {
if field.auto {
continue;
}
let raw = payload.get(field.name).map(String::as_str);
match parse_form_value(field, raw) {
Ok(value) => {
if let Err(bound_err) =
crate::core::validate_value(T::SCHEMA.table, field, &value)
{
errors.add(field.name.to_owned(), bound_err.to_string());
} else {
columns.push(field.column);
values.push(value);
}
}
Err(e) => {
errors.add(field.name.to_owned(), e.to_string());
}
}
}
if errors.is_empty() {
Ok(Self {
columns,
values,
_marker: std::marker::PhantomData,
})
} else {
Err(errors)
}
}
#[must_use]
pub fn columns(&self) -> &[&'static str] {
&self.columns
}
#[must_use]
pub fn values(&self) -> &[crate::core::SqlValue] {
&self.values
}
pub async fn validate_unique_together(
&self,
pool: &crate::sql::sqlx::PgPool,
pk_value: Option<&crate::core::SqlValue>,
) -> Result<(), FormErrors> {
use crate::sql::sqlx;
let mut errors = FormErrors::default();
let pk_field = T::SCHEMA.primary_key();
for idx in T::SCHEMA.indexes {
if !idx.unique || idx.columns.len() < 2 {
continue;
}
let mut bound: Vec<(&'static str, &crate::core::SqlValue)> = Vec::new();
let mut all_present = true;
for col in idx.columns {
match self.columns.iter().position(|c| c == col) {
Some(i) => bound.push((
idx.columns.iter().find(|c| c == &col).copied().unwrap(),
&self.values[i],
)),
None => {
all_present = false;
break;
}
}
}
if !all_present {
continue;
}
let mut sql = format!(r#"SELECT 1 FROM "{}" WHERE "#, T::SCHEMA.table);
let mut sep = "";
for (i, (col, _)) in bound.iter().enumerate() {
sql.push_str(sep);
sep = " AND ";
sql.push_str(&format!(r#""{col}" = ${}"#, i + 1));
}
let extra_pk_idx = bound.len() + 1;
if let (Some(pk_field), Some(_pk_value)) = (pk_field, pk_value) {
sql.push_str(&format!(
r#" AND "{}" <> ${}"#,
pk_field.column, extra_pk_idx
));
}
sql.push_str(" LIMIT 1");
let mut q: sqlx::query::Query<'_, sqlx::Postgres, sqlx::postgres::PgArguments> =
sqlx::query(&sql);
for (_, v) in &bound {
q = bind_sql_value_inline(q, v);
}
if let (Some(_), Some(pk_value)) = (pk_field, pk_value) {
q = bind_sql_value_inline(q, pk_value);
}
match q.fetch_optional(pool).await {
Ok(Some(_)) => {
let label = idx.columns.join(", ");
let msg = format!("a row with the same ({label}) already exists");
for col in idx.columns {
errors.add(col.to_owned(), msg.clone());
}
}
Ok(None) => {}
Err(e) => errors.add_non_field(format!("unique-together pre-check failed: {e}")),
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[must_use]
pub fn into_insert_query(self) -> crate::core::InsertQuery {
crate::core::InsertQuery {
model: T::SCHEMA,
columns: self.columns,
values: self.values,
returning: Vec::new(),
on_conflict: None,
}
}
#[must_use]
pub fn into_update_query(
self,
pk_value: crate::core::SqlValue,
) -> Option<crate::core::UpdateQuery> {
let pk = T::SCHEMA.primary_key()?;
let assignments: Vec<crate::core::Assignment> = self
.columns
.into_iter()
.zip(self.values)
.map(|(column, value)| crate::core::Assignment { column, value })
.collect();
Some(crate::core::UpdateQuery {
model: T::SCHEMA,
set: assignments,
where_clause: crate::core::WhereExpr::Predicate(crate::core::Filter {
column: pk.column,
op: crate::core::Op::Eq,
value: pk_value,
}),
})
}
}
fn bind_sql_value_inline<'a>(
q: crate::sql::sqlx::query::Query<
'a,
crate::sql::sqlx::Postgres,
crate::sql::sqlx::postgres::PgArguments,
>,
v: &crate::core::SqlValue,
) -> crate::sql::sqlx::query::Query<
'a,
crate::sql::sqlx::Postgres,
crate::sql::sqlx::postgres::PgArguments,
> {
use crate::core::SqlValue;
match v {
SqlValue::Null => q.bind(None::<i64>),
SqlValue::I16(v) => q.bind(*v),
SqlValue::I32(v) => q.bind(*v),
SqlValue::I64(v) => q.bind(*v),
SqlValue::F32(v) => q.bind(*v),
SqlValue::F64(v) => q.bind(*v),
SqlValue::Bool(v) => q.bind(*v),
SqlValue::String(v) => q.bind(v.clone()),
SqlValue::DateTime(v) => q.bind(*v),
SqlValue::Date(v) => q.bind(*v),
SqlValue::Uuid(v) => q.bind(*v),
SqlValue::Json(v) => q.bind(v.clone()),
SqlValue::List(_) => panic!("validate_unique_together: List not supported in pre-check"),
}
}
#[cfg(test)]
mod model_form_tests {
use super::*;
use crate::sql::Auto;
#[derive(crate::Model, Debug)]
#[rustango(table = "mf_post")]
#[allow(dead_code)]
pub struct Post {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 50)]
pub title: String,
pub body: Option<String>,
}
#[test]
fn parse_skips_auto_pk_and_accepts_required_string() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "hi".into());
p.insert("body".into(), "".into());
let mf: ModelFormFor<Post> = ModelFormFor::<Post>::parse(&p).expect("valid");
assert_eq!(mf.columns(), &["title", "body"]);
}
#[test]
fn parse_collects_multiple_errors() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "x".repeat(100));
let err = ModelFormFor::<Post>::parse(&p).expect_err("should error");
assert!(!err.is_empty(), "errors should be non-empty");
}
#[test]
fn into_insert_query_targets_correct_model() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "hi".into());
let mf: ModelFormFor<Post> = ModelFormFor::<Post>::parse(&p).expect("valid");
let q = mf.into_insert_query();
assert_eq!(q.model.table, "mf_post");
assert!(q.columns.contains(&"title"));
}
#[test]
fn from_json_parses_object_body() {
let v = serde_json::json!({ "title": "from-json", "body": "x" });
let mf: ModelFormFor<Post> = ModelFormFor::<Post>::from_json(&v).expect("valid");
assert!(mf.columns().contains(&"title"));
assert!(mf.columns().contains(&"body"));
}
#[test]
fn from_json_handles_numeric_values() {
let v = serde_json::json!({ "title": 42 });
let mf: ModelFormFor<Post> = ModelFormFor::<Post>::from_json(&v).expect("valid");
match mf.values().iter().find(|_| true).unwrap() {
crate::core::SqlValue::String(s) => assert_eq!(s, "42"),
other => panic!("expected stringified value, got {other:?}"),
}
}
#[test]
fn from_json_rejects_non_object_root() {
let v = serde_json::json!(["title", "body"]);
let err = ModelFormFor::<Post>::from_json(&v).expect_err("array body should error");
assert!(!err.non_field().is_empty());
}
#[test]
fn from_json_treats_null_as_absent() {
let v = serde_json::json!({ "title": "ok", "body": null });
let mf: ModelFormFor<Post> = ModelFormFor::<Post>::from_json(&v).expect("valid");
let body_value = mf
.columns()
.iter()
.position(|c| *c == "body")
.and_then(|i| mf.values().get(i));
assert!(matches!(body_value, Some(crate::core::SqlValue::Null)));
}
#[test]
fn into_update_query_filters_on_pk() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "edited".into());
let mf: ModelFormFor<Post> = ModelFormFor::<Post>::parse(&p).expect("valid");
let q = mf
.into_update_query(crate::core::SqlValue::I64(42))
.expect("model has PK");
assert_eq!(q.set.len(), 2);
match &q.where_clause {
crate::core::WhereExpr::Predicate(f) => {
assert_eq!(f.column, "id");
assert_eq!(f.value, crate::core::SqlValue::I64(42));
}
_ => panic!("wrong where shape"),
}
}
}