use serde_json::{Map, Value};
use crate::migrate::{Column, ModelMeta};
use crate::orm::model::SqlType;
use crate::orm::write::WriteError;
pub async fn validate_on_create(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut errors = validate_required_create(meta, body);
errors.extend(validate_choices(meta, body));
errors.extend(validate_m2m_relations(meta, body).await);
let mut fk_errors = validate_fk_references(meta, body).await;
let fk_fields: std::collections::HashSet<String> = fk_errors
.iter()
.filter_map(|e| match e {
WriteError::ForeignKeyNotFound { field, .. } => Some(field.clone()),
_ => None,
})
.collect();
errors.retain(|e| match e {
WriteError::RequiredFieldMissing { field } => !fk_fields.contains(field),
WriteError::BlankNotAllowed { field } => !fk_fields.contains(field),
_ => true,
});
errors.append(&mut fk_errors);
errors
}
pub async fn validate_on_create_in_tx(
meta: &ModelMeta,
body: &Map<String, Value>,
tx: &mut crate::db::Transaction,
) -> Vec<WriteError> {
let mut errors = validate_required_create(meta, body);
errors.extend(validate_choices(meta, body));
errors.extend(validate_m2m_relations(meta, body).await);
let mut fk_errors = validate_fk_references_in_tx(meta, body, tx).await;
let fk_fields: std::collections::HashSet<String> = fk_errors
.iter()
.filter_map(|e| match e {
WriteError::ForeignKeyNotFound { field, .. } => Some(field.clone()),
_ => None,
})
.collect();
errors.retain(|e| match e {
WriteError::RequiredFieldMissing { field } => !fk_fields.contains(field),
WriteError::BlankNotAllowed { field } => !fk_fields.contains(field),
_ => true,
});
errors.append(&mut fk_errors);
errors
}
pub async fn validate_on_typed_create(
meta: &ModelMeta,
body: &Map<String, Value>,
) -> Vec<WriteError> {
let mut errors = Vec::new();
errors.extend(validate_choices(meta, body));
errors.extend(validate_m2m_relations(meta, body).await);
let mut fk_errors = validate_fk_references(meta, body).await;
let fk_fields: std::collections::HashSet<String> = fk_errors
.iter()
.filter_map(|e| match e {
WriteError::ForeignKeyNotFound { field, .. } => Some(field.clone()),
_ => None,
})
.collect();
errors.retain(|e| match e {
WriteError::Validator { field, .. } => !fk_fields.contains(field),
_ => true,
});
errors.append(&mut fk_errors);
errors
}
pub async fn validate_on_update(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut errors = validate_required_update(meta, body);
errors.extend(validate_choices(meta, body));
errors.extend(validate_m2m_relations(meta, body).await);
let mut fk_errors = validate_fk_references(meta, body).await;
let fk_fields: std::collections::HashSet<String> = fk_errors
.iter()
.filter_map(|e| match e {
WriteError::ForeignKeyNotFound { field, .. } => Some(field.clone()),
_ => None,
})
.collect();
errors.retain(|e| match e {
WriteError::RequiredFieldMissing { field } | WriteError::BlankNotAllowed { field } => {
!fk_fields.contains(field)
}
_ => true,
});
errors.append(&mut fk_errors);
errors
}
pub async fn validate_on_update_in_tx(
meta: &ModelMeta,
body: &Map<String, Value>,
tx: &mut crate::db::Transaction,
) -> Vec<WriteError> {
let mut errors = validate_required_update(meta, body);
errors.extend(validate_choices(meta, body));
errors.extend(validate_m2m_relations(meta, body).await);
let mut fk_errors = validate_fk_references_in_tx(meta, body, tx).await;
let fk_fields: std::collections::HashSet<String> = fk_errors
.iter()
.filter_map(|e| match e {
WriteError::ForeignKeyNotFound { field, .. } => Some(field.clone()),
_ => None,
})
.collect();
errors.retain(|e| match e {
WriteError::RequiredFieldMissing { field } | WriteError::BlankNotAllowed { field } => {
!fk_fields.contains(field)
}
_ => true,
});
errors.append(&mut fk_errors);
errors
}
pub fn classify_sql_error(e: &sqlx::Error, body: &Map<String, Value>) -> Option<WriteError> {
let db_err = e.as_database_error()?;
let sql_code = db_err.code()?;
let sql_code = sql_code.as_ref();
let message = db_err.message();
let pg_column = db_err
.constraint()
.and_then(parse_pg_column_from_constraint);
match sql_code {
"787" => Some(WriteError::ForeignKeyViolation { field: None }),
"2067" => {
let cols = sqlite_columns_from_message(message, "UNIQUE constraint failed:");
if cols.len() == 1 {
let col = cols.into_iter().next().unwrap();
let value = body.get(&col).cloned();
Some(WriteError::UniqueViolation {
field: Some(col),
value,
})
} else if !cols.is_empty() {
Some(WriteError::UniqueViolation {
field: Some(cols.into_iter().next().unwrap()),
value: None,
})
} else {
Some(WriteError::UniqueViolation {
field: None,
value: None,
})
}
}
"1299" => {
let cols = sqlite_columns_from_message(message, "NOT NULL constraint failed:");
Some(WriteError::NotNullViolation {
field: cols.into_iter().next(),
})
}
"275" => Some(WriteError::CheckViolation { constraint: None }),
"23503" => Some(WriteError::ForeignKeyViolation { field: pg_column }),
"23505" => {
let value = pg_column.as_ref().and_then(|c| body.get(c)).cloned();
Some(WriteError::UniqueViolation {
field: pg_column,
value,
})
}
"23502" => Some(WriteError::NotNullViolation { field: pg_column }),
"23514" => Some(WriteError::CheckViolation {
constraint: db_err.constraint().map(String::from),
}),
_ => None,
}
}
fn validate_required_create(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut out = Vec::new();
for col in &meta.fields {
if !column_is_required(col) {
continue;
}
match body.get(&col.name) {
None | Some(Value::Null) => {
out.push(WriteError::RequiredFieldMissing {
field: col.name.clone(),
});
}
Some(Value::String(s)) if value_is_blank_for_type(s, col.ty) => {
if matches!(col.ty, SqlType::Text) {
out.push(WriteError::BlankNotAllowed {
field: col.name.clone(),
});
} else {
out.push(WriteError::RequiredFieldMissing {
field: col.name.clone(),
});
}
}
_ => {}
}
}
out
}
fn validate_required_update(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut out = Vec::new();
for col in &meta.fields {
if !column_is_required(col) {
continue;
}
let Some(value) = body.get(&col.name) else {
continue;
};
match value {
Value::Null => {
out.push(WriteError::RequiredFieldMissing {
field: col.name.clone(),
});
}
Value::String(s) if value_is_blank_for_type(s, col.ty) => {
if matches!(col.ty, SqlType::Text) {
out.push(WriteError::BlankNotAllowed {
field: col.name.clone(),
});
} else {
out.push(WriteError::RequiredFieldMissing {
field: col.name.clone(),
});
}
}
_ => {}
}
}
out
}
fn normalize_number_repr(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
return i.to_string();
}
if let Some(u) = n.as_u64() {
return u.to_string();
}
if let Some(f) = n.as_f64() {
if f.is_finite() && f.fract() == 0.0 && f.abs() < i64::MAX as f64 {
return (f as i64).to_string();
}
return f.to_string();
}
n.to_string()
}
fn validate_choices(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut out = Vec::new();
for col in &meta.fields {
if col.choices.is_empty() {
continue;
}
let Some(value) = body.get(&col.name) else {
continue;
};
if value.is_null() {
continue;
}
let value_repr = match value {
Value::String(s) if s.is_empty() => continue,
Value::String(s) => s.clone(),
Value::Number(n) => normalize_number_repr(n),
Value::Bool(b) => b.to_string(),
_ => continue, };
if col.choices.iter().any(|c| c == &value_repr) {
continue;
}
out.push(WriteError::Validator {
field: col.name.clone(),
message: format!(
"'{value_repr}' is not a valid choice. Allowed: {}.",
col.choices.join(", "),
),
});
}
out
}
async fn validate_m2m_relations(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut out = Vec::new();
for rel in &meta.m2m_relations {
let Some(value) = body.get(&rel.field_name) else {
continue;
};
if value.is_null() {
continue;
}
let Some(items) = value.as_array() else {
out.push(WriteError::Validator {
field: rel.field_name.clone(),
message: format!(
"Expected an array of `{}` ids, got {}.",
rel.target_name,
json_kind(value),
),
});
continue;
};
let to_check: Vec<&Value> = items.iter().filter(|v| !v.is_null()).collect();
if to_check.is_empty() {
continue;
}
let Some(target_meta) = model_meta_by_table(&rel.target_table) else {
continue;
};
let mut missing: Vec<Value> = Vec::new();
for item in to_check {
match check_fk_row_exists(&target_meta, item).await {
Ok(true) => {}
Ok(false) => missing.push(item.clone()),
Err(e) => {
out.push(e);
missing.clear();
break;
}
}
}
if !missing.is_empty() {
let listed = missing
.iter()
.map(repr_json_value_local)
.collect::<Vec<_>>()
.join(", ");
out.push(WriteError::Validator {
field: rel.field_name.clone(),
message: format!(
"Some referenced `{}` rows do not exist: {listed}.",
rel.target_name,
),
});
}
}
out
}
fn json_kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn repr_json_value_local(v: &Value) -> String {
match v {
Value::String(s) => format!("'{s}'"),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
_ => serde_json::to_string(v).unwrap_or_else(|_| "(?)".to_string()),
}
}
async fn validate_fk_references(meta: &ModelMeta, body: &Map<String, Value>) -> Vec<WriteError> {
let mut out = Vec::new();
for col in &meta.fields {
let Some(target_table) = col.fk_target.as_deref() else {
continue;
};
let Some(value) = body.get(&col.name) else {
continue;
};
if value.is_null() {
continue;
}
let Some(target_meta) = model_meta_by_table(target_table) else {
continue;
};
match check_fk_row_exists(&target_meta, value).await {
Ok(true) => {}
Ok(false) => {
out.push(WriteError::ForeignKeyNotFound {
field: col.name.clone(),
target_table: target_table.to_string(),
value: value.clone(),
});
}
Err(e) => out.push(e),
}
}
out
}
async fn validate_fk_references_in_tx(
meta: &ModelMeta,
body: &Map<String, Value>,
tx: &mut crate::db::Transaction,
) -> Vec<WriteError> {
let mut out = Vec::new();
for col in &meta.fields {
let Some(target_table) = col.fk_target.as_deref() else {
continue;
};
let Some(value) = body.get(&col.name) else {
continue;
};
if value.is_null() {
continue;
}
let Some(target_meta) = model_meta_by_table(target_table) else {
continue;
};
match check_fk_row_exists_in_tx(&target_meta, value, tx).await {
Ok(true) => {}
Ok(false) => {
out.push(WriteError::ForeignKeyNotFound {
field: col.name.clone(),
target_table: target_table.to_string(),
value: value.clone(),
});
}
Err(e) => out.push(e),
}
}
out
}
fn column_is_required(col: &Column) -> bool {
!col.primary_key
&& !col.noform
&& !col.nullable
&& col.default.is_empty()
&& !col.auto_now
&& !col.auto_now_add
}
fn value_is_blank_for_type(s: &str, ty: SqlType) -> bool {
if s.is_empty() {
return true;
}
matches!(
ty,
SqlType::SmallInt
| SqlType::Integer
| SqlType::BigInt
| SqlType::Real
| SqlType::Double
| SqlType::Boolean
| SqlType::Date
| SqlType::Time
| SqlType::Timestamptz
| SqlType::Uuid
| SqlType::ForeignKey,
) && s.trim().is_empty()
}
fn model_meta_by_table(table: &str) -> Option<ModelMeta> {
for plugin in crate::migrate::registered_plugins() {
for meta in crate::migrate::models_for_plugin(&plugin) {
if meta.table == table {
return Some(meta);
}
}
}
None
}
async fn check_fk_row_exists(target: &ModelMeta, value: &Value) -> Result<bool, WriteError> {
let Some(pk) = target.fields.iter().find(|c| c.primary_key) else {
return Ok(true);
};
let pk_repr = match value {
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
_ => return Ok(true),
};
let count = crate::orm::dynamic::DynQuerySet::for_meta(target)
.filter_eq_string(&pk.name, &pk_repr)
.count()
.await
.map_err(|e| match e {
crate::orm::dynamic::DynError::Write(e) => e,
crate::orm::dynamic::DynError::Sqlx(e) => WriteError::Sqlx(e),
})?;
Ok(count > 0)
}
async fn check_fk_row_exists_in_tx(
target: &ModelMeta,
value: &Value,
tx: &mut crate::db::Transaction,
) -> Result<bool, WriteError> {
use sea_query::{Alias, Expr, Func, PostgresQueryBuilder, Query, SqliteQueryBuilder};
use sea_query_binder::SqlxBinder;
let Some(pk) = target.fields.iter().find(|c| c.primary_key) else {
return Ok(true);
};
let pk_value = match crate::orm::write::json_to_sea_value(pk.ty, value, false, &pk.name, None) {
Ok(v) => v,
Err(_) => return Ok(false),
};
let mut q = Query::select();
q.from(crate::db::router::schema_qualified_table(&target.table))
.expr(Func::count(Expr::col(Alias::new(&pk.name))))
.and_where(Expr::col(Alias::new(&pk.name)).eq(pk_value));
match tx.backend_name() {
"sqlite" => {
let Some(inner) = tx.as_sqlite_mut() else {
return Ok(true);
};
let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
match sqlx::query_scalar_with::<_, i64, _>(&sql, values)
.fetch_one(&mut **inner)
.await
{
Ok(n) => Ok(n > 0),
Err(e) => Err(WriteError::Sqlx(e)),
}
}
_ => {
let Some(inner) = tx.as_pg_mut() else {
return Ok(true);
};
let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
match sqlx::query_scalar_with::<_, i64, _>(&sql, values)
.fetch_one(&mut **inner)
.await
{
Ok(n) => Ok(n > 0),
Err(e) => Err(WriteError::Sqlx(e)),
}
}
}
}
fn sqlite_columns_from_message(message: &str, prefix: &str) -> Vec<String> {
let trimmed_prefix = prefix.trim();
let Some(suffix) = message
.strip_prefix(trimmed_prefix)
.or_else(|| message.strip_prefix(prefix))
else {
return Vec::new();
};
suffix
.split(',')
.map(str::trim)
.filter_map(|seg| seg.split('.').nth(1))
.map(str::to_string)
.collect()
}
fn parse_pg_column_from_constraint(constraint: &str) -> Option<String> {
for suffix in ["_fkey", "_key", "_check"] {
if let Some(rest) = constraint.strip_suffix(suffix) {
return rest.split_once('_').map(|(_, col)| col.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::Column;
fn col(name: &str, choices: &[&str]) -> Column {
Column {
name: name.to_string(),
ty: SqlType::Text,
primary_key: false,
nullable: false,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
fk_target: None,
on_delete: crate::orm::model::FkAction::NoAction,
on_update: crate::orm::model::FkAction::NoAction,
unique: false,
default: String::new(),
choices: choices.iter().map(|s| s.to_string()).collect(),
choice_labels: Vec::new(),
is_multichoice: false,
min: None,
max: None,
text_format: None,
slug_from: None,
auto_now: false,
auto_now_add: false,
help: String::new(),
example: String::new(),
widget: None,
index: false,
supported_backends: Vec::new(),
}
}
fn meta_with(cols: Vec<Column>) -> ModelMeta {
ModelMeta {
name: "Test".into(),
table: "test".into(),
fields: cols,
display: "Test".into(),
icon: "database".into(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: Vec::new(),
soft_delete: false,
app_label: "app".into(),
}
}
#[test]
fn choices_validator_catches_typo_against_allowed_set() {
let meta = meta_with(vec![col("currency", &["usd", "eur", "gbp"])]);
let mut body = serde_json::Map::new();
body.insert("currency".into(), serde_json::Value::String("usdd".into()));
let errors = validate_choices(&meta, &body);
assert_eq!(errors.len(), 1, "expected one error, got {errors:?}");
match &errors[0] {
WriteError::Validator { field, message } => {
assert_eq!(field, "currency");
assert!(
message.contains("'usdd'"),
"message should name the offending value; got {message:?}",
);
assert!(
message.contains("usd, eur, gbp"),
"message should list the allowed set; got {message:?}",
);
}
other => panic!("expected Validator, got {other:?}"),
}
}
#[test]
fn choices_validator_accepts_a_known_value() {
let meta = meta_with(vec![col("status", &["draft", "active", "archived"])]);
let mut body = serde_json::Map::new();
body.insert("status".into(), serde_json::Value::String("active".into()));
assert!(validate_choices(&meta, &body).is_empty());
}
#[test]
fn choices_validator_skips_blank_and_missing() {
let meta = meta_with(vec![col("status", &["draft", "active"])]);
assert!(validate_choices(&meta, &serde_json::Map::new()).is_empty());
let mut body = serde_json::Map::new();
body.insert("status".into(), serde_json::Value::Null);
assert!(validate_choices(&meta, &body).is_empty());
let mut body = serde_json::Map::new();
body.insert("status".into(), serde_json::Value::String(String::new()));
assert!(validate_choices(&meta, &body).is_empty());
}
#[test]
fn choices_validator_accepts_integer_valued_float() {
let meta = meta_with(vec![col("level", &["1", "2", "3"])]);
let mut body = serde_json::Map::new();
body.insert("level".into(), serde_json::json!(2));
assert!(
validate_choices(&meta, &body).is_empty(),
"integer 2 should match choice \"2\"",
);
let mut body = serde_json::Map::new();
body.insert("level".into(), serde_json::json!(2.0));
assert!(
validate_choices(&meta, &body).is_empty(),
"float 2.0 should normalise to \"2\" and match",
);
}
#[test]
fn choices_validator_rejects_fractional_value() {
let meta = meta_with(vec![col("level", &["1", "2", "3"])]);
let mut body = serde_json::Map::new();
body.insert("level".into(), serde_json::json!(2.5));
let errors = validate_choices(&meta, &body);
assert_eq!(errors.len(), 1, "2.5 is not a valid choice; got {errors:?}");
}
#[test]
fn choices_validator_skips_columns_without_choices() {
let meta = meta_with(vec![col("name", &[])]);
let mut body = serde_json::Map::new();
body.insert("name".into(), serde_json::Value::String("anything".into()));
assert!(validate_choices(&meta, &body).is_empty());
}
fn meta_with_m2m(field_name: &str, target_table: &str, target_name: &str) -> ModelMeta {
let mut meta = meta_with(vec![]);
meta.m2m_relations.push(crate::migrate::M2MRelation {
field_name: field_name.to_string(),
target_table: target_table.to_string(),
target_name: target_name.to_string(),
});
meta
}
#[tokio::test]
async fn m2m_rejects_a_scalar_where_an_array_was_expected() {
let meta = meta_with_m2m("tags", "tag", "Tag");
let mut body = serde_json::Map::new();
body.insert("tags".into(), serde_json::Value::Number(1.into()));
let errors = validate_m2m_relations(&meta, &body).await;
assert_eq!(errors.len(), 1, "expected one shape error, got {errors:?}");
match &errors[0] {
WriteError::Validator { field, message } => {
assert_eq!(field, "tags");
assert!(
message.contains("array")
&& message.contains("Tag")
&& message.contains("number"),
"message should name the expected shape, the target, and \
the kind of the bad value; got {message:?}",
);
}
other => panic!("expected Validator, got {other:?}"),
}
}
#[tokio::test]
async fn m2m_accepts_an_empty_array() {
let meta = meta_with_m2m("tags", "tag", "Tag");
let mut body = serde_json::Map::new();
body.insert("tags".into(), serde_json::Value::Array(Vec::new()));
assert!(validate_m2m_relations(&meta, &body).await.is_empty());
}
#[tokio::test]
async fn m2m_skips_null_and_missing_values() {
let meta = meta_with_m2m("tags", "tag", "Tag");
assert!(
validate_m2m_relations(&meta, &serde_json::Map::new())
.await
.is_empty()
);
let mut body = serde_json::Map::new();
body.insert("tags".into(), serde_json::Value::Null);
assert!(validate_m2m_relations(&meta, &body).await.is_empty());
}
#[tokio::test]
async fn m2m_rejects_an_object_too() {
let meta = meta_with_m2m("tags", "tag", "Tag");
let mut body = serde_json::Map::new();
body.insert("tags".into(), serde_json::json!({ "id": 1 }));
let errors = validate_m2m_relations(&meta, &body).await;
assert_eq!(errors.len(), 1);
match &errors[0] {
WriteError::Validator { field, message } => {
assert_eq!(field, "tags");
assert!(message.contains("object"));
}
other => panic!("expected Validator, got {other:?}"),
}
}
}