use serde_json::Value;
#[doc(hidden)]
pub trait LoadedRelation {
type Target;
type Field<V>;
fn render<V>(
&self,
map: impl FnOnce(&Self::Target) -> V,
unloaded: impl FnOnce() -> V,
) -> Self::Field<V>;
}
impl<T, K> LoadedRelation for crate::sql::ForeignKey<T, K> {
type Target = T;
type Field<V> = V;
fn render<V>(&self, map: impl FnOnce(&T) -> V, unloaded: impl FnOnce() -> V) -> V {
self.value().map_or_else(unloaded, map)
}
}
impl<R: LoadedRelation> LoadedRelation for Option<R> {
type Target = R::Target;
type Field<V> = Option<R::Field<V>>;
fn render<V>(
&self,
map: impl FnOnce(&R::Target) -> V,
unloaded: impl FnOnce() -> V,
) -> Option<R::Field<V>> {
self.as_ref().map(|r| r.render(map, unloaded))
}
}
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 readable_source_fields() -> &'static [&'static str] {
&[]
}
fn readable_fields() -> &'static [&'static str] {
Self::readable_source_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_patch(stored: &Self::Model, body: &Value) -> Result<(), crate::forms::FormErrors> {
let _ = stored;
Self::from_writable_json(body)?.validate()
}
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, SqlValue};
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).filter(|v| !matches!(v, SqlValue::Null)) 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 select =
crate::core::SelectQuery::new(schema).where_clause(crate::core::WhereExpr::And(
predicates
.into_iter()
.map(crate::core::WhereExpr::Predicate)
.collect(),
));
let hits = crate::sql::count_rows_pool(pool, &crate::core::CountQuery::exists(select))
.await
.map_err(|e| {
let mut errs = crate::forms::FormErrors::default();
errs.add_non_field(format!("unique_together check failed: {e}"));
errs
})?;
if hits > 0 {
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 {
crate::url_codec::fill_pk_segment(template, &render_pk(pk))
}
#[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 url = crate::url_codec::fill_pk_segment(self_template, &render_pk_json(pk_val));
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 url = crate::url_codec::fill_pk_segment(template, &render_pk_json(v));
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_urls_encode_a_string_pk_as_one_segment() {
let pk = crate::core::SqlValue::String("a/../admin".into());
assert_eq!(hyperlink_url("/u/{pk}", &pk), "/u/a%2F..%2Fadmin");
let mut fks = std::collections::HashMap::new();
fks.insert("tag", "/t/{pk}");
let out = hyperlinked_to_value(
serde_json::json!({"id": "x?y=1", "tag": "é #"}),
"/u/{pk}",
"id",
&fks,
);
assert_eq!(out["url"], "/u/x%3Fy%3D1");
assert_eq!(out["tag_url"], "/t/%C3%A9%20%23");
}
#[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");
}
}