use serde_json::Value;
pub trait ModelSerializer: serde::Serialize + Sized {
type Model;
fn from_model(model: &Self::Model) -> Self;
fn to_value(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
fn many(models: &[Self::Model]) -> Vec<Self> {
models.iter().map(Self::from_model).collect()
}
fn many_to_value(models: &[Self::Model]) -> Value {
Value::Array(
models
.iter()
.map(|m| Self::from_model(m).to_value())
.collect(),
)
}
fn writable_fields() -> &'static [&'static str];
fn writable_source_fields() -> &'static [&'static str] {
Self::writable_fields()
}
fn from_writable_json(body: &Value) -> Result<Self, crate::forms::FormErrors> {
let _ = body;
let mut errors = crate::forms::FormErrors::default();
errors.add_non_field("from_writable_json not implemented for this serializer");
Err(errors)
}
fn validate(&self) -> Result<(), crate::forms::FormErrors> {
Ok(())
}
}
pub async fn check_unique_together_pool(
pool: &crate::sql::Pool,
schema: &'static crate::core::ModelSchema,
values: &std::collections::HashMap<&'static str, crate::core::SqlValue>,
exclude_pk: Option<&crate::core::SqlValue>,
) -> Result<(), crate::forms::FormErrors> {
use crate::core::{Filter, Op};
let mut errors = crate::forms::FormErrors::default();
for index in schema.indexes {
if !index.unique || index.columns.len() < 2 || index.where_clause.is_some() {
continue;
}
let mut predicates: Vec<Filter> = Vec::with_capacity(index.columns.len());
let mut all_bound = true;
for col in index.columns {
let Some(val) = values.get(*col) else {
all_bound = false;
break;
};
predicates.push(Filter {
column: col,
op: Op::Eq,
value: val.clone(),
});
}
if !all_bound {
continue;
}
if let (Some(pk_field), Some(pk_value)) = (schema.primary_key(), exclude_pk) {
predicates.push(Filter {
column: pk_field.column,
op: Op::Ne,
value: pk_value.clone(),
});
}
let dialect = pool.dialect();
let table_q = dialect.quote_ident(schema.table);
let mut clauses: Vec<String> = Vec::with_capacity(predicates.len());
let mut params: Vec<crate::core::SqlValue> = Vec::with_capacity(predicates.len());
for (i, pred) in predicates.iter().enumerate() {
let col = dialect.quote_ident(pred.column);
let op_str = match pred.op {
Op::Eq => "=",
Op::Ne => "<>",
_ => unreachable!("only Eq/Ne above"),
};
let placeholder = dialect.placeholder(i + 1);
clauses.push(format!("{col} {op_str} {placeholder}"));
params.push(pred.value.clone());
}
let where_sql = clauses.join(" AND ");
let sql = format!("SELECT 1 FROM {table_q} WHERE {where_sql} LIMIT 1");
let hits: Vec<(i64,)> = crate::sql::raw_query_pool(&sql, params, pool)
.await
.map_err(|e| {
let mut errs = crate::forms::FormErrors::default();
errs.add_non_field(format!("unique_together check failed: {e}"));
errs
})?;
if !hits.is_empty() {
errors.add_non_field(format!(
"The fields {} must be unique together.",
index.columns.join(", "),
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[must_use]
pub fn hyperlink_url(template: &str, pk: &crate::core::SqlValue) -> String {
let pk_str = render_pk(pk);
template.replace("{pk}", &pk_str)
}
#[must_use]
pub fn hyperlinked_to_value(
mut base: serde_json::Value,
self_template: &str,
pk_field: &str,
fk_templates: &std::collections::HashMap<&str, &str>,
) -> serde_json::Value {
let obj = base
.as_object_mut()
.expect("hyperlinked_to_value: base must be a JSON object");
if let Some(pk_val) = obj.get(pk_field) {
let pk_str = render_pk_json(pk_val);
let url = self_template.replace("{pk}", &pk_str);
obj.insert("url".into(), serde_json::Value::String(url));
}
for (fk_field, template) in fk_templates {
let url_key = format!("{fk_field}_url");
match obj.get(*fk_field) {
Some(v) if !v.is_null() => {
let pk_str = render_pk_json(v);
let url = template.replace("{pk}", &pk_str);
obj.insert(url_key, serde_json::Value::String(url));
}
_ => {
obj.insert(url_key, serde_json::Value::Null);
}
}
}
base
}
fn render_pk(pk: &crate::core::SqlValue) -> String {
use crate::core::SqlValue;
match pk {
SqlValue::I16(v) => v.to_string(),
SqlValue::I32(v) => v.to_string(),
SqlValue::I64(v) => v.to_string(),
SqlValue::F32(v) => v.to_string(),
SqlValue::F64(v) => v.to_string(),
SqlValue::String(s) => s.clone(),
SqlValue::Uuid(u) => u.to_string(),
other => format!("{other:?}"),
}
}
fn render_pk_json(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod hyperlinked_tests {
use super::*;
#[test]
fn hyperlink_url_substitutes_i64_pk() {
let url = hyperlink_url("/api/posts/{pk}", &crate::core::SqlValue::I64(42));
assert_eq!(url, "/api/posts/42");
}
#[test]
fn hyperlink_url_substitutes_string_pk() {
let url = hyperlink_url(
"/users/{pk}",
&crate::core::SqlValue::String("alice".into()),
);
assert_eq!(url, "/users/alice");
}
#[test]
fn hyperlink_url_substitutes_every_occurrence() {
let url = hyperlink_url("/posts/{pk}/comments/{pk}", &crate::core::SqlValue::I64(7));
assert_eq!(url, "/posts/7/comments/7");
}
#[test]
fn hyperlinked_to_value_adds_url_field_for_self() {
let base = serde_json::json!({"id": 42, "title": "Hi"});
let out = hyperlinked_to_value(
base,
"/api/posts/{pk}",
"id",
&std::collections::HashMap::new(),
);
assert_eq!(out["url"], "/api/posts/42");
assert_eq!(out["id"], 42);
assert_eq!(out["title"], "Hi");
}
#[test]
fn hyperlinked_to_value_adds_fk_url_keys() {
let base = serde_json::json!({
"id": 1,
"title": "Hi",
"author_id": 7,
"section_id": serde_json::Value::Null,
});
let mut fks: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
fks.insert("author_id", "/users/{pk}");
fks.insert("section_id", "/sections/{pk}");
let out = hyperlinked_to_value(base, "/posts/{pk}", "id", &fks);
assert_eq!(out["author_id_url"], "/users/7");
assert!(out["section_id_url"].is_null());
}
#[test]
fn hyperlinked_to_value_handles_missing_pk_key_gracefully() {
let base = serde_json::json!({"title": "Hi"});
let out = hyperlinked_to_value(
base,
"/api/posts/{pk}",
"id",
&std::collections::HashMap::new(),
);
assert_eq!(out.get("url"), None);
}
#[test]
fn hyperlinked_to_value_supports_string_pk() {
let base = serde_json::json!({"slug": "hello", "title": "Hello"});
let out = hyperlinked_to_value(
base,
"/posts/{pk}",
"slug",
&std::collections::HashMap::new(),
);
assert_eq!(out["url"], "/posts/hello");
}
}