use std::collections::HashMap;
use crate::core::{
Assignment, FieldSchema, FieldType, Filter, InsertQuery, ModelSchema, Op, SqlValue,
UpdateQuery, WhereExpr,
};
#[cfg(feature = "csrf")]
pub mod csrf;
pub mod formset;
pub mod validators;
#[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 merge(&mut self, mut other: FormErrors) {
for (field, msgs) in other.fields.drain() {
self.fields.entry(field).or_default().extend(msgs);
}
self.non_field.extend(other.non_field.drain(..));
}
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>;
}
#[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::Time
| FieldType::Json
| FieldType::Decimal
| FieldType::Binary
| FieldType::Array(_)
| FieldType::Range(_)
| FieldType::HStore
| FieldType::Vector(_)
| FieldType::Geometry(_) => Err(FormError::UnsupportedPk {
field: field.name.to_owned(),
ty: field.ty.as_str(),
}),
}
}
pub(crate) fn absent_is_missing(field: &FieldSchema) -> bool {
!field.nullable && !matches!(field.ty, FieldType::Bool)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Encoding {
Json,
Form,
}
pub(crate) fn absent_takes_default(field: &FieldSchema, encoding: Encoding) -> bool {
field.default.is_some()
&& !field.primary_key
&& (encoding == Encoding::Json || field.ty != FieldType::Bool)
}
pub fn parse_form_value(field: &FieldSchema, raw: Option<&str>) -> Result<SqlValue, FormError> {
let Some(raw) = raw else {
if absent_is_missing(field) {
return Err(FormError::Missing {
field: field.name.to_owned(),
});
}
return Ok(if field.nullable {
SqlValue::Null
} else {
SqlValue::Bool(false)
});
};
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 => match raw.to_ascii_lowercase().as_str() {
"true" | "1" | "on" | "yes" => Ok(SqlValue::Bool(true)),
"" | "false" | "0" | "off" | "no" => Ok(SqlValue::Bool(false)),
_ => Err(make_parse_err("bool", &"expected true or false")),
},
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(),
})
}
}
FieldType::Decimal => raw
.parse::<rust_decimal::Decimal>()
.map(SqlValue::Decimal)
.map_err(|e| make_parse_err("Decimal", &e)),
FieldType::Binary => {
if raw.len() % 2 != 0 || !raw.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(make_parse_err(
"Binary",
&"expected lowercase hex (even length)",
));
}
let bytes = raw
.as_bytes()
.chunks_exact(2)
.map(|c| {
let h = (c[0] as char).to_digit(16).unwrap_or(0) as u8;
let l = (c[1] as char).to_digit(16).unwrap_or(0) as u8;
(h << 4) | l
})
.collect::<Vec<u8>>();
Ok(SqlValue::Binary(bytes))
}
FieldType::Time => chrono::NaiveTime::parse_from_str(raw, "%H:%M:%S")
.or_else(|_| chrono::NaiveTime::parse_from_str(raw, "%H:%M"))
.map(SqlValue::Time)
.map_err(|e| make_parse_err("Time", &e)),
FieldType::Array(elem) => {
let parts: Vec<&str> = if raw.trim().is_empty() {
Vec::new()
} else {
raw.split(',').map(str::trim).collect()
};
match elem {
crate::core::ArrayElem::Text => Ok(SqlValue::Array(
parts
.into_iter()
.map(|s| SqlValue::String(s.to_owned()))
.collect(),
)),
crate::core::ArrayElem::Int => parts
.into_iter()
.map(|s| s.parse::<i32>().map(SqlValue::I32))
.collect::<Result<Vec<_>, _>>()
.map(SqlValue::Array)
.map_err(|e| make_parse_err("array<i32>", &e)),
crate::core::ArrayElem::BigInt => parts
.into_iter()
.map(|s| s.parse::<i64>().map(SqlValue::I64))
.collect::<Result<Vec<_>, _>>()
.map(SqlValue::Array)
.map_err(|e| make_parse_err("array<i64>", &e)),
}
}
FieldType::Range(_) => Ok(SqlValue::RangeLiteral(raw.to_owned())),
FieldType::HStore => {
let map: std::collections::BTreeMap<String, Option<String>> = serde_json::from_str(raw)
.map_err(|e| make_parse_err("hstore (JSON object)", &e))?;
Ok(SqlValue::HStore(map.into_iter().collect()))
}
FieldType::Vector(_) => {
let vec: Vec<f32> = serde_json::from_str(raw)
.map_err(|e| make_parse_err("vector (JSON array of numbers)", &e))?;
Ok(SqlValue::Vector(vec))
}
FieldType::Geometry(_) => {
let p: crate::sql::Point = serde_json::from_str(raw)
.map_err(|e| make_parse_err("geometry (JSON {x, y, srid?})", &e))?;
Ok(p.into())
}
}
}
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 || field.generated_as.is_some() || 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)
}
pub fn collect_insert_values(
model: &'static ModelSchema,
form: &HashMap<String, String>,
skip: &[&str],
) -> Result<Vec<(&'static str, SqlValue)>, FormError> {
let mut out = collect_values(model, form, skip)?;
let now = chrono::Utc::now();
for field in model.scalar_fields() {
if out.iter().any(|(c, _)| *c == field.column) {
continue;
}
if let Some(v) = insert_stamp(field, now) {
out.push((field.column, v));
}
}
Ok(out)
}
fn insert_stamp(field: &FieldSchema, now: chrono::DateTime<chrono::Utc>) -> Option<SqlValue> {
if field.is_auto_timestamp() {
Some(SqlValue::DateTime(now))
} else if field.is_rust_side_uuid() {
Some(SqlValue::Uuid(uuid::Uuid::now_v7()))
} else {
None
}
}
pub fn stamp_auto_timestamps(
model: &'static ModelSchema,
columns: &mut Vec<&'static str>,
values: &mut Vec<SqlValue>,
) {
let now = chrono::Utc::now();
for field in model.scalar_fields() {
if columns.contains(&field.column) {
continue;
}
if let Some(v) = insert_stamp(field, now) {
columns.push(field.column);
values.push(v);
}
}
}
pub(crate) fn stamp_auto_now(model: &'static ModelSchema, set: &mut Vec<(&'static str, SqlValue)>) {
let now = chrono::Utc::now();
for f in model.scalar_fields() {
if f.auto_now && !set.iter().any(|(c, _)| *c == f.column) {
set.push((f.column, SqlValue::DateTime(now)));
}
}
}
#[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>>,
exclude_fields: Vec<String>,
}
impl ModelForm {
pub fn new(schema: &'static ModelSchema, data: HashMap<String, String>) -> Self {
Self {
schema,
data,
pk_value: None,
include_fields: None,
exclude_fields: Vec::new(),
}
}
pub fn for_update(
schema: &'static ModelSchema,
data: HashMap<String, String>,
pk: SqlValue,
) -> Self {
Self {
schema,
data,
pk_value: Some(pk),
include_fields: None,
exclude_fields: Vec::new(),
}
}
pub fn fields(mut self, fields: &[&str]) -> Self {
self.include_fields = Some(fields.iter().map(|&s| s.to_owned()).collect());
self
}
pub fn exclude(mut self, fields: &[&str]) -> Self {
for f in fields {
self.exclude_fields.push((*f).to_owned());
}
self
}
fn should_include(&self, field: &FieldSchema) -> bool {
let kind = if self.pk_value.is_some() {
crate::core::WriteKind::Update
} else {
crate::core::WriteKind::Insert
};
if !field.accepts_input(kind) {
return false;
}
if self.exclude_fields.iter().any(|n| n == field.name) {
return false;
}
if kind == crate::core::WriteKind::Insert
&& absent_takes_default(field, Encoding::Form)
&& !self.data.contains_key(field.name)
{
return false;
}
match &self.include_fields {
Some(list) => list.iter().any(|n| n == field.name),
None => true,
}
}
#[cfg(test)]
pub(crate) fn included_field_names(&self) -> Vec<&'static str> {
self.schema
.scalar_fields()
.filter(|f| self.should_include(f))
.map(|f| f.name)
.collect()
}
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);
let checked = parse_form_value(field, raw)
.map_err(|e| e.to_string())
.and_then(|v| {
crate::core::validate_value(self.schema.name, field, &v)
.map_err(|e| e.to_string())
});
if let Err(e) = checked {
errors.add(field.name, e);
}
}
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::Pool) -> Result<SqlValue, ModelFormError> {
self.prepare_save()?.commit_pool(pool).await
}
pub fn prepare_save(&self) -> Result<PreparedSave, 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(),
)))
})?;
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);
}
}
Ok(PreparedSave {
schema: self.schema,
pk_field,
pk_value: self.pk_value.clone(),
columns,
values,
})
}
}
#[derive(Debug, Clone)]
pub struct PreparedSave {
schema: &'static ModelSchema,
pk_field: &'static FieldSchema,
pk_value: Option<SqlValue>,
columns: Vec<&'static str>,
values: Vec<SqlValue>,
}
impl PreparedSave {
pub fn set(&mut self, field: &str, value: impl Into<SqlValue>) -> &mut Self {
let Some(target_col) = self
.schema
.scalar_fields()
.find(|f| f.name == field)
.map(|f| f.column)
else {
return self;
};
if let Some(idx) = self.columns.iter().position(|c| *c == target_col) {
self.values[idx] = value.into();
} else {
self.columns.push(target_col);
self.values.push(value.into());
}
self
}
pub fn unset(&mut self, field: &str) -> &mut Self {
let Some(target_col) = self
.schema
.scalar_fields()
.find(|f| f.name == field)
.map(|f| f.column)
else {
return self;
};
if let Some(idx) = self.columns.iter().position(|c| *c == target_col) {
self.columns.remove(idx);
self.values.remove(idx);
}
self
}
#[must_use]
pub fn has(&self, field: &str) -> bool {
self.schema
.scalar_fields()
.find(|f| f.name == field)
.is_some_and(|f| self.columns.iter().any(|c| *c == f.column))
}
#[must_use]
pub fn is_insert(&self) -> bool {
self.pk_value.is_none()
}
pub async fn commit_pool(self, pool: &crate::sql::Pool) -> Result<SqlValue, ModelFormError> {
if let Some(pk_val) = self.pk_value {
let mut set: Vec<(&'static str, SqlValue)> =
self.columns.into_iter().zip(self.values).collect();
stamp_auto_now(self.schema, &mut set);
let assignments: Vec<Assignment> = set
.into_iter()
.map(|(column, val)| Assignment {
column,
value: val.into(),
})
.collect();
let query = UpdateQuery {
model: self.schema,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: self.pk_field.column,
op: Op::Eq,
value: pk_val.clone(),
}),
};
crate::audit::update(pool, &query).await?;
return Ok(pk_val);
}
let mut columns = self.columns;
let mut values = self.values;
stamp_auto_timestamps(self.schema, &mut columns, &mut values);
let query = InsertQuery {
model: self.schema,
columns,
values,
returning: vec![self.pk_field.column],
on_conflict: None,
};
Ok(crate::audit::insert(pool, &query, self.pk_field).await?)
}
}
#[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)]
#[serde(from = "DynamicFieldRepr")]
pub struct DynamicField {
pub name: String,
pub label: String,
pub field_type: DynamicFieldType,
pub required: bool,
pub max_length: Option<usize>,
pub min_length: Option<usize>,
pub min: Option<f64>,
pub max: Option<f64>,
pub choices: Vec<(String, String)>,
pub help_text: String,
}
#[derive(serde::Deserialize)]
struct DynamicFieldRepr {
name: String,
#[serde(default)]
label: String,
field_type: DynamicFieldType,
required: Option<bool>,
max_length: Option<usize>,
min_length: Option<usize>,
min: Option<f64>,
max: Option<f64>,
#[serde(default)]
choices: Vec<(String, String)>,
#[serde(default)]
help_text: String,
}
impl From<DynamicFieldRepr> for DynamicField {
fn from(r: DynamicFieldRepr) -> Self {
let required = r
.required
.unwrap_or(r.field_type != DynamicFieldType::Boolean);
Self {
name: r.name,
label: r.label,
field_type: r.field_type,
required,
max_length: r.max_length,
min_length: r.min_length,
min: r.min,
max: r.max,
choices: r.choices,
help_text: r.help_text,
}
}
}
pub struct DynamicForm {
fields: Vec<DynamicField>,
data: Option<HashMap<String, Vec<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>) {
let multi = |name: &str| {
self.fields
.iter()
.any(|f| f.name == name && f.field_type == DynamicFieldType::MultiSelect)
};
let data = data
.into_iter()
.map(|(k, v)| {
let values = if multi(&k) {
v.split(',').map(|p| p.trim().to_owned()).collect()
} else {
vec![v]
};
(k, values)
})
.collect();
self.data = Some(data);
}
pub fn bind_pairs(&mut self, pairs: impl IntoIterator<Item = (String, String)>) {
let mut data: HashMap<String, Vec<String>> = HashMap::new();
for (k, v) in pairs {
data.entry(k).or_default().push(v);
}
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 values = data.get(&field.name).map_or(&[][..], Vec::as_slice);
let raw_str = values.last().map_or("", String::as_str);
let missing = match field.field_type {
DynamicFieldType::Boolean => !is_truthy(raw_str),
DynamicFieldType::MultiSelect => values.iter().all(String::is_empty),
_ => raw_str.is_empty(),
};
if field.required && missing {
errors.add(&field.name, "This field is required.");
continue;
}
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().filter(|n| n.is_finite()) {
Some(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}."),
);
}
}
}
None => errors.add(&field.name, "Enter a number."),
}
}
}
DynamicFieldType::Text
| DynamicFieldType::Textarea
| DynamicFieldType::Email
| DynamicFieldType::Url => {
let len = raw_str.chars().count();
if let Some(max) = field.max_length {
if 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() && 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 values.iter().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 empty = HashMap::new();
let data = self.data.as_ref().unwrap_or(&empty);
let mut out = HashMap::new();
for field in &self.fields {
let values = data.get(&field.name).map_or(&[][..], Vec::as_slice);
let raw = values.last().map_or("", String::as_str);
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(is_truthy(raw)),
DynamicFieldType::MultiSelect => {
let parts: Vec<serde_json::Value> = values
.iter()
.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)
}
}
fn is_truthy(raw: &str) -> bool {
!matches!(
raw.to_ascii_lowercase().as_str(),
"" | "false" | "0" | "off" | "no"
)
}
#[cfg(test)]
mod dynamic_form_tests {
use super::*;
fn form(schema: serde_json::Value) -> DynamicForm {
DynamicForm::from_json(schema).unwrap()
}
#[test]
fn multi_select_keeps_every_repeated_value() {
let mut f = form(
serde_json::json!([{"name": "tags", "field_type": "multi_select",
"choices": [["a", "A"], ["b", "B"], ["x,y", "XY"]]}]),
);
f.bind_pairs([("tags", "a"), ("tags", "x,y")].map(|(k, v)| (k.into(), v.into())));
assert_eq!(
f.cleaned_data().unwrap()["tags"],
serde_json::json!(["a", "x,y"])
);
}
#[test]
fn text_length_counts_characters() {
let mut f = form(serde_json::json!([{"name": "t", "field_type": "text", "max_length": 3}]));
f.bind(HashMap::from([("t".into(), "ééé".into())]));
assert!(f.is_valid(), "{:?}", f.errors());
let mut f = form(serde_json::json!([{"name": "t", "field_type": "text", "min_length": 2}]));
f.bind(HashMap::from([("t".into(), "é".into())]));
assert!(!f.is_valid(), "min_length counted bytes");
}
#[test]
fn bind_splits_a_multi_select_on_commas() {
let mut f = form(
serde_json::json!([{"name": "tags", "field_type": "multi_select",
"choices": [["a", "A"], ["b", "B"]]}, {"name": "t", "field_type": "text"}]),
);
f.bind(HashMap::from([
("tags".into(), "a, b".into()),
("t".into(), "x,y".into()),
]));
let data = f.cleaned_data().unwrap();
assert_eq!(data["tags"], serde_json::json!(["a", "b"]));
assert_eq!(
data["t"],
serde_json::json!("x,y"),
"only multi-select splits"
);
}
#[test]
fn non_finite_floats_are_refused() {
let mut f = form(serde_json::json!([{"name": "n", "field_type": "float", "max": 5.0}]));
for bad in ["NaN", "inf", "-inf"] {
f.bind(HashMap::from([("n".into(), bad.into())]));
assert!(!f.is_valid(), "{bad} accepted");
}
}
#[test]
fn a_required_checkbox_must_be_ticked() {
let schema = serde_json::json!([{"name": "ok", "field_type": "boolean", "required": true}]);
let mut f = form(schema);
f.bind(HashMap::new());
assert!(!f.is_valid());
f.bind(HashMap::from([("ok".into(), "on".into())]));
assert!(f.is_valid());
}
#[test]
fn a_checkbox_is_optional_by_default() {
let mut f = form(serde_json::json!([
{"name": "ok", "field_type": "boolean"},
{"name": "t", "field_type": "text"},
]));
f.bind(HashMap::from([("t".into(), "x".into())]));
assert!(f.is_valid(), "{:?}", f.errors());
f.bind(HashMap::new());
assert!(
!f.errors().get("t").is_empty(),
"text stays required by default"
);
}
}
#[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::Pool,
pk_value: Option<&crate::core::SqlValue>,
) -> Result<(), FormErrors> {
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 || idx.where_clause.is_some() {
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].clone(),
)),
None => {
all_present = false;
break;
}
}
}
let has_null = bound
.iter()
.any(|(_, v)| matches!(v, crate::core::SqlValue::Null));
if !all_present || has_null {
continue;
}
let mut predicates: Vec<crate::core::WhereExpr> = bound
.into_iter()
.map(|(col, val)| {
crate::core::WhereExpr::Predicate(crate::core::Filter::new(
col,
crate::core::Op::Eq,
val,
))
})
.collect();
if let (Some(pk_field), Some(pk_v)) = (pk_field, pk_value) {
predicates.push(crate::core::WhereExpr::Predicate(crate::core::Filter::new(
pk_field.column,
crate::core::Op::Ne,
pk_v.clone(),
)));
}
let select = crate::core::SelectQuery::new(T::SCHEMA)
.where_clause(crate::core::WhereExpr::And(predicates));
let hits =
crate::sql::count_rows_pool(pool, &crate::core::CountQuery::exists(select)).await;
match hits.map(|n| n > 0) {
Ok(true) => {
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(false) => {}
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 mut set: Vec<(&'static str, SqlValue)> =
self.columns.into_iter().zip(self.values).collect();
stamp_auto_now(T::SCHEMA, &mut set);
let assignments: Vec<crate::core::Assignment> = set
.into_iter()
.map(|(column, value)| crate::core::Assignment {
column,
value: value.into(),
})
.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,
}),
})
}
}
#[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)));
}
#[derive(crate::Model, Debug)]
#[rustango(table = "mf_flags")]
#[allow(dead_code)]
pub struct Flags {
#[rustango(primary_key)]
pub id: Auto<i64>,
pub on: bool,
pub maybe: Option<bool>,
}
#[derive(crate::Model, Debug)]
#[rustango(table = "mf_stamped")]
#[allow(dead_code)]
pub struct Stamped {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 50)]
pub title: String,
#[rustango(auto_now_add)]
pub created_at: Auto<chrono::DateTime<chrono::Utc>>,
#[rustango(auto_now)]
pub updated_at: Auto<chrono::DateTime<chrono::Utc>>,
}
#[test]
fn into_update_query_restamps_only_auto_now() {
let p = HashMap::from([("title".to_owned(), "t".to_owned())]);
let q = ModelFormFor::<Stamped>::parse(&p)
.expect("valid")
.into_update_query(crate::core::SqlValue::I64(1))
.expect("pk");
let cols: Vec<&str> = q.set.iter().map(|a| a.column).collect();
assert_eq!(cols, ["title", "updated_at"]);
}
#[test]
fn bool_parse_is_strict() {
let on = <Flags as crate::core::Model>::SCHEMA.field("on").unwrap();
for (raw, want) in [
("on", true),
("Yes", true),
("1", true),
("TRUE", true),
("false", false),
("0", false),
("off", false),
("NO", false),
("", false),
] {
assert_eq!(
parse_form_value(on, Some(raw)).unwrap(),
crate::core::SqlValue::Bool(want),
"{raw:?}"
);
}
assert!(parse_form_value(on, Some("nope")).is_err());
}
#[test]
fn absent_nullable_bool_is_null_not_false() {
let mf =
ModelFormFor::<Flags>::from_json(&serde_json::json!({ "maybe": null })).expect("valid");
let get = |col: &str| {
let i = mf.columns().iter().position(|c| *c == col).unwrap();
mf.values()[i].clone()
};
assert_eq!(get("on"), crate::core::SqlValue::Bool(false));
assert_eq!(get("maybe"), crate::core::SqlValue::Null);
}
#[test]
fn parse_absent_nullable_bool_is_null() {
let mf = ModelFormFor::<Flags>::parse(&HashMap::new()).expect("valid");
let get = |col: &str| {
let i = mf.columns().iter().position(|c| *c == col).unwrap();
mf.values()[i].clone()
};
assert_eq!(get("on"), crate::core::SqlValue::Bool(false));
assert_eq!(get("maybe"), 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"),
}
}
fn post_schema() -> &'static crate::core::ModelSchema {
<Post as crate::core::Model>::SCHEMA
}
#[test]
fn modelform_default_includes_all_non_pk_non_auto_fields() {
let form = ModelForm::new(post_schema(), HashMap::new());
let included = form.included_field_names();
assert_eq!(included, vec!["title", "body"]);
}
#[derive(crate::Model, Debug)]
#[rustango(table = "mf_tag")]
#[allow(dead_code)]
pub struct Tag {
#[rustango(primary_key, max_length = 32)]
pub slug: String,
#[rustango(max_length = 64)]
pub name: String,
}
#[test]
fn modelform_keeps_a_natural_pk_on_insert_only() {
let schema = <Tag as crate::core::Model>::SCHEMA;
let insert = ModelForm::new(schema, HashMap::new());
assert_eq!(insert.included_field_names(), vec!["slug", "name"]);
let update = ModelForm::for_update(schema, HashMap::new(), SqlValue::String("go".into()));
assert_eq!(update.included_field_names(), vec!["name"]);
}
#[test]
fn modelform_fields_restricts_to_named_set() {
let form = ModelForm::new(post_schema(), HashMap::new()).fields(&["title"]);
assert_eq!(form.included_field_names(), vec!["title"]);
}
#[test]
fn modelform_exclude_drops_named_fields() {
let form = ModelForm::new(post_schema(), HashMap::new()).exclude(&["body"]);
assert_eq!(form.included_field_names(), vec!["title"]);
}
#[test]
fn modelform_exclude_and_fields_compose() {
let form = ModelForm::new(post_schema(), HashMap::new())
.fields(&["title", "body"])
.exclude(&["body"]);
assert_eq!(form.included_field_names(), vec!["title"]);
}
#[test]
fn modelform_exclude_cannot_re_enable_pk_or_auto() {
let form = ModelForm::new(post_schema(), HashMap::new()).exclude(&["id"]);
assert_eq!(form.included_field_names(), vec!["title", "body"]);
}
#[test]
fn modelform_exclude_unknown_field_is_a_no_op() {
let form = ModelForm::new(post_schema(), HashMap::new()).exclude(&["nope"]);
assert_eq!(form.included_field_names(), vec!["title", "body"]);
}
#[test]
fn prepare_save_returns_validation_error_when_form_invalid() {
let p: HashMap<String, String> = HashMap::new();
let form = ModelForm::new(post_schema(), p);
let err = form.prepare_save().expect_err("should fail validation");
assert!(matches!(err, ModelFormError::Validation(_)));
}
#[test]
fn prepare_save_walks_only_form_supplied_fields_on_insert() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "ok".into());
let prep = ModelForm::new(post_schema(), p)
.prepare_save()
.expect("valid");
assert!(prep.is_insert(), "no pk_value supplied → INSERT");
assert!(prep.has("title"));
assert!(prep.has("body"));
}
#[test]
fn prepared_save_set_adds_missing_field() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "ok".into());
let form = ModelForm::new(post_schema(), p).exclude(&["body"]);
let mut prep = form.prepare_save().expect("valid");
assert!(!prep.has("body"), "body was excluded by the form");
prep.set("body", SqlValue::String("late binding".into()));
assert!(
prep.has("body"),
"set() should have added body to the write set"
);
}
#[test]
fn prepared_save_set_overrides_existing_field() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "from-form".into());
let mut prep = ModelForm::new(post_schema(), p)
.prepare_save()
.expect("valid");
prep.set("title", SqlValue::String("override".into()));
let title_col = post_schema()
.scalar_fields()
.find(|f| f.name == "title")
.unwrap()
.column;
assert_eq!(
prep.columns.iter().filter(|c| **c == title_col).count(),
1,
"title should appear exactly once"
);
}
#[test]
fn prepared_save_unset_drops_field_from_write_set() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "ok".into());
let mut prep = ModelForm::new(post_schema(), p)
.prepare_save()
.expect("valid");
assert!(prep.has("body"));
prep.unset("body");
assert!(!prep.has("body"), "unset() should drop body");
}
#[test]
fn prepared_save_set_unknown_field_is_a_noop() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "ok".into());
let mut prep = ModelForm::new(post_schema(), p)
.prepare_save()
.expect("valid");
prep.set("nonexistent", SqlValue::I64(0));
assert!(
prep.columns.iter().all(|c| *c != "nonexistent"),
"unknown field name should not pollute the write set"
);
}
#[test]
fn prepare_save_carries_pk_for_update_path() {
let mut p: HashMap<String, String> = HashMap::new();
p.insert("title".into(), "edited".into());
let prep = ModelForm::for_update(post_schema(), p, SqlValue::I64(42))
.prepare_save()
.expect("valid");
assert!(!prep.is_insert(), "pk_value was supplied → UPDATE");
}
}