use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use serde_json::Value;
use tera::{Context, Tera};
use crate::core::{Filter, ModelSchema, Op, OrderClause, SelectQuery, SqlValue, WhereExpr};
use crate::sql::sqlx::PgPool;
use crate::sql::{count_rows, row_to_json, select_one_row, select_rows};
pub type BulkActionFuture<'a> =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send + 'a>>;
pub type BulkActionFn =
Arc<dyn for<'a> Fn(&'a PgPool, &'a [SqlValue]) -> BulkActionFuture<'a> + Send + Sync>;
#[cfg(feature = "tenancy")]
pub type TenantBulkActionFn = Arc<
dyn for<'a> Fn(&'a mut crate::sql::sqlx::PgConnection, &'a [SqlValue]) -> BulkActionFuture<'a>
+ Send
+ Sync,
>;
#[derive(Clone)]
pub struct BulkAction {
pub name: String,
pub label: String,
pub handler: BulkActionHandler,
}
#[derive(Clone)]
pub enum BulkActionHandler {
Pool(BulkActionFn),
#[cfg(feature = "tenancy")]
Tenant(TenantBulkActionFn),
}
#[derive(Clone)]
pub struct ListView {
schema: &'static ModelSchema,
template: String,
page_size: i64,
max_page_size: i64,
fields: Option<Vec<String>>,
order_by: Vec<(String, bool)>,
filter_fields: Vec<String>,
search_fields: Vec<String>,
ordering_fields: Vec<String>,
bulk_actions_enabled: bool,
actions: Vec<BulkAction>,
confirm_delete: bool,
confirm_delete_template: Option<String>,
fk_display: bool,
}
impl ListView {
#[must_use]
pub fn for_model(schema: &'static ModelSchema) -> Self {
Self {
schema,
template: format!("{}_list.html", schema.table),
page_size: 20,
max_page_size: 100,
fields: None,
order_by: Vec::new(),
filter_fields: Vec::new(),
search_fields: Vec::new(),
ordering_fields: Vec::new(),
bulk_actions_enabled: false,
actions: Vec::new(),
confirm_delete: false,
confirm_delete_template: None,
fk_display: false,
}
}
#[must_use]
pub fn template(mut self, name: impl Into<String>) -> Self {
self.template = name.into();
self
}
#[must_use]
pub fn page_size(mut self, n: usize) -> Self {
self.page_size = i64::try_from(n).unwrap_or(20).max(1);
self
}
#[must_use]
pub fn max_page_size(mut self, n: usize) -> Self {
self.max_page_size = i64::try_from(n).unwrap_or(100).max(1);
self
}
#[must_use]
pub fn order_by(mut self, column: impl Into<String>, desc: bool) -> Self {
self.order_by.push((column.into(), desc));
self
}
#[must_use]
pub fn ordering_fields(mut self, names: &[&str]) -> Self {
self.ordering_fields = names.iter().map(|s| (*s).to_owned()).collect();
self
}
#[must_use]
pub fn filter_fields(mut self, names: &[&str]) -> Self {
self.filter_fields = names.iter().map(|s| (*s).to_owned()).collect();
self
}
#[must_use]
pub fn search_fields(mut self, names: &[&str]) -> Self {
self.search_fields = names.iter().map(|s| (*s).to_owned()).collect();
self
}
#[must_use]
pub fn fields(mut self, names: &[&str]) -> Self {
self.fields = Some(names.iter().map(|s| (*s).to_owned()).collect());
self
}
#[must_use]
pub fn bulk_actions(mut self, on: bool) -> Self {
self.bulk_actions_enabled = on;
self
}
#[must_use]
pub fn action(
mut self,
name: impl Into<String>,
label: impl Into<String>,
handler: BulkActionFn,
) -> Self {
let name = name.into();
self.actions.retain(|a| !same_action_name(&a.name, &name));
self.actions.push(BulkAction {
name,
label: label.into(),
handler: BulkActionHandler::Pool(handler),
});
self
}
#[must_use]
pub fn with_delete_confirmation(mut self, on: bool) -> Self {
self.confirm_delete = on;
self
}
#[must_use]
pub fn with_delete_confirmation_template(mut self, name: impl Into<String>) -> Self {
self.confirm_delete_template = Some(name.into());
self.confirm_delete = true;
self
}
#[must_use]
pub fn with_fk_display(mut self, on: bool) -> Self {
self.fk_display = on;
self
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_action(
mut self,
name: impl Into<String>,
label: impl Into<String>,
handler: TenantBulkActionFn,
) -> Self {
let name = name.into();
self.actions.retain(|a| !same_action_name(&a.name, &name));
self.actions.push(BulkAction {
name,
label: label.into(),
handler: BulkActionHandler::Tenant(handler),
});
self
}
#[must_use]
pub fn router(self, prefix: &str, tera: Arc<Tera>, pool: PgPool) -> Router<()> {
let bulk = self.bulk_actions_enabled;
let state = Arc::new(ListViewState {
vs: self,
tera,
pool,
});
let route = if bulk {
get(handle_list).post(handle_list_action)
} else {
get(handle_list)
};
Router::new().route(prefix, route).with_state(state)
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_router(self, prefix: &str, tera: Arc<Tera>) -> Router<()> {
let bulk = self.bulk_actions_enabled;
let state = Arc::new(TenantListViewState { vs: self, tera });
let route = if bulk {
get(handle_list_tenant).post(handle_list_action_tenant)
} else {
get(handle_list_tenant)
};
Router::new().route(prefix, route).with_state(state)
}
}
fn same_action_name(a: &str, b: &str) -> bool {
a == b
}
#[derive(Clone)]
struct ListViewState {
vs: ListView,
tera: Arc<Tera>,
pool: PgPool,
}
async fn handle_list(
State(state): State<Arc<ListViewState>>,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let page: i64 = params
.get("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1)
.max(1);
let page_size = resolve_page_size(state.vs.page_size, state.vs.max_page_size, ¶ms);
let offset = (page - 1) * page_size;
let (order_by, active_ordering) = match resolve_active_order(
state.vs.schema,
&state.vs.order_by,
&state.vs.ordering_fields,
¶ms,
) {
Ok(v) => v,
Err(msg) => return template_error(&msg),
};
let where_clause = build_list_where(
state.vs.schema,
&state.vs.filter_fields,
&state.vs.search_fields,
¶ms,
);
let select_q = SelectQuery {
model: state.vs.schema,
where_clause: where_clause.clone(),
search: None,
joins: vec![],
order_by,
limit: Some(page_size),
offset: Some(offset),
};
let count_q = crate::core::CountQuery {
model: state.vs.schema,
where_clause,
search: None,
};
let (rows_result, count_result) = tokio::join!(
select_rows(&state.pool, &select_q),
count_rows(&state.pool, &count_q),
);
let rows = match rows_result {
Ok(r) => r,
Err(e) => return template_error(&format!("query rows: {e}")),
};
let total = match count_result {
Ok(c) => c,
Err(e) => return template_error(&format!("count rows: {e}")),
};
let fields = resolved_fields(state.vs.schema, state.vs.fields.as_deref());
let mut object_list: Vec<Value> = rows.iter().map(|r| row_to_json(r, &fields)).collect();
if state.vs.fk_display {
resolve_fk_displays_pool(state.vs.schema, &state.pool, &mut object_list).await;
}
let total_pages = ((total - 1).max(0) / page_size) + 1;
let mut ctx = Context::new();
ctx.insert("object_list", &object_list);
ctx.insert("page", &page);
ctx.insert("page_size", &page_size);
ctx.insert("total", &total);
ctx.insert("total_pages", &total_pages);
let has_next = page < total_pages;
let has_prev = page > 1;
ctx.insert("has_next", &has_next);
ctx.insert("has_prev", &has_prev);
ctx.insert("ordering", &active_ordering);
insert_filter_context(&mut ctx, &state.vs.filter_fields, ¶ms);
insert_pagination_urls(&mut ctx, page, has_next, has_prev, ¶ms);
insert_bulk_actions_context(&mut ctx, &state.vs);
let set_cookie = stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.vs.template, &ctx);
apply_csrf_cookie(&mut resp, set_cookie);
resp
}
async fn handle_list_action(
State(state): State<Arc<ListViewState>>,
req: axum::extract::Request,
) -> Response {
let (parts, body) = req.into_parts();
let form = match read_repeating_form(body).await {
Ok(f) => f,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
let (action, raws) = match parse_bulk_action_form(&form) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — bulk actions require one",
state.vs.schema.table
));
};
let pks = match coerce_selected_pks(pk_field, &raws) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
if state.vs.confirm_delete && action == BUILTIN_DELETE_SELECTED && !is_form_confirmed(&form) {
let objects =
match fetch_pks_as_objects_pool(state.vs.schema, pk_field, &state.pool, &pks).await {
Ok(o) => o,
Err(e) => return template_error(&format!("fetch confirm rows: {e}")),
};
return render_bulk_delete_confirm(
&state.tera,
confirm_delete_template_name(&state.vs),
&action,
&raws,
&objects,
&parts.headers,
);
}
let dispatch_path = parts.uri.path().to_owned();
let result: Result<(), String> = if let Some(custom) = state
.vs
.actions
.iter()
.find(|a| same_action_name(&a.name, &action))
{
match &custom.handler {
BulkActionHandler::Pool(f) => f(&state.pool, &pks).await,
#[cfg(feature = "tenancy")]
BulkActionHandler::Tenant(_) => Err("this action was registered via tenant_action — \
mount the ListView via tenant_router(...) to dispatch it"
.into()),
}
} else if action == BUILTIN_DELETE_SELECTED {
run_delete_selected_pool(state.vs.schema, pk_field, &state.pool, &pks).await
} else {
return (
StatusCode::BAD_REQUEST,
format!("unknown action `{action}`"),
)
.into_response();
};
match result {
Ok(()) => axum::response::Redirect::to(&dispatch_path).into_response(),
Err(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
}
}
async fn read_repeating_form(
body: axum::body::Body,
) -> Result<HashMap<String, Vec<String>>, String> {
use axum::body::to_bytes;
let bytes = to_bytes(body, 4 * 1024 * 1024)
.await
.map_err(|e| e.to_string())?;
let pairs: Vec<(String, String)> =
serde_urlencoded::from_bytes(&bytes).map_err(|e| e.to_string())?;
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (k, v) in pairs {
out.entry(k).or_default().push(v);
}
Ok(out)
}
#[derive(Clone)]
pub struct DetailView {
schema: &'static ModelSchema,
template: String,
fields: Option<Vec<String>>,
}
impl DetailView {
#[must_use]
pub fn for_model(schema: &'static ModelSchema) -> Self {
Self {
schema,
template: format!("{}_detail.html", schema.table),
fields: None,
}
}
#[must_use]
pub fn template(mut self, name: impl Into<String>) -> Self {
self.template = name.into();
self
}
#[must_use]
pub fn fields(mut self, names: &[&str]) -> Self {
self.fields = Some(names.iter().map(|s| (*s).to_owned()).collect());
self
}
#[must_use]
pub fn router(self, prefix: &str, tera: Arc<Tera>, pool: PgPool) -> Router<()> {
let state = Arc::new(DetailViewState {
vs: self,
tera,
pool,
});
let path = format!("{}/{{pk}}", prefix.trim_end_matches('/'));
Router::new()
.route(&path, get(handle_detail))
.with_state(state)
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_router(self, prefix: &str, tera: Arc<Tera>) -> Router<()> {
let state = Arc::new(TenantDetailViewState { vs: self, tera });
let path = format!("{}/{{pk}}", prefix.trim_end_matches('/'));
Router::new()
.route(&path, get(handle_detail_tenant))
.with_state(state)
}
}
#[derive(Clone)]
struct DetailViewState {
vs: DetailView,
tera: Arc<Tera>,
pool: PgPool,
}
async fn handle_detail(
State(state): State<Arc<DetailViewState>>,
Path(pk): Path<String>,
) -> Response {
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — DetailView can't probe by PK",
state.vs.schema.table
));
};
let select_q = SelectQuery {
model: state.vs.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: Some(1),
offset: None,
};
let row = match select_one_row(&state.pool, &select_q).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => return template_error(&format!("query row: {e}")),
};
let fields = resolved_fields(state.vs.schema, state.vs.fields.as_deref());
let object = row_to_json(&row, &fields);
let mut ctx = Context::new();
ctx.insert("object", &object);
render(&state.tera, &state.vs.template, &ctx)
}
#[derive(Clone)]
pub struct DeleteView {
schema: &'static ModelSchema,
template: String,
success_url: String,
fields: Option<Vec<String>>,
}
impl DeleteView {
#[must_use]
pub fn for_model(schema: &'static ModelSchema) -> Self {
Self {
schema,
template: format!("{}_confirm_delete.html", schema.table),
success_url: "/".to_owned(),
fields: None,
}
}
#[must_use]
pub fn template(mut self, name: impl Into<String>) -> Self {
self.template = name.into();
self
}
#[must_use]
pub fn success_url(mut self, url: impl Into<String>) -> Self {
self.success_url = url.into();
self
}
#[must_use]
pub fn fields(mut self, names: &[&str]) -> Self {
self.fields = Some(names.iter().map(|s| (*s).to_owned()).collect());
self
}
#[must_use]
pub fn router(self, prefix: &str, tera: Arc<Tera>, pool: PgPool) -> Router<()> {
let state = Arc::new(DeleteViewState {
vs: self,
tera,
pool,
});
let path = format!("{}/{{pk}}/delete", prefix.trim_end_matches('/'));
Router::new()
.route(
&path,
axum::routing::get(handle_delete_confirm).post(handle_delete_submit),
)
.with_state(state)
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_router(self, prefix: &str, tera: Arc<Tera>) -> Router<()> {
let state = Arc::new(TenantDeleteViewState { vs: self, tera });
let path = format!("{}/{{pk}}/delete", prefix.trim_end_matches('/'));
Router::new()
.route(
&path,
axum::routing::get(handle_delete_confirm_tenant).post(handle_delete_submit_tenant),
)
.with_state(state)
}
}
#[derive(Clone)]
struct DeleteViewState {
vs: DeleteView,
tera: Arc<Tera>,
pool: PgPool,
}
async fn handle_delete_confirm(
State(state): State<Arc<DeleteViewState>>,
Path(pk): Path<String>,
headers: axum::http::HeaderMap,
) -> Response {
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — DeleteView can't probe by PK",
state.vs.schema.table
));
};
let select_q = SelectQuery {
model: state.vs.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: Some(1),
offset: None,
};
let row = match select_one_row(&state.pool, &select_q).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => return template_error(&format!("query row: {e}")),
};
let fields = resolved_fields(state.vs.schema, state.vs.fields.as_deref());
let object = row_to_json(&row, &fields);
let mut ctx = Context::new();
ctx.insert("object", &object);
let set_cookie = stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.vs.template, &ctx);
apply_csrf_cookie(&mut resp, set_cookie);
resp
}
async fn handle_delete_submit(
State(state): State<Arc<DeleteViewState>>,
Path(pk): Path<String>,
) -> Response {
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — DeleteView can't delete by PK",
state.vs.schema.table
));
};
let delete_q = crate::core::DeleteQuery {
model: state.vs.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
};
match crate::sql::delete(&state.pool, &delete_q).await {
Ok(0) => (StatusCode::NOT_FOUND, "not found").into_response(),
Ok(_) => {
let target = substitute_pk(&state.vs.success_url, &pk);
axum::response::Redirect::to(&target).into_response()
}
Err(e) => template_error(&format!("delete row: {e}")),
}
}
#[derive(Clone)]
pub struct CreateView {
schema: &'static ModelSchema,
template: String,
success_url: String,
fields: Option<Vec<String>>,
validator: Option<Validator>,
}
impl CreateView {
#[must_use]
pub fn for_model(schema: &'static ModelSchema) -> Self {
Self {
schema,
template: format!("{}_form.html", schema.table),
success_url: "/".to_owned(),
fields: None,
validator: None,
}
}
#[must_use]
pub fn template(mut self, name: impl Into<String>) -> Self {
self.template = name.into();
self
}
#[must_use]
pub fn success_url(mut self, url: impl Into<String>) -> Self {
self.success_url = url.into();
self
}
#[must_use]
pub fn fields(mut self, names: &[&str]) -> Self {
self.fields = Some(names.iter().map(|s| (*s).to_owned()).collect());
self
}
#[must_use]
pub fn validator<F>(mut self, f: F) -> Self
where
F: Fn(&HashMap<String, String>) -> Result<(), crate::forms::FormErrors>
+ Send
+ Sync
+ 'static,
{
self.validator = Some(Arc::new(f));
self
}
#[must_use]
pub fn form<F: crate::forms::Form>(self) -> Self {
self.validator(|data| F::parse(data).map(|_| ()))
}
#[must_use]
pub fn router(self, prefix: &str, tera: Arc<Tera>, pool: PgPool) -> Router<()> {
let state = Arc::new(FormViewState {
schema: self.schema,
template: self.template.clone(),
success_url: self.success_url.clone(),
fields: self.fields.clone(),
tera,
pool,
validator: self.validator.clone(),
});
let path = format!("{}/new", prefix.trim_end_matches('/'));
Router::new()
.route(
&path,
axum::routing::get(handle_create_get).post(handle_create_post),
)
.with_state(state)
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_router(self, prefix: &str, tera: Arc<Tera>) -> Router<()> {
let state = Arc::new(TenantFormViewState {
schema: self.schema,
template: self.template,
success_url: self.success_url,
fields: self.fields,
tera,
validator: self.validator,
});
let path = format!("{}/new", prefix.trim_end_matches('/'));
Router::new()
.route(
&path,
axum::routing::get(handle_create_get_tenant).post(handle_create_post_tenant),
)
.with_state(state)
}
}
#[derive(Clone)]
pub struct UpdateView {
schema: &'static ModelSchema,
template: String,
success_url: String,
fields: Option<Vec<String>>,
validator: Option<Validator>,
}
impl UpdateView {
#[must_use]
pub fn for_model(schema: &'static ModelSchema) -> Self {
Self {
schema,
template: format!("{}_form.html", schema.table),
success_url: "/".to_owned(),
fields: None,
validator: None,
}
}
#[must_use]
pub fn template(mut self, name: impl Into<String>) -> Self {
self.template = name.into();
self
}
#[must_use]
pub fn success_url(mut self, url: impl Into<String>) -> Self {
self.success_url = url.into();
self
}
#[must_use]
pub fn fields(mut self, names: &[&str]) -> Self {
self.fields = Some(names.iter().map(|s| (*s).to_owned()).collect());
self
}
#[must_use]
pub fn validator<F>(mut self, f: F) -> Self
where
F: Fn(&HashMap<String, String>) -> Result<(), crate::forms::FormErrors>
+ Send
+ Sync
+ 'static,
{
self.validator = Some(Arc::new(f));
self
}
#[must_use]
pub fn form<F: crate::forms::Form>(self) -> Self {
self.validator(|data| F::parse(data).map(|_| ()))
}
#[must_use]
pub fn router(self, prefix: &str, tera: Arc<Tera>, pool: PgPool) -> Router<()> {
let state = Arc::new(FormViewState {
schema: self.schema,
template: self.template.clone(),
success_url: self.success_url.clone(),
fields: self.fields.clone(),
tera,
pool,
validator: self.validator.clone(),
});
let path = format!("{}/{{pk}}/edit", prefix.trim_end_matches('/'));
Router::new()
.route(
&path,
axum::routing::get(handle_update_get).post(handle_update_post),
)
.with_state(state)
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_router(self, prefix: &str, tera: Arc<Tera>) -> Router<()> {
let state = Arc::new(TenantFormViewState {
schema: self.schema,
template: self.template,
success_url: self.success_url,
fields: self.fields,
tera,
validator: self.validator,
});
let path = format!("{}/{{pk}}/edit", prefix.trim_end_matches('/'));
Router::new()
.route(
&path,
axum::routing::get(handle_update_get_tenant).post(handle_update_post_tenant),
)
.with_state(state)
}
}
pub type Validator =
Arc<dyn Fn(&HashMap<String, String>) -> Result<(), crate::forms::FormErrors> + Send + Sync>;
#[derive(Clone)]
struct FormViewState {
schema: &'static ModelSchema,
template: String,
success_url: String,
fields: Option<Vec<String>>,
tera: Arc<Tera>,
pool: PgPool,
validator: Option<Validator>,
}
#[derive(serde::Serialize)]
struct FormField {
name: &'static str,
column: &'static str,
ty: &'static str,
required: bool,
max_length: Option<u32>,
value: String,
}
fn form_fields(
schema: &'static ModelSchema,
explicit: Option<&[String]>,
values: &HashMap<String, String>,
) -> Vec<FormField> {
schema
.fields
.iter()
.filter(|f| {
if f.primary_key || f.auto || f.generated_as.is_some() {
return false;
}
match explicit {
Some(names) => names.iter().any(|n| n == f.name || n == f.column),
None => true,
}
})
.map(|f| FormField {
name: f.name,
column: f.column,
ty: field_type_label(f.ty),
required: !f.nullable,
max_length: f.max_length,
value: values
.get(f.name)
.or_else(|| values.get(f.column))
.cloned()
.unwrap_or_default(),
})
.collect()
}
fn field_type_label(ty: crate::core::FieldType) -> &'static str {
use crate::core::FieldType as T;
match ty {
T::String => "string",
T::I16 => "i16",
T::I32 => "i32",
T::I64 => "i64",
T::F32 => "f32",
T::F64 => "f64",
T::Bool => "bool",
T::DateTime => "datetime",
T::Date => "date",
T::Uuid => "uuid",
T::Json => "json",
}
}
fn substitute_pk(template: &str, pk: &str) -> String {
if !template.contains("{pk}") {
return template.to_owned();
}
template.replace("{pk}", pk)
}
fn interpolate_success_url(
template: &str,
row: &sqlx::postgres::PgRow,
schema: &'static crate::core::ModelSchema,
) -> Result<String, String> {
let placeholders = parse_success_url_placeholders(template);
if placeholders.is_empty() {
return Ok(template.to_owned());
}
let mut out = template.to_owned();
for name in placeholders {
let column = if name == "pk" {
let Some(pk) = schema.primary_key() else {
return Err(
"success_url contains `{pk}` placeholder but the model has no primary key"
.to_owned(),
);
};
pk
} else {
schema.field(name).ok_or_else(|| {
format!(
"success_url placeholder `{{{name}}}` does not match any field on `{}`",
schema.table
)
})?
};
let v = column_value_as_string(row, column).map_err(|e| {
format!(
"success_url interpolation failed reading `{}`: {e}",
column.column
)
})?;
out = out.replace(&format!("{{{name}}}"), &v);
}
Ok(out)
}
fn parse_success_url_placeholders(template: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = template;
while let Some(start) = rest.find('{') {
let after = &rest[start + 1..];
let Some(end) = after.find('}') else {
break;
};
let candidate = &after[..end];
if !candidate.is_empty()
&& candidate
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
out.push(candidate);
}
rest = &after[end + 1..];
}
out
}
fn success_url_returning_columns(
template: &str,
schema: &'static crate::core::ModelSchema,
) -> Result<Vec<&'static str>, String> {
let placeholders = parse_success_url_placeholders(template);
let mut out: Vec<&'static str> = Vec::new();
for name in placeholders {
let column = if name == "pk" {
let Some(pk) = schema.primary_key() else {
return Err(
"success_url contains `{pk}` placeholder but the model has no primary key"
.to_owned(),
);
};
pk.column
} else {
schema
.field(name)
.ok_or_else(|| {
format!(
"success_url placeholder `{{{name}}}` does not match any field on `{}`",
schema.table
)
})?
.column
};
if !out.contains(&column) {
out.push(column);
}
}
Ok(out)
}
fn column_value_as_string(
row: &sqlx::postgres::PgRow,
field: &'static crate::core::FieldSchema,
) -> Result<String, sqlx::Error> {
use crate::core::FieldType as T;
use sqlx::Row as _;
match field.ty {
T::I16 => row.try_get::<i16, _>(field.column).map(|n| n.to_string()),
T::I32 => row.try_get::<i32, _>(field.column).map(|n| n.to_string()),
T::I64 => row.try_get::<i64, _>(field.column).map(|n| n.to_string()),
T::Uuid => row
.try_get::<uuid::Uuid, _>(field.column)
.map(|u| u.to_string()),
_ => row.try_get::<String, _>(field.column),
}
}
fn coerce_pk(field: &crate::core::FieldSchema, raw: &str) -> SqlValue {
use crate::core::FieldType as T;
match field.ty {
T::I16 | T::I32 | T::I64 => raw
.parse::<i64>()
.map(SqlValue::I64)
.unwrap_or_else(|_| SqlValue::String(raw.to_owned())),
T::Uuid => raw
.parse::<uuid::Uuid>()
.map(SqlValue::Uuid)
.unwrap_or_else(|_| SqlValue::String(raw.to_owned())),
_ => SqlValue::String(raw.to_owned()),
}
}
fn coerce_value(field: &crate::core::FieldSchema, raw: &str) -> Result<SqlValue, String> {
use crate::core::FieldType as T;
if raw.is_empty() && field.nullable {
return Ok(SqlValue::Null);
}
match field.ty {
T::String => Ok(SqlValue::String(raw.to_owned())),
T::I16 => raw
.parse::<i16>()
.map(|n| SqlValue::I64(i64::from(n)))
.map_err(|e| format!("expected an integer, got `{raw}` ({e})")),
T::I32 => raw
.parse::<i32>()
.map(|n| SqlValue::I64(i64::from(n)))
.map_err(|e| format!("expected an integer, got `{raw}` ({e})")),
T::I64 => raw
.parse::<i64>()
.map(SqlValue::I64)
.map_err(|e| format!("expected an integer, got `{raw}` ({e})")),
T::F32 => raw
.parse::<f32>()
.map(|n| SqlValue::F64(f64::from(n)))
.map_err(|e| format!("expected a number, got `{raw}` ({e})")),
T::F64 => raw
.parse::<f64>()
.map(SqlValue::F64)
.map_err(|e| format!("expected a number, got `{raw}` ({e})")),
T::Bool => match raw {
"1" | "true" | "on" | "yes" => Ok(SqlValue::Bool(true)),
"0" | "false" | "off" | "no" | "" => Ok(SqlValue::Bool(false)),
_ => Err(format!("expected boolean, got `{raw}`")),
},
_ => Ok(SqlValue::String(raw.to_owned())),
}
}
async fn handle_create_get(
State(state): State<Arc<FormViewState>>,
headers: axum::http::HeaderMap,
) -> Response {
let mut ctx = Context::new();
let fields = form_fields(state.schema, state.fields.as_deref(), &HashMap::new());
ctx.insert(
"form",
&serde_json::json!({"fields": fields, "errors": serde_json::Map::new()}),
);
ctx.insert("is_create", &true);
ctx.insert("is_update", &false);
let set_cookie = stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.template, &ctx);
apply_csrf_cookie(&mut resp, set_cookie);
resp
}
async fn handle_create_post(
State(state): State<Arc<FormViewState>>,
headers: axum::http::HeaderMap,
axum::Form(form): axum::Form<HashMap<String, String>>,
) -> Response {
let (columns, values, mut errors) = parse_form(state.schema, state.fields.as_deref(), &form);
merge_validator_errors(state.validator.as_ref(), &form, &mut errors);
if !errors.is_empty() {
return rerender_form(&state, &form, &errors, false, &headers);
}
let returning = match success_url_returning_columns(&state.success_url, state.schema) {
Ok(cols) => cols,
Err(e) => return template_error(&e),
};
let need_returning = !returning.is_empty();
let insert_q = crate::core::InsertQuery {
model: state.schema,
columns,
values,
returning,
on_conflict: None,
};
let target_url = if need_returning {
match crate::sql::insert_returning(&state.pool, &insert_q).await {
Ok(row) => match interpolate_success_url(&state.success_url, &row, state.schema) {
Ok(url) => url,
Err(e) => return template_error(&e),
},
Err(e) => return template_error(&format!("insert row: {e}")),
}
} else {
if let Err(e) = crate::sql::insert(&state.pool, &insert_q).await {
return template_error(&format!("insert row: {e}"));
}
state.success_url.clone()
};
axum::response::Redirect::to(&target_url).into_response()
}
async fn handle_update_get(
State(state): State<Arc<FormViewState>>,
Path(pk): Path<String>,
headers: axum::http::HeaderMap,
) -> Response {
let Some(pk_field) = state.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — UpdateView can't probe by PK",
state.schema.table
));
};
let select_q = SelectQuery {
model: state.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: Some(1),
offset: None,
};
let row = match select_one_row(&state.pool, &select_q).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => return template_error(&format!("query row: {e}")),
};
let scalars: Vec<&'static crate::core::FieldSchema> = state.schema.scalar_fields().collect();
let row_json = row_to_json(&row, &scalars);
let row_obj = row_json.as_object().cloned().unwrap_or_default();
let mut values: HashMap<String, String> = HashMap::with_capacity(row_obj.len());
for (k, v) in row_obj {
let s = match v {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s,
other => other.to_string(),
};
values.insert(k, s);
}
let fields = form_fields(state.schema, state.fields.as_deref(), &values);
let mut ctx = Context::new();
ctx.insert(
"form",
&serde_json::json!({"fields": fields, "errors": serde_json::Map::new()}),
);
ctx.insert("object", &row_json);
ctx.insert("pk", &pk);
ctx.insert("is_create", &false);
ctx.insert("is_update", &true);
let set_cookie = stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.template, &ctx);
apply_csrf_cookie(&mut resp, set_cookie);
resp
}
async fn handle_update_post(
State(state): State<Arc<FormViewState>>,
Path(pk): Path<String>,
headers: axum::http::HeaderMap,
axum::Form(form): axum::Form<HashMap<String, String>>,
) -> Response {
let Some(pk_field) = state.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — UpdateView can't update by PK",
state.schema.table
));
};
let (columns, values, mut errors) = parse_form(state.schema, state.fields.as_deref(), &form);
merge_validator_errors(state.validator.as_ref(), &form, &mut errors);
if !errors.is_empty() {
return rerender_form(&state, &form, &errors, true, &headers);
}
let assignments: Vec<crate::core::Assignment> = columns
.into_iter()
.zip(values)
.map(|(column, value)| crate::core::Assignment { column, value })
.collect();
let update_q = crate::core::UpdateQuery {
model: state.schema,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
};
match crate::sql::update(&state.pool, &update_q).await {
Ok(0) => (StatusCode::NOT_FOUND, "not found").into_response(),
Ok(_) => {
let target = substitute_pk(&state.success_url, &pk);
axum::response::Redirect::to(&target).into_response()
}
Err(e) => template_error(&format!("update row: {e}")),
}
}
fn parse_form(
schema: &'static ModelSchema,
explicit: Option<&[String]>,
submitted: &HashMap<String, String>,
) -> (Vec<&'static str>, Vec<SqlValue>, HashMap<String, String>) {
let mut columns: Vec<&'static str> = Vec::new();
let mut values: Vec<SqlValue> = Vec::new();
let mut errors: HashMap<String, String> = HashMap::new();
for f in schema.fields {
if f.primary_key || f.auto || f.generated_as.is_some() {
continue;
}
if let Some(names) = explicit {
if !names.iter().any(|n| n == f.name || n == f.column) {
continue;
}
}
let raw = submitted
.get(f.name)
.or_else(|| submitted.get(f.column))
.cloned()
.unwrap_or_default();
if raw.is_empty() && !f.nullable && !matches!(f.ty, crate::core::FieldType::Bool) {
errors.insert(f.name.to_owned(), "this field is required".to_owned());
continue;
}
match coerce_value(f, &raw) {
Ok(v) => {
if let Err(e) = crate::core::validate_value(schema.name, f, &v) {
errors.insert(f.name.to_owned(), bounds_error_message(&e));
continue;
}
columns.push(f.column);
values.push(v);
}
Err(e) => {
errors.insert(f.name.to_owned(), e);
}
}
}
(columns, values, errors)
}
fn bounds_error_message(e: &crate::core::QueryError) -> String {
use crate::core::QueryError;
match e {
QueryError::MaxLengthExceeded { max, actual, .. } => {
format!("must be {max} characters or fewer (got {actual})")
}
QueryError::OutOfRange {
min, max, value, ..
} => match (min, max) {
(Some(lo), Some(hi)) => format!("must be between {lo} and {hi} (got {value})"),
(Some(lo), None) => format!("must be ≥ {lo} (got {value})"),
(None, Some(hi)) => format!("must be ≤ {hi} (got {value})"),
(None, None) => format!("invalid value: {value}"),
},
other => other.to_string(),
}
}
fn merge_validator_errors(
validator: Option<&Validator>,
submitted: &HashMap<String, String>,
errors: &mut HashMap<String, String>,
) {
let Some(v) = validator else { return };
let Err(form_errs) = v(submitted) else { return };
for (field, msgs) in form_errs.fields() {
if msgs.is_empty() {
continue;
}
let joined = msgs.join("; ");
errors
.entry(field.clone())
.and_modify(|prev| {
prev.push_str("; ");
prev.push_str(&joined);
})
.or_insert(joined);
}
if !form_errs.non_field().is_empty() {
let joined = form_errs.non_field().join("; ");
errors
.entry("__all__".to_owned())
.and_modify(|prev| {
prev.push_str("; ");
prev.push_str(&joined);
})
.or_insert(joined);
}
}
fn rerender_form(
state: &FormViewState,
submitted: &HashMap<String, String>,
errors: &HashMap<String, String>,
is_update: bool,
headers: &axum::http::HeaderMap,
) -> Response {
let fields = form_fields(state.schema, state.fields.as_deref(), submitted);
let mut ctx = Context::new();
ctx.insert(
"form",
&serde_json::json!({"fields": fields, "errors": errors}),
);
ctx.insert("is_create", &!is_update);
ctx.insert("is_update", &is_update);
let set_cookie = stamp_csrf(headers, &mut ctx);
let mut resp = render(&state.tera, &state.template, &ctx);
*resp.status_mut() = StatusCode::UNPROCESSABLE_ENTITY;
apply_csrf_cookie(&mut resp, set_cookie);
resp
}
fn resolve_order_by(
schema: &'static ModelSchema,
spec: &[(String, bool)],
) -> Result<Vec<OrderClause>, String> {
if spec.is_empty() {
return Ok(default_order_by(schema));
}
let mut out = Vec::with_capacity(spec.len());
for (name, desc) in spec {
let field = schema
.fields
.iter()
.find(|f| f.name == name || f.column == name)
.ok_or_else(|| {
format!(
"order_by(`{}`) does not match any field on `{}`",
name, schema.table
)
})?;
out.push(OrderClause {
column: field.column,
desc: *desc,
});
}
Ok(out)
}
fn default_order_by(schema: &'static ModelSchema) -> Vec<OrderClause> {
match schema.primary_key() {
Some(pk) => vec![OrderClause {
column: pk.column,
desc: false,
}],
None => Vec::new(),
}
}
fn resolve_page_size(default: i64, max: i64, params: &HashMap<String, String>) -> i64 {
let Some(raw) = params.get("page_size") else {
return default;
};
let Ok(n) = raw.parse::<i64>() else {
return default;
};
n.clamp(1, max)
}
fn resolve_active_order(
schema: &'static ModelSchema,
builder_spec: &[(String, bool)],
ordering_fields: &[String],
params: &HashMap<String, String>,
) -> Result<(Vec<OrderClause>, String), String> {
if let Some(raw) = params.get("ordering").filter(|s| !s.is_empty()) {
let (name, desc) = if let Some(rest) = raw.strip_prefix('-') {
(rest, true)
} else {
(raw.as_str(), false)
};
if ordering_fields.iter().any(|f| f == name) {
if let Some(field) = schema.field(name) {
return Ok((
vec![OrderClause {
column: field.column,
desc,
}],
raw.clone(),
));
}
}
}
let resolved = resolve_order_by(schema, builder_spec)?;
Ok((resolved, String::new()))
}
fn build_list_where(
schema: &'static ModelSchema,
filter_fields: &[String],
search_fields: &[String],
params: &HashMap<String, String>,
) -> WhereExpr {
use crate::core::Filter;
let mut predicates: Vec<WhereExpr> = Vec::new();
for (key, val) in params {
if matches!(key.as_str(), "page" | "page_size" | "search") {
continue;
}
if !filter_fields.iter().any(|f| f == key) {
continue;
}
let Some(field) = schema.field(key) else {
continue;
};
predicates.push(WhereExpr::Predicate(Filter {
column: field.column,
op: Op::Eq,
value: SqlValue::String(val.clone()),
}));
}
if let Some(q) = params.get("search").filter(|s| !s.is_empty()) {
let escaped = escape_like_pattern(q);
let pattern = format!("%{escaped}%");
let mut or_branches: Vec<WhereExpr> = Vec::new();
for name in search_fields {
if let Some(field) = schema.field(name) {
or_branches.push(WhereExpr::Predicate(Filter {
column: field.column,
op: Op::ILike,
value: SqlValue::String(pattern.clone()),
}));
}
}
match or_branches.len() {
0 => {}
1 => predicates.push(or_branches.remove(0)),
_ => predicates.push(WhereExpr::Or(or_branches)),
}
}
if predicates.is_empty() {
WhereExpr::And(vec![])
} else if predicates.len() == 1 {
predicates.remove(0)
} else {
WhereExpr::And(predicates)
}
}
fn escape_like_pattern(input: &str) -> String {
input
.replace('\\', r"\\")
.replace('%', r"\%")
.replace('_', r"\_")
}
fn stamp_csrf(_headers: &axum::http::HeaderMap, ctx: &mut Context) -> Option<String> {
#[cfg(feature = "csrf")]
{
let (token, set_cookie) =
crate::forms::csrf::ensure_token(_headers, crate::forms::csrf::CSRF_COOKIE);
ctx.insert("csrf_token", &token);
set_cookie
}
#[cfg(not(feature = "csrf"))]
{
ctx.insert("csrf_token", "");
None
}
}
fn apply_csrf_cookie(resp: &mut Response, set_cookie: Option<String>) {
let Some(c) = set_cookie else { return };
if let Ok(hv) = axum::http::HeaderValue::from_str(&c) {
resp.headers_mut()
.append(axum::http::header::SET_COOKIE, hv);
}
}
fn insert_pagination_urls(
ctx: &mut Context,
page: i64,
has_next: bool,
has_prev: bool,
params: &HashMap<String, String>,
) {
let next_url = if has_next {
Some(build_pagination_query(params, page + 1))
} else {
None
};
let prev_url = if has_prev {
Some(build_pagination_query(params, page - 1))
} else {
None
};
ctx.insert("next_page_url", &next_url);
ctx.insert("prev_page_url", &prev_url);
}
fn build_pagination_query(params: &HashMap<String, String>, target_page: i64) -> String {
let mut keys: Vec<&str> = params
.keys()
.map(String::as_str)
.filter(|k| *k != "page")
.collect();
keys.sort_unstable();
let mut out = String::from("?");
for k in keys {
let v = ¶ms[k];
if !out.ends_with('?') {
out.push('&');
}
out.push_str(&urlencode(k));
out.push('=');
out.push_str(&urlencode(v));
}
if !out.ends_with('?') {
out.push('&');
}
out.push_str("page=");
out.push_str(&target_page.to_string());
out
}
fn urlencode(s: &str) -> String {
crate::url_codec::url_encode(s)
}
pub(super) fn insert_filter_context(
ctx: &mut Context,
filter_fields: &[String],
params: &HashMap<String, String>,
) {
let filters: HashMap<&str, &str> = params
.iter()
.filter(|(k, _)| filter_fields.iter().any(|f| f == *k))
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
ctx.insert("filters", &filters);
let search = params.get("search").map(String::as_str).unwrap_or_default();
ctx.insert("search", search);
}
fn insert_bulk_actions_context(ctx: &mut Context, vs: &ListView) {
#[derive(serde::Serialize)]
struct Entry<'a> {
name: &'a str,
label: &'a str,
}
let mut entries: Vec<Entry> = Vec::new();
if vs.bulk_actions_enabled {
entries.push(Entry {
name: BUILTIN_DELETE_SELECTED,
label: "Delete selected",
});
for a in &vs.actions {
entries.push(Entry {
name: &a.name,
label: &a.label,
});
}
}
ctx.insert("bulk_actions", &entries);
}
const BUILTIN_DELETE_SELECTED: &str = "delete_selected";
fn parse_bulk_action_form(
form: &HashMap<String, Vec<String>>,
) -> Result<(String, Vec<String>), String> {
let action = form
.get("action")
.and_then(|v| v.first())
.map(String::clone)
.ok_or_else(|| "missing `action` form field".to_owned())?;
let pks = form
.get("_selected_action")
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
if pks.is_empty() {
return Err("no rows selected (_selected_action missing)".into());
}
Ok((action, pks))
}
fn coerce_selected_pks(
pk_field: &'static crate::core::FieldSchema,
raws: &[String],
) -> Result<Vec<SqlValue>, String> {
raws.iter()
.map(|s| coerce_pk_typed(pk_field, s))
.collect::<Result<Vec<_>, _>>()
}
fn coerce_pk_typed(
pk_field: &'static crate::core::FieldSchema,
raw: &str,
) -> Result<SqlValue, String> {
use crate::core::FieldType;
match pk_field.ty {
FieldType::I64 => raw
.parse::<i64>()
.map(SqlValue::I64)
.map_err(|e| format!("invalid i64 PK `{raw}`: {e}")),
FieldType::I32 => raw
.parse::<i32>()
.map(SqlValue::I32)
.map_err(|e| format!("invalid i32 PK `{raw}`: {e}")),
FieldType::I16 => raw
.parse::<i16>()
.map(SqlValue::I16)
.map_err(|e| format!("invalid i16 PK `{raw}`: {e}")),
FieldType::Uuid => uuid::Uuid::parse_str(raw)
.map(SqlValue::Uuid)
.map_err(|e| format!("invalid uuid PK `{raw}`: {e}")),
FieldType::String => Ok(SqlValue::String(raw.to_owned())),
other => Err(format!(
"PK type {other:?} is not supported for bulk actions"
)),
}
}
async fn resolve_fk_displays_pool(
schema: &'static ModelSchema,
pool: &PgPool,
object_list: &mut [Value],
) {
let lookups = collect_fk_target_lookups(schema, object_list);
for fk in lookups {
let map = match fetch_fk_display_map_pool(&fk, pool).await {
Ok(m) => m,
Err(e) => {
tracing::debug!(
target: "rustango::template_views",
field = fk.local_field,
target_table = fk.target_table,
error = %e,
"fk display lookup failed; templates fall back to raw FK"
);
continue;
}
};
stamp_display_into_rows(&fk, &map, object_list);
}
}
#[cfg(feature = "tenancy")]
async fn resolve_fk_displays_conn(
schema: &'static ModelSchema,
conn: &mut crate::sql::sqlx::PgConnection,
object_list: &mut [Value],
) {
let lookups = collect_fk_target_lookups(schema, object_list);
for fk in lookups {
let map = match fetch_fk_display_map_conn(&fk, conn).await {
Ok(m) => m,
Err(e) => {
tracing::debug!(
target: "rustango::template_views",
field = fk.local_field,
target_table = fk.target_table,
error = %e,
"fk display lookup failed (tenant); templates fall back to raw FK"
);
continue;
}
};
stamp_display_into_rows(&fk, &map, object_list);
}
}
struct FkLookup {
local_field: &'static str,
target_table: &'static str,
target_pk_column: &'static str,
target_display_column: &'static str,
target_display_field_name: &'static str,
target_display_field_type: crate::core::FieldType,
distinct_values: Vec<Value>,
}
fn collect_fk_target_lookups(schema: &'static ModelSchema, object_list: &[Value]) -> Vec<FkLookup> {
use crate::core::Relation;
let mut out = Vec::new();
for field in schema.scalar_fields() {
let Some(rel) = field.relation else { continue };
let (to, on) = match rel {
Relation::Fk { to, on } | Relation::O2O { to, on } => (to, on),
};
let Some(target) = lookup_target_schema(to) else {
continue;
};
let Some(display_field) = target.display_field() else {
continue;
};
let mut distinct: Vec<Value> = Vec::new();
for row in object_list {
let Some(val) = row.get(field.name) else {
continue;
};
if val.is_null() {
continue;
}
if !distinct.iter().any(|v| v == val) {
distinct.push(val.clone());
}
}
if distinct.is_empty() {
continue;
}
out.push(FkLookup {
local_field: field.name,
target_table: target.table,
target_pk_column: on,
target_display_column: display_field.column,
target_display_field_name: display_field.name,
target_display_field_type: display_field.ty,
distinct_values: distinct,
});
}
out
}
fn lookup_target_schema(table: &str) -> Option<&'static ModelSchema> {
crate::core::inventory::iter::<crate::core::ModelEntry>
.into_iter()
.find(|e| e.schema.table == table)
.map(|e| e.schema)
}
async fn fetch_fk_display_map_pool(
fk: &FkLookup,
pool: &PgPool,
) -> Result<HashMap<String, Value>, crate::sql::ExecError> {
let q = build_fk_display_query(fk);
let rows = select_rows(pool, &q).await?;
Ok(extract_fk_display_map(fk, &rows))
}
#[cfg(feature = "tenancy")]
async fn fetch_fk_display_map_conn(
fk: &FkLookup,
conn: &mut crate::sql::sqlx::PgConnection,
) -> Result<HashMap<String, Value>, crate::sql::ExecError> {
let q = build_fk_display_query(fk);
let rows = crate::sql::select_rows_on(conn, &q).await?;
Ok(extract_fk_display_map(fk, &rows))
}
fn build_fk_display_query(fk: &FkLookup) -> SelectQuery {
use crate::core::{Filter, Op};
let target = lookup_target_schema(fk.target_table)
.expect("target table existed when collecting lookups");
SelectQuery {
model: target,
where_clause: WhereExpr::Predicate(Filter {
column: fk.target_pk_column,
op: Op::In,
value: SqlValue::List(
fk.distinct_values
.iter()
.map(json_value_to_sql_for_fk_pk)
.collect(),
),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
}
}
fn json_value_to_sql_for_fk_pk(v: &Value) -> SqlValue {
match v {
Value::Number(n) => {
if let Some(i) = n.as_i64() {
SqlValue::I64(i)
} else if let Some(u) = n.as_u64() {
SqlValue::I64(u as i64)
} else {
SqlValue::String(n.to_string())
}
}
Value::String(s) => {
if let Ok(u) = uuid::Uuid::parse_str(s) {
SqlValue::Uuid(u)
} else {
SqlValue::String(s.clone())
}
}
_ => SqlValue::Null,
}
}
fn extract_fk_display_map(
fk: &FkLookup,
rows: &[crate::sql::sqlx::postgres::PgRow],
) -> HashMap<String, Value> {
use crate::core::FieldType;
use crate::sql::sqlx::Row as _;
let mut map = HashMap::new();
for row in rows {
let key: Option<String> = read_pk_as_string(row, fk.target_pk_column);
let Some(key) = key else { continue };
let display_val: Value = match fk.target_display_field_type {
FieldType::String => row
.try_get::<Option<String>, _>(fk.target_display_column)
.unwrap_or(None)
.map(Value::String)
.unwrap_or(Value::Null),
FieldType::I64 => row
.try_get::<Option<i64>, _>(fk.target_display_column)
.unwrap_or(None)
.map(|n| Value::Number(n.into()))
.unwrap_or(Value::Null),
FieldType::I32 => row
.try_get::<Option<i32>, _>(fk.target_display_column)
.unwrap_or(None)
.map(|n| Value::Number(n.into()))
.unwrap_or(Value::Null),
FieldType::Uuid => row
.try_get::<Option<uuid::Uuid>, _>(fk.target_display_column)
.unwrap_or(None)
.map(|u| Value::String(u.to_string()))
.unwrap_or(Value::Null),
_ => row
.try_get::<Option<String>, _>(fk.target_display_column)
.unwrap_or(None)
.map(Value::String)
.unwrap_or(Value::Null),
};
map.insert(key, display_val);
}
let _ = fk.target_display_field_name; map
}
fn read_pk_as_string(row: &crate::sql::sqlx::postgres::PgRow, column: &str) -> Option<String> {
use crate::sql::sqlx::Row as _;
if let Ok(Some(s)) = row.try_get::<Option<String>, _>(column) {
return Some(s);
}
if let Ok(Some(n)) = row.try_get::<Option<i64>, _>(column) {
return Some(n.to_string());
}
if let Ok(Some(n)) = row.try_get::<Option<i32>, _>(column) {
return Some(n.to_string());
}
if let Ok(Some(n)) = row.try_get::<Option<i16>, _>(column) {
return Some(n.to_string());
}
if let Ok(Some(u)) = row.try_get::<Option<uuid::Uuid>, _>(column) {
return Some(u.to_string());
}
None
}
fn json_value_as_lookup_key(v: &Value) -> Option<String> {
match v {
Value::Number(n) => Some(n.to_string()),
Value::String(s) => Some(s.clone()),
_ => None,
}
}
fn stamp_display_into_rows(fk: &FkLookup, map: &HashMap<String, Value>, object_list: &mut [Value]) {
let display_key = format!("{}_display", fk.local_field);
for row in object_list.iter_mut() {
let Some(obj) = row.as_object_mut() else {
continue;
};
let Some(fk_val) = obj.get(fk.local_field) else {
continue;
};
let Some(key) = json_value_as_lookup_key(fk_val) else {
continue;
};
if let Some(display) = map.get(&key) {
obj.insert(display_key.clone(), display.clone());
}
}
}
fn is_form_confirmed(form: &HashMap<String, Vec<String>>) -> bool {
form.get("confirmed")
.and_then(|v| v.first())
.map(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on"))
.unwrap_or(false)
}
fn confirm_delete_template_name(vs: &ListView) -> String {
vs.confirm_delete_template
.clone()
.unwrap_or_else(|| format!("{}_confirm_bulk_delete.html", vs.schema.table))
}
fn render_bulk_delete_confirm(
tera: &Tera,
template_name: String,
action: &str,
pks: &[String],
objects: &[Value],
headers: &axum::http::HeaderMap,
) -> Response {
let mut ctx = Context::new();
ctx.insert("action", action);
ctx.insert("pks", &pks);
ctx.insert("objects", &objects);
let set_cookie = stamp_csrf(headers, &mut ctx);
let mut resp = render(tera, &template_name, &ctx);
apply_csrf_cookie(&mut resp, set_cookie);
resp
}
async fn fetch_pks_as_objects_pool(
schema: &'static ModelSchema,
pk_field: &'static crate::core::FieldSchema,
pool: &PgPool,
pks: &[SqlValue],
) -> Result<Vec<Value>, String> {
use crate::core::{Filter, Op};
let q = SelectQuery {
model: schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pks.to_vec()),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
};
let rows = select_rows(pool, &q).await.map_err(|e| e.to_string())?;
let fields: Vec<&'static crate::core::FieldSchema> = schema.scalar_fields().collect();
Ok(rows.iter().map(|r| row_to_json(r, &fields)).collect())
}
#[cfg(feature = "tenancy")]
async fn fetch_pks_as_objects_conn(
schema: &'static ModelSchema,
pk_field: &'static crate::core::FieldSchema,
conn: &mut crate::sql::sqlx::PgConnection,
pks: &[SqlValue],
) -> Result<Vec<Value>, String> {
use crate::core::{Filter, Op};
let q = SelectQuery {
model: schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pks.to_vec()),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
};
let rows = crate::sql::select_rows_on(conn, &q)
.await
.map_err(|e| e.to_string())?;
let fields: Vec<&'static crate::core::FieldSchema> = schema.scalar_fields().collect();
Ok(rows.iter().map(|r| row_to_json(r, &fields)).collect())
}
async fn run_delete_selected_pool(
schema: &'static ModelSchema,
pk_field: &'static crate::core::FieldSchema,
pool: &PgPool,
pks: &[SqlValue],
) -> Result<(), String> {
use crate::core::{DeleteQuery, Filter, Op};
let q = DeleteQuery {
model: schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pks.to_vec()),
}),
};
crate::sql::delete(pool, &q)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(feature = "tenancy")]
async fn run_delete_selected_conn(
schema: &'static ModelSchema,
pk_field: &'static crate::core::FieldSchema,
conn: &mut crate::sql::sqlx::PgConnection,
pks: &[SqlValue],
) -> Result<(), String> {
use crate::core::{DeleteQuery, Filter, Op};
let q = DeleteQuery {
model: schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pks.to_vec()),
}),
};
crate::sql::delete_on(conn, &q)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
fn resolved_fields(
schema: &'static ModelSchema,
explicit: Option<&[String]>,
) -> Vec<&'static crate::core::FieldSchema> {
match explicit {
Some(names) => schema
.scalar_fields()
.filter(|f| names.iter().any(|n| n == f.name || n == f.column))
.collect(),
None => schema.scalar_fields().collect(),
}
}
fn render(tera: &Tera, name: &str, ctx: &Context) -> Response {
match tera.render(name, ctx) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::warn!(target: "rustango::template_views", template = %name, error = %e, "template render failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("template render error: {e}"),
)
.into_response()
}
}
}
fn template_error(msg: &str) -> Response {
tracing::warn!(target: "rustango::template_views", error = %msg, "template view error");
(StatusCode::INTERNAL_SERVER_ERROR, msg.to_owned()).into_response()
}
#[cfg(feature = "tenancy")]
mod tenant {
use super::*;
use crate::extractors::Tenant;
#[derive(Clone)]
pub(super) struct TenantListViewState {
pub(super) vs: ListView,
pub(super) tera: Arc<Tera>,
}
pub(super) async fn handle_list_tenant(
State(state): State<Arc<TenantListViewState>>,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
mut t: Tenant,
) -> Response {
let page: i64 = params
.get("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1)
.max(1);
let page_size =
super::resolve_page_size(state.vs.page_size, state.vs.max_page_size, ¶ms);
let offset = (page - 1) * page_size;
let (order_by, active_ordering) = match super::resolve_active_order(
state.vs.schema,
&state.vs.order_by,
&state.vs.ordering_fields,
¶ms,
) {
Ok(v) => v,
Err(msg) => return template_error(&msg),
};
let where_clause = build_list_where(
state.vs.schema,
&state.vs.filter_fields,
&state.vs.search_fields,
¶ms,
);
let select_q = SelectQuery {
model: state.vs.schema,
where_clause: where_clause.clone(),
search: None,
joins: vec![],
order_by,
limit: Some(page_size),
offset: Some(offset),
};
let count_q = crate::core::CountQuery {
model: state.vs.schema,
where_clause,
search: None,
};
let conn = t.conn();
let rows = match crate::sql::select_rows_on(&mut *conn, &select_q).await {
Ok(r) => r,
Err(e) => return template_error(&format!("query rows: {e}")),
};
let total = match crate::sql::count_rows_on(&mut *conn, &count_q).await {
Ok(c) => c,
Err(e) => return template_error(&format!("count rows: {e}")),
};
let fields = resolved_fields(state.vs.schema, state.vs.fields.as_deref());
let mut object_list: Vec<Value> = rows.iter().map(|r| row_to_json(r, &fields)).collect();
if state.vs.fk_display {
super::resolve_fk_displays_conn(state.vs.schema, conn, &mut object_list).await;
}
let total_pages = ((total - 1).max(0) / page_size) + 1;
let mut ctx = Context::new();
ctx.insert("object_list", &object_list);
ctx.insert("page", &page);
ctx.insert("page_size", &page_size);
ctx.insert("total", &total);
ctx.insert("total_pages", &total_pages);
let has_next = page < total_pages;
let has_prev = page > 1;
ctx.insert("has_next", &has_next);
ctx.insert("has_prev", &has_prev);
ctx.insert("ordering", &active_ordering);
super::insert_filter_context(&mut ctx, &state.vs.filter_fields, ¶ms);
super::insert_pagination_urls(&mut ctx, page, has_next, has_prev, ¶ms);
super::insert_bulk_actions_context(&mut ctx, &state.vs);
let set_cookie = super::stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.vs.template, &ctx);
super::apply_csrf_cookie(&mut resp, set_cookie);
resp
}
pub(super) async fn handle_list_action_tenant(
State(state): State<Arc<TenantListViewState>>,
mut t: Tenant,
req: axum::extract::Request,
) -> Response {
let (parts, body) = req.into_parts();
let form = match super::read_repeating_form(body).await {
Ok(f) => f,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
let (action, raws) = match super::parse_bulk_action_form(&form) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — bulk actions require one",
state.vs.schema.table
));
};
let pks = match super::coerce_selected_pks(pk_field, &raws) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
if state.vs.confirm_delete
&& action == super::BUILTIN_DELETE_SELECTED
&& !super::is_form_confirmed(&form)
{
let conn = t.conn();
let objects =
match super::fetch_pks_as_objects_conn(state.vs.schema, pk_field, conn, &pks).await
{
Ok(o) => o,
Err(e) => return template_error(&format!("fetch confirm rows: {e}")),
};
return super::render_bulk_delete_confirm(
&state.tera,
super::confirm_delete_template_name(&state.vs),
&action,
&raws,
&objects,
&parts.headers,
);
}
let dispatch_path = parts.uri.path().to_owned();
let conn = t.conn();
let result: Result<(), String> = if let Some(custom) = state
.vs
.actions
.iter()
.find(|a| super::same_action_name(&a.name, &action))
{
match &custom.handler {
super::BulkActionHandler::Tenant(f) => f(conn, &pks).await,
super::BulkActionHandler::Pool(_) => {
Err("this action was registered via .action(...) — \
mount the ListView via router(...) (single-pool) to dispatch it"
.into())
}
}
} else if action == super::BUILTIN_DELETE_SELECTED {
super::run_delete_selected_conn(state.vs.schema, pk_field, conn, &pks).await
} else {
return (
StatusCode::BAD_REQUEST,
format!("unknown action `{action}`"),
)
.into_response();
};
match result {
Ok(()) => axum::response::Redirect::to(&dispatch_path).into_response(),
Err(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
}
}
#[derive(Clone)]
pub(super) struct TenantDetailViewState {
pub(super) vs: DetailView,
pub(super) tera: Arc<Tera>,
}
pub(super) async fn handle_detail_tenant(
State(state): State<Arc<TenantDetailViewState>>,
Path(pk): Path<String>,
mut t: Tenant,
) -> Response {
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — DetailView can't probe by PK",
state.vs.schema.table
));
};
let select_q = SelectQuery {
model: state.vs.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: Some(1),
offset: None,
};
let row = match crate::sql::select_one_row_on(&mut *t.conn(), &select_q).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => return template_error(&format!("query row: {e}")),
};
let fields = resolved_fields(state.vs.schema, state.vs.fields.as_deref());
let object = row_to_json(&row, &fields);
let mut ctx = Context::new();
ctx.insert("object", &object);
render(&state.tera, &state.vs.template, &ctx)
}
#[derive(Clone)]
pub(super) struct TenantDeleteViewState {
pub(super) vs: DeleteView,
pub(super) tera: Arc<Tera>,
}
pub(super) async fn handle_delete_confirm_tenant(
State(state): State<Arc<TenantDeleteViewState>>,
Path(pk): Path<String>,
headers: axum::http::HeaderMap,
mut t: Tenant,
) -> Response {
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — DeleteView can't probe by PK",
state.vs.schema.table
));
};
let select_q = SelectQuery {
model: state.vs.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: Some(1),
offset: None,
};
let row = match crate::sql::select_one_row_on(&mut *t.conn(), &select_q).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => return template_error(&format!("query row: {e}")),
};
let fields = resolved_fields(state.vs.schema, state.vs.fields.as_deref());
let object = row_to_json(&row, &fields);
let mut ctx = Context::new();
ctx.insert("object", &object);
let set_cookie = super::stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.vs.template, &ctx);
super::apply_csrf_cookie(&mut resp, set_cookie);
resp
}
pub(super) async fn handle_delete_submit_tenant(
State(state): State<Arc<TenantDeleteViewState>>,
Path(pk): Path<String>,
mut t: Tenant,
) -> Response {
let Some(pk_field) = state.vs.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — DeleteView can't delete by PK",
state.vs.schema.table
));
};
let delete_q = crate::core::DeleteQuery {
model: state.vs.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
};
match crate::sql::delete_on(&mut *t.conn(), &delete_q).await {
Ok(0) => (StatusCode::NOT_FOUND, "not found").into_response(),
Ok(_) => {
let target = super::substitute_pk(&state.vs.success_url, &pk);
axum::response::Redirect::to(&target).into_response()
}
Err(e) => template_error(&format!("delete row: {e}")),
}
}
#[derive(Clone)]
pub(super) struct TenantFormViewState {
pub(super) schema: &'static ModelSchema,
pub(super) template: String,
pub(super) success_url: String,
pub(super) fields: Option<Vec<String>>,
pub(super) tera: Arc<Tera>,
pub(super) validator: Option<super::Validator>,
}
pub(super) async fn handle_create_get_tenant(
State(state): State<Arc<TenantFormViewState>>,
headers: axum::http::HeaderMap,
) -> Response {
let mut ctx = Context::new();
let fields = form_fields(state.schema, state.fields.as_deref(), &HashMap::new());
ctx.insert(
"form",
&serde_json::json!({"fields": fields, "errors": serde_json::Map::new()}),
);
ctx.insert("is_create", &true);
ctx.insert("is_update", &false);
let set_cookie = super::stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.template, &ctx);
super::apply_csrf_cookie(&mut resp, set_cookie);
resp
}
pub(super) async fn handle_create_post_tenant(
State(state): State<Arc<TenantFormViewState>>,
headers: axum::http::HeaderMap,
mut t: Tenant,
axum::Form(form): axum::Form<HashMap<String, String>>,
) -> Response {
let (columns, values, mut errors) =
parse_form(state.schema, state.fields.as_deref(), &form);
super::merge_validator_errors(state.validator.as_ref(), &form, &mut errors);
if !errors.is_empty() {
return rerender_form_tenant(
&state, &form, &errors, false, &headers,
);
}
let returning = match super::success_url_returning_columns(&state.success_url, state.schema)
{
Ok(cols) => cols,
Err(e) => return template_error(&e),
};
let need_returning = !returning.is_empty();
let insert_q = crate::core::InsertQuery {
model: state.schema,
columns,
values,
returning,
on_conflict: None,
};
let target_url = if need_returning {
match crate::sql::insert_returning_on(&mut *t.conn(), &insert_q).await {
Ok(row) => {
match super::interpolate_success_url(&state.success_url, &row, state.schema) {
Ok(url) => url,
Err(e) => return template_error(&e),
}
}
Err(e) => return template_error(&format!("insert row: {e}")),
}
} else {
if let Err(e) = crate::sql::insert_on(&mut *t.conn(), &insert_q).await {
return template_error(&format!("insert row: {e}"));
}
state.success_url.clone()
};
axum::response::Redirect::to(&target_url).into_response()
}
pub(super) async fn handle_update_get_tenant(
State(state): State<Arc<TenantFormViewState>>,
Path(pk): Path<String>,
headers: axum::http::HeaderMap,
mut t: Tenant,
) -> Response {
let Some(pk_field) = state.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — UpdateView can't probe by PK",
state.schema.table
));
};
let select_q = SelectQuery {
model: state.schema,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: Some(1),
offset: None,
};
let row = match crate::sql::select_one_row_on(&mut *t.conn(), &select_q).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => return template_error(&format!("query row: {e}")),
};
let scalars: Vec<&'static crate::core::FieldSchema> =
state.schema.scalar_fields().collect();
let row_json = row_to_json(&row, &scalars);
let row_obj = row_json.as_object().cloned().unwrap_or_default();
let mut values: HashMap<String, String> = HashMap::with_capacity(row_obj.len());
for (k, v) in row_obj {
let s = match v {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s,
other => other.to_string(),
};
values.insert(k, s);
}
let fields = form_fields(state.schema, state.fields.as_deref(), &values);
let mut ctx = Context::new();
ctx.insert(
"form",
&serde_json::json!({"fields": fields, "errors": serde_json::Map::new()}),
);
ctx.insert("object", &row_json);
ctx.insert("pk", &pk);
ctx.insert("is_create", &false);
ctx.insert("is_update", &true);
let set_cookie = super::stamp_csrf(&headers, &mut ctx);
let mut resp = render(&state.tera, &state.template, &ctx);
super::apply_csrf_cookie(&mut resp, set_cookie);
resp
}
pub(super) async fn handle_update_post_tenant(
State(state): State<Arc<TenantFormViewState>>,
Path(pk): Path<String>,
headers: axum::http::HeaderMap,
mut t: Tenant,
axum::Form(form): axum::Form<HashMap<String, String>>,
) -> Response {
let Some(pk_field) = state.schema.primary_key() else {
return template_error(&format!(
"model `{}` has no primary key — UpdateView can't update by PK",
state.schema.table
));
};
let (columns, values, mut errors) =
parse_form(state.schema, state.fields.as_deref(), &form);
super::merge_validator_errors(state.validator.as_ref(), &form, &mut errors);
if !errors.is_empty() {
return rerender_form_tenant(
&state, &form, &errors, true, &headers,
);
}
let assignments: Vec<crate::core::Assignment> = columns
.into_iter()
.zip(values)
.map(|(column, value)| crate::core::Assignment { column, value })
.collect();
let update_q = crate::core::UpdateQuery {
model: state.schema,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: coerce_pk(pk_field, &pk),
}),
};
match crate::sql::update_on(&mut *t.conn(), &update_q).await {
Ok(0) => (StatusCode::NOT_FOUND, "not found").into_response(),
Ok(_) => {
let target = super::substitute_pk(&state.success_url, &pk);
axum::response::Redirect::to(&target).into_response()
}
Err(e) => template_error(&format!("update row: {e}")),
}
}
fn rerender_form_tenant(
state: &TenantFormViewState,
submitted: &HashMap<String, String>,
errors: &HashMap<String, String>,
is_update: bool,
headers: &axum::http::HeaderMap,
) -> Response {
let fields = form_fields(state.schema, state.fields.as_deref(), submitted);
let mut ctx = Context::new();
ctx.insert(
"form",
&serde_json::json!({"fields": fields, "errors": errors}),
);
ctx.insert("is_create", &!is_update);
ctx.insert("is_update", &is_update);
let set_cookie = super::stamp_csrf(headers, &mut ctx);
let mut resp = render(&state.tera, &state.template, &ctx);
*resp.status_mut() = StatusCode::UNPROCESSABLE_ENTITY;
super::apply_csrf_cookie(&mut resp, set_cookie);
resp
}
}
#[cfg(feature = "tenancy")]
use tenant::{
handle_create_get_tenant, handle_create_post_tenant, handle_delete_confirm_tenant,
handle_delete_submit_tenant, handle_detail_tenant, handle_list_action_tenant,
handle_list_tenant, handle_update_get_tenant, handle_update_post_tenant, TenantDeleteViewState,
TenantDetailViewState, TenantFormViewState, TenantListViewState,
};
#[cfg(test)]
mod tests {
use super::*;
use crate::core::FieldType;
fn schema_two_fields() -> &'static ModelSchema {
Box::leak(Box::new(ModelSchema {
name: "Post",
table: "posts",
fields: Box::leak(Box::new([
crate::core::FieldSchema {
name: "id",
column: "id",
ty: FieldType::I64,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: true,
unique: false,
generated_as: None,
},
crate::core::FieldSchema {
name: "title",
column: "title",
ty: FieldType::String,
nullable: false,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
generated_as: None,
},
])),
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
permissions: false,
audit_track: None,
m2m: &[],
indexes: &[],
check_constraints: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
}))
}
fn schema_with_bounds() -> &'static ModelSchema {
Box::leak(Box::new(ModelSchema {
name: "Post",
table: "posts",
fields: Box::leak(Box::new([
crate::core::FieldSchema {
name: "id",
column: "id",
ty: FieldType::I64,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: true,
unique: false,
generated_as: None,
},
crate::core::FieldSchema {
name: "title",
column: "title",
ty: FieldType::String,
nullable: false,
primary_key: false,
relation: None,
max_length: Some(5),
min: None,
max: None,
default: None,
auto: false,
unique: false,
generated_as: None,
},
crate::core::FieldSchema {
name: "score",
column: "score",
ty: FieldType::I32,
nullable: false,
primary_key: false,
relation: None,
max_length: None,
min: Some(0),
max: Some(100),
default: None,
auto: false,
unique: false,
generated_as: None,
},
])),
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
permissions: false,
audit_track: None,
m2m: &[],
indexes: &[],
check_constraints: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
}))
}
#[test]
fn list_view_default_template_matches_table() {
let lv = ListView::for_model(schema_two_fields());
assert_eq!(lv.template, "posts_list.html");
assert_eq!(lv.page_size, 20);
}
#[test]
fn detail_view_default_template_matches_table() {
let dv = DetailView::for_model(schema_two_fields());
assert_eq!(dv.template, "posts_detail.html");
}
#[test]
fn list_view_builder_chains() {
let lv = ListView::for_model(schema_two_fields())
.template("custom.html")
.page_size(50)
.order_by("title", false)
.order_by("id", true)
.fields(&["id", "title"]);
assert_eq!(lv.template, "custom.html");
assert_eq!(lv.page_size, 50);
assert_eq!(lv.order_by.len(), 2);
assert_eq!(lv.fields.as_deref().map(<[String]>::len), Some(2));
}
#[test]
fn list_view_page_size_clamps_to_one() {
let lv = ListView::for_model(schema_two_fields()).page_size(0);
assert_eq!(lv.page_size, 1);
}
#[test]
fn resolve_order_by_accepts_field_or_column_name() {
let s = schema_two_fields();
let r = resolve_order_by(s, &[("title".into(), false)]).unwrap();
assert_eq!(r.len(), 1);
assert_eq!(r[0].column, "title");
assert!(!r[0].desc);
}
#[test]
fn resolve_order_by_rejects_unknown_field() {
let s = schema_two_fields();
let err = resolve_order_by(s, &[("nope".into(), false)]).unwrap_err();
assert!(err.contains("`nope`"), "got: {err}");
assert!(err.contains("posts"), "got: {err}");
}
#[test]
fn resolve_order_by_empty_falls_back_to_pk() {
let s = schema_two_fields();
let out = resolve_order_by(s, &[]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].column, "id");
assert!(!out[0].desc, "PK fallback is ASC");
}
#[test]
fn list_view_filter_and_search_chain() {
let lv = ListView::for_model(schema_two_fields())
.filter_fields(&["title"])
.search_fields(&["title"]);
assert_eq!(lv.filter_fields, vec!["title".to_owned()]);
assert_eq!(lv.search_fields, vec!["title".to_owned()]);
}
#[test]
fn build_list_where_empty_params_returns_empty_and() {
let s = schema_two_fields();
let where_clause = build_list_where(s, &["title".into()], &[], &HashMap::new());
match where_clause {
WhereExpr::And(v) => assert!(v.is_empty()),
other => panic!("expected empty And, got {other:?}"),
}
}
#[test]
fn build_list_where_filter_field_in_allowlist() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("title".to_owned(), "Hello".to_owned());
let where_clause = build_list_where(s, &["title".into()], &[], ¶ms);
match where_clause {
WhereExpr::Predicate(f) => {
assert_eq!(f.column, "title");
assert_eq!(f.op, Op::Eq);
}
other => panic!("expected single Predicate, got {other:?}"),
}
}
#[test]
fn build_list_where_unknown_field_ignored() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("category".to_owned(), "tech".to_owned()); let where_clause = build_list_where(s, &["title".into()], &[], ¶ms);
match where_clause {
WhereExpr::And(v) => assert!(v.is_empty()),
other => panic!("expected empty And, got {other:?}"),
}
}
#[test]
fn build_list_where_reserved_keys_skipped() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("page".to_owned(), "2".to_owned());
params.insert("page_size".to_owned(), "50".to_owned());
let where_clause = build_list_where(s, &["page".into(), "page_size".into()], &[], ¶ms);
match where_clause {
WhereExpr::And(v) => assert!(v.is_empty()),
other => panic!("expected empty And, got {other:?}"),
}
}
#[test]
fn build_list_where_search_single_field() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("search".to_owned(), "Hello".to_owned());
let where_clause = build_list_where(s, &[], &["title".into()], ¶ms);
match where_clause {
WhereExpr::Predicate(f) => {
assert_eq!(f.column, "title");
assert_eq!(f.op, Op::ILike);
if let SqlValue::String(p) = f.value {
assert!(p.contains("Hello"), "got: {p}");
assert!(p.starts_with('%') && p.ends_with('%'), "got: {p}");
} else {
panic!("expected SqlValue::String");
}
}
other => panic!("expected single Predicate, got {other:?}"),
}
}
#[test]
fn build_list_where_filter_plus_search_and_combined() {
let s = schema_with_bounds(); let mut params = HashMap::new();
params.insert("title".to_owned(), "Hello".to_owned()); params.insert("search".to_owned(), "world".to_owned()); let where_clause = build_list_where(
s,
&["title".into()],
&["title".into(), "score".into()],
¶ms,
);
match where_clause {
WhereExpr::And(branches) => {
assert_eq!(branches.len(), 2, "expected filter AND search");
}
other => panic!("expected And, got {other:?}"),
}
}
#[test]
fn escape_like_pattern_neutralizes_wildcards() {
assert_eq!(escape_like_pattern("100%"), r"100\%");
assert_eq!(escape_like_pattern("foo_bar"), r"foo\_bar");
assert_eq!(escape_like_pattern(r"a\b"), r"a\\b");
assert_eq!(escape_like_pattern("plain"), "plain");
}
#[test]
fn build_list_where_empty_search_param_skipped() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("search".to_owned(), String::new());
let where_clause = build_list_where(s, &[], &["title".into()], ¶ms);
match where_clause {
WhereExpr::And(v) => assert!(v.is_empty()),
other => panic!("expected empty And, got {other:?}"),
}
}
#[test]
fn insert_filter_context_stamps_active_values() {
let mut ctx = Context::new();
let mut params = HashMap::new();
params.insert("status".to_owned(), "published".to_owned());
params.insert("category".to_owned(), "tech".to_owned()); params.insert("search".to_owned(), "rustango".to_owned());
params.insert("page".to_owned(), "2".to_owned()); insert_filter_context(&mut ctx, &["status".into()], ¶ms);
let mut tera = Tera::default();
tera.add_raw_template(
"t",
"{{ filters.status }}|{{ search }}|{{ filters | length }}",
)
.unwrap();
let rendered = tera.render("t", &ctx).unwrap();
assert_eq!(rendered, "published|rustango|1");
}
#[cfg(feature = "csrf")]
#[test]
fn stamp_csrf_reuses_existing_cookie() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static("session=abc; rustango_csrf=existing-token"),
);
let mut ctx = Context::new();
let set_cookie = stamp_csrf(&headers, &mut ctx);
assert!(
set_cookie.is_none(),
"no Set-Cookie when cookie was present"
);
let mut tera = Tera::default();
tera.add_raw_template("t", "{{ csrf_token }}").unwrap();
let rendered = tera.render("t", &ctx).unwrap();
assert_eq!(rendered, "existing-token");
}
#[cfg(feature = "csrf")]
#[test]
fn stamp_csrf_mints_fresh_when_absent() {
let headers = axum::http::HeaderMap::new();
let mut ctx = Context::new();
let set_cookie = stamp_csrf(&headers, &mut ctx);
let cookie = set_cookie.expect("Set-Cookie returned when cookie absent");
assert!(cookie.starts_with("rustango_csrf="), "got: {cookie}");
let token_in_cookie = cookie
.split_once('=')
.and_then(|(_, rest)| rest.split(';').next())
.unwrap();
let mut tera = Tera::default();
tera.add_raw_template("t", "{{ csrf_token }}").unwrap();
let rendered = tera.render("t", &ctx).unwrap();
assert_eq!(rendered, token_in_cookie);
assert_eq!(rendered.len(), 43);
}
#[cfg(not(feature = "csrf"))]
#[test]
fn stamp_csrf_noop_when_feature_off() {
let headers = axum::http::HeaderMap::new();
let mut ctx = Context::new();
let set_cookie = stamp_csrf(&headers, &mut ctx);
assert!(set_cookie.is_none());
let mut tera = Tera::default();
tera.add_raw_template("t", "{{ csrf_token }}").unwrap();
assert_eq!(tera.render("t", &ctx).unwrap(), "");
}
#[test]
fn apply_csrf_cookie_appends_when_some() {
let mut resp = (StatusCode::OK, "ok").into_response();
apply_csrf_cookie(&mut resp, Some("rustango_csrf=tok; Path=/".into()));
let cookies: Vec<_> = resp
.headers()
.get_all(axum::http::header::SET_COOKIE)
.iter()
.collect();
assert_eq!(cookies.len(), 1);
assert!(cookies[0].to_str().unwrap().contains("rustango_csrf=tok"));
let mut resp = (StatusCode::OK, "ok").into_response();
apply_csrf_cookie(&mut resp, None);
assert!(resp.headers().get(axum::http::header::SET_COOKIE).is_none());
}
#[test]
fn insert_filter_context_empty_params_yields_empty_values() {
let mut ctx = Context::new();
insert_filter_context(&mut ctx, &["status".into()], &HashMap::new());
let mut tera = Tera::default();
tera.add_raw_template("t", "[{{ search }}][{{ filters | length }}]")
.unwrap();
let rendered = tera.render("t", &ctx).unwrap();
assert_eq!(rendered, "[][0]");
}
#[test]
fn default_order_by_empty_when_no_pk() {
let no_pk: &'static ModelSchema = Box::leak(Box::new(ModelSchema {
name: "Audit",
table: "audits",
fields: Box::leak(Box::new([crate::core::FieldSchema {
name: "msg",
column: "msg",
ty: FieldType::String,
nullable: false,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
generated_as: None,
}])),
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
permissions: false,
audit_track: None,
m2m: &[],
indexes: &[],
check_constraints: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
}));
assert!(default_order_by(no_pk).is_empty());
}
#[test]
fn resolved_fields_default_is_every_scalar() {
let s = schema_two_fields();
let fields = resolved_fields(s, None);
assert_eq!(fields.len(), 2);
}
#[test]
fn resolved_fields_explicit_filters() {
let s = schema_two_fields();
let names = ["title".to_owned()];
let fields = resolved_fields(s, Some(&names));
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].name, "title");
}
#[test]
fn delete_view_default_template_and_success_url() {
let dv = DeleteView::for_model(schema_two_fields());
assert_eq!(dv.template, "posts_confirm_delete.html");
assert_eq!(dv.success_url, "/");
}
#[test]
fn delete_view_builder_chains() {
let dv = DeleteView::for_model(schema_two_fields())
.template("custom_delete.html")
.success_url("/posts")
.fields(&["id", "title"]);
assert_eq!(dv.template, "custom_delete.html");
assert_eq!(dv.success_url, "/posts");
assert_eq!(dv.fields.as_deref().map(<[String]>::len), Some(2));
}
#[test]
fn create_view_defaults() {
let cv = CreateView::for_model(schema_two_fields());
assert_eq!(cv.template, "posts_form.html");
assert_eq!(cv.success_url, "/");
}
#[test]
fn update_view_default_template_matches_create() {
let uv = UpdateView::for_model(schema_two_fields());
assert_eq!(uv.template, "posts_form.html");
}
#[test]
fn form_fields_skips_pk_and_auto() {
let s = schema_two_fields();
let values = HashMap::new();
let ff = form_fields(s, None, &values);
assert_eq!(ff.len(), 1);
assert_eq!(ff[0].name, "title");
}
#[test]
fn form_fields_populates_value_from_row() {
let s = schema_two_fields();
let mut values = HashMap::new();
values.insert("title".to_owned(), "Hello".to_owned());
let ff = form_fields(s, None, &values);
assert_eq!(ff[0].value, "Hello");
}
#[test]
fn substitute_pk_replaces_placeholder() {
assert_eq!(substitute_pk("/posts/{pk}", "42"), "/posts/42");
assert_eq!(
substitute_pk("/posts/{pk}/edit", "abc-123"),
"/posts/abc-123/edit"
);
}
#[test]
fn substitute_pk_noop_when_no_placeholder() {
assert_eq!(substitute_pk("/posts", "42"), "/posts");
assert_eq!(substitute_pk("", "42"), "");
}
#[test]
fn substitute_pk_handles_multiple_occurrences() {
assert_eq!(substitute_pk("/{pk}/related/{pk}", "7"), "/7/related/7");
}
#[test]
fn parse_success_url_placeholders_extracts_valid_names() {
assert_eq!(parse_success_url_placeholders("/posts/{pk}"), vec!["pk"]);
assert_eq!(
parse_success_url_placeholders("/posts/{pk}/{slug}"),
vec!["pk", "slug"]
);
assert_eq!(parse_success_url_placeholders("/{}/{ok}"), vec!["ok"]);
assert_eq!(parse_success_url_placeholders("/{a-b}/{ok}"), vec!["ok"]);
assert!(parse_success_url_placeholders("/posts").is_empty());
assert!(parse_success_url_placeholders("").is_empty());
}
#[test]
fn success_url_returning_columns_resolves_pk_and_names() {
let s = schema_two_fields();
let cols = success_url_returning_columns("/posts/{pk}", s).unwrap();
assert_eq!(cols, vec!["id"]);
let cols = success_url_returning_columns("/posts/{pk}/{title}", s).unwrap();
assert_eq!(cols, vec!["id", "title"]);
assert!(success_url_returning_columns("/posts", s)
.unwrap()
.is_empty());
}
#[test]
fn success_url_returning_columns_rejects_unknown_placeholder() {
let s = schema_two_fields();
let err = success_url_returning_columns("/posts/{nope}", s).unwrap_err();
assert!(err.contains("`{nope}`"), "got: {err}");
assert!(err.contains("posts"), "got: {err}");
}
#[test]
fn interpolate_success_url_noop_when_no_placeholder() {
let template = "/posts";
assert!(!template.contains("{pk}"));
}
#[test]
fn coerce_pk_integer_field() {
let s = schema_two_fields();
let pk = s.primary_key().unwrap();
match coerce_pk(pk, "42") {
SqlValue::I64(n) => assert_eq!(n, 42),
other => panic!("expected I64, got {other:?}"),
}
}
#[test]
fn coerce_pk_integer_field_fallback_on_garbage() {
let s = schema_two_fields();
let pk = s.primary_key().unwrap();
match coerce_pk(pk, "not-a-number") {
SqlValue::String(raw) => assert_eq!(raw, "not-a-number"),
other => panic!("expected fallback String, got {other:?}"),
}
}
#[test]
fn coerce_pk_uuid_field() {
let uuid_schema: &'static ModelSchema = Box::leak(Box::new(ModelSchema {
name: "Doc",
table: "docs",
fields: Box::leak(Box::new([crate::core::FieldSchema {
name: "id",
column: "id",
ty: FieldType::Uuid,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
generated_as: None,
}])),
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
permissions: false,
audit_track: None,
m2m: &[],
indexes: &[],
check_constraints: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
}));
let pk = uuid_schema.primary_key().unwrap();
let raw = "550e8400-e29b-41d4-a716-446655440000";
match coerce_pk(pk, raw) {
SqlValue::Uuid(_) => {} other => panic!("expected Uuid, got {other:?}"),
}
match coerce_pk(pk, "not-a-uuid") {
SqlValue::String(s) => assert_eq!(s, "not-a-uuid"),
other => panic!("expected fallback String, got {other:?}"),
}
}
#[test]
fn coerce_pk_string_field() {
let str_schema: &'static ModelSchema = Box::leak(Box::new(ModelSchema {
name: "Slug",
table: "slugs",
fields: Box::leak(Box::new([crate::core::FieldSchema {
name: "slug",
column: "slug",
ty: FieldType::String,
nullable: false,
primary_key: true,
relation: None,
max_length: Some(64),
min: None,
max: None,
default: None,
auto: false,
unique: false,
generated_as: None,
}])),
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
permissions: false,
audit_track: None,
m2m: &[],
indexes: &[],
check_constraints: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
}));
let pk = str_schema.primary_key().unwrap();
match coerce_pk(pk, "hello-world") {
SqlValue::String(s) => assert_eq!(s, "hello-world"),
other => panic!("expected String, got {other:?}"),
}
}
#[test]
fn coerce_value_int_error_surfaces() {
let s = schema_two_fields();
let id = &s.fields[0];
let err = coerce_value(id, "not-a-number").unwrap_err();
assert!(err.contains("integer"), "got: {err}");
}
#[test]
fn coerce_value_empty_string_passes_through() {
let s = schema_two_fields();
let title = &s.fields[1];
let v = coerce_value(title, "").unwrap();
assert!(matches!(v, SqlValue::String(ref s) if s.is_empty()));
}
#[test]
fn parse_form_flags_required_missing() {
let s = schema_two_fields();
let submitted = HashMap::new();
let (cols, vals, errors) = parse_form(s, None, &submitted);
assert!(cols.is_empty());
assert!(vals.is_empty());
assert_eq!(errors.len(), 1);
assert!(errors.contains_key("title"));
}
#[test]
fn parse_form_accepts_valid_submission() {
let s = schema_two_fields();
let mut submitted = HashMap::new();
submitted.insert("title".to_owned(), "Hello".to_owned());
let (cols, vals, errors) = parse_form(s, None, &submitted);
assert!(errors.is_empty());
assert_eq!(cols, vec!["title"]);
assert_eq!(vals.len(), 1);
assert!(matches!(&vals[0], SqlValue::String(ref s) if s == "Hello"));
}
#[test]
fn field_type_label_covers_known_variants() {
use crate::core::FieldType as T;
assert_eq!(field_type_label(T::String), "string");
assert_eq!(field_type_label(T::I16), "i16");
assert_eq!(field_type_label(T::I32), "i32");
assert_eq!(field_type_label(T::I64), "i64");
assert_eq!(field_type_label(T::F32), "f32");
assert_eq!(field_type_label(T::F64), "f64");
assert_eq!(field_type_label(T::Bool), "bool");
assert_eq!(field_type_label(T::DateTime), "datetime");
assert_eq!(field_type_label(T::Date), "date");
assert_eq!(field_type_label(T::Uuid), "uuid");
assert_eq!(field_type_label(T::Json), "json");
}
#[test]
fn parse_form_enforces_max_length() {
let s = schema_with_bounds();
let mut submitted = HashMap::new();
submitted.insert("title".to_owned(), "way too long".to_owned()); submitted.insert("score".to_owned(), "50".to_owned());
let (cols, vals, errors) = parse_form(s, None, &submitted);
assert!(cols.is_empty() || !cols.contains(&"title"));
assert!(
vals.is_empty() || vals.len() == 1,
"title rejected, score still in"
);
let title_err = errors.get("title").expect("title error present");
assert!(
title_err.contains("5") && title_err.contains("12"),
"expected length detail, got: {title_err}"
);
assert!(!errors.contains_key("score"));
}
#[test]
fn parse_form_enforces_int_range() {
let s = schema_with_bounds();
let mut submitted = HashMap::new();
submitted.insert("title".to_owned(), "ok".to_owned());
submitted.insert("score".to_owned(), "150".to_owned()); let (_, _, errors) = parse_form(s, None, &submitted);
let score_err = errors.get("score").expect("score error present");
assert!(
score_err.contains("100") && score_err.contains("150"),
"expected range detail, got: {score_err}"
);
}
#[test]
fn bounds_error_message_strips_framing() {
use crate::core::QueryError;
let max = QueryError::MaxLengthExceeded {
model: "Post",
field: "title".into(),
max: 5,
actual: 12,
};
let msg = bounds_error_message(&max);
assert!(msg.contains("5") && msg.contains("12"), "got: {msg}");
assert!(!msg.contains("Post"), "should drop model framing: {msg}");
assert!(!msg.contains("title"), "should drop field framing: {msg}");
let range = QueryError::OutOfRange {
model: "Post",
field: "score".into(),
value: 150,
min: Some(0),
max: Some(100),
};
let msg = bounds_error_message(&range);
assert!(
msg.contains("0") && msg.contains("100") && msg.contains("150"),
"got: {msg}"
);
}
#[test]
fn bounds_error_message_one_sided_range() {
use crate::core::QueryError;
let only_min = QueryError::OutOfRange {
model: "X",
field: "n".into(),
value: -5,
min: Some(0),
max: None,
};
assert!(bounds_error_message(&only_min).contains("≥ 0"));
let only_max = QueryError::OutOfRange {
model: "X",
field: "n".into(),
value: 200,
min: None,
max: Some(100),
};
assert!(bounds_error_message(&only_max).contains("≤ 100"));
}
#[test]
fn urlencode_encodes_reserved_chars() {
assert_eq!(urlencode("hello world"), "hello%20world");
assert_eq!(urlencode("a&b=c"), "a%26b%3Dc");
assert_eq!(urlencode("plain"), "plain");
assert_eq!(urlencode("foo-bar.baz_~"), "foo-bar.baz_~");
}
#[test]
fn build_pagination_query_preserves_other_params() {
let mut params = HashMap::new();
params.insert("author_id".to_owned(), "42".to_owned());
params.insert("search".to_owned(), "hello world".to_owned());
params.insert("page".to_owned(), "1".to_owned()); let q = build_pagination_query(¶ms, 3);
assert_eq!(q, "?author_id=42&search=hello%20world&page=3");
}
#[test]
fn build_pagination_query_no_other_params() {
let params = HashMap::new();
assert_eq!(build_pagination_query(¶ms, 5), "?page=5");
}
#[test]
fn insert_pagination_urls_stamps_correct_directions() {
let mut ctx = Context::new();
let mut params = HashMap::new();
params.insert("status".to_owned(), "draft".to_owned());
insert_pagination_urls(
&mut ctx, 3, true, true, ¶ms,
);
let mut tera = Tera::default();
tera.add_raw_template("t", "{{ next_page_url }}|{{ prev_page_url }}")
.unwrap();
let rendered = tera.render("t", &ctx).unwrap();
assert_eq!(rendered, "?status=draft&page=4|?status=draft&page=2");
}
#[test]
fn insert_pagination_urls_first_page_no_prev() {
let mut ctx = Context::new();
let params = HashMap::new();
insert_pagination_urls(
&mut ctx, 1, true, false, ¶ms,
);
let mut tera = Tera::default();
tera.add_raw_template(
"t",
"{% if prev_page_url %}HAS_PREV{% else %}NO_PREV{% endif %}",
)
.unwrap();
assert_eq!(tera.render("t", &ctx).unwrap(), "NO_PREV");
}
#[test]
fn resolve_page_size_unset_returns_default() {
let params = HashMap::new();
assert_eq!(resolve_page_size(20, 100, ¶ms), 20);
let mut params = HashMap::new();
params.insert("page_size".into(), "garbage".into());
assert_eq!(resolve_page_size(20, 100, ¶ms), 20);
}
#[test]
fn resolve_page_size_clamps_to_range() {
let mut params = HashMap::new();
params.insert("page_size".into(), "0".into());
assert_eq!(resolve_page_size(20, 100, ¶ms), 1);
params.insert("page_size".into(), "-5".into());
assert_eq!(resolve_page_size(20, 100, ¶ms), 1);
params.insert("page_size".into(), "999999".into());
assert_eq!(resolve_page_size(20, 100, ¶ms), 100);
params.insert("page_size".into(), "50".into());
assert_eq!(resolve_page_size(20, 100, ¶ms), 50);
}
#[test]
fn resolve_active_order_url_override_asc() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("ordering".into(), "title".into());
let (clauses, active) = resolve_active_order(s, &[], &["title".into()], ¶ms).unwrap();
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].column, "title");
assert!(!clauses[0].desc);
assert_eq!(active, "title");
}
#[test]
fn resolve_active_order_url_override_desc_prefix() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("ordering".into(), "-title".into());
let (clauses, active) = resolve_active_order(s, &[], &["title".into()], ¶ms).unwrap();
assert_eq!(clauses[0].column, "title");
assert!(clauses[0].desc);
assert_eq!(active, "-title");
}
#[test]
fn resolve_active_order_url_override_outside_allowlist_falls_back() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("ordering".into(), "id".into()); let (_, active) = resolve_active_order(s, &[], &["title".into()], ¶ms).unwrap();
assert_eq!(active, "");
}
#[test]
fn resolve_active_order_no_url_uses_builder_default() {
let s = schema_two_fields();
let params = HashMap::new();
let (clauses, active) = resolve_active_order(s, &[], &["title".into()], ¶ms).unwrap();
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].column, "id");
assert!(!clauses[0].desc);
assert_eq!(active, "");
}
#[test]
fn resolve_active_order_empty_value_treated_as_no_override() {
let s = schema_two_fields();
let mut params = HashMap::new();
params.insert("ordering".into(), String::new());
let (_, active) = resolve_active_order(s, &[], &["title".into()], ¶ms).unwrap();
assert_eq!(active, "");
}
#[test]
fn merge_validator_no_errors_leaves_map_untouched() {
let v: Validator = Arc::new(|_data| Ok(()));
let mut errors: HashMap<String, String> = HashMap::new();
merge_validator_errors(Some(&v), &HashMap::new(), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn merge_validator_field_errors_land_under_field_key() {
let v: Validator = Arc::new(|_data| {
let mut e = crate::forms::FormErrors::default();
e.add("title", "must be at least 5 characters");
e.add("title", "must not contain whitespace");
e.add("body", "required");
Err(e)
});
let mut errors: HashMap<String, String> = HashMap::new();
merge_validator_errors(Some(&v), &HashMap::new(), &mut errors);
let title = errors.get("title").expect("title error");
assert!(title.contains("at least 5"), "got: {title}");
assert!(title.contains("whitespace"), "got: {title}");
assert_eq!(errors.get("body"), Some(&"required".to_owned()));
}
#[test]
fn merge_validator_non_field_errors_land_under_all_key() {
let v: Validator = Arc::new(|_data| {
let mut e = crate::forms::FormErrors::default();
e.add_non_field("password and confirm_password must match");
Err(e)
});
let mut errors: HashMap<String, String> = HashMap::new();
merge_validator_errors(Some(&v), &HashMap::new(), &mut errors);
let all = errors.get("__all__").expect("non-field error");
assert!(all.contains("must match"), "got: {all}");
}
#[test]
fn merge_validator_appends_to_existing_field_error() {
let v: Validator = Arc::new(|_data| {
let mut e = crate::forms::FormErrors::default();
e.add("title", "regex mismatch");
Err(e)
});
let mut errors: HashMap<String, String> = HashMap::new();
errors.insert("title".into(), "max_length 5 exceeded".into());
merge_validator_errors(Some(&v), &HashMap::new(), &mut errors);
let title = errors.get("title").unwrap();
assert!(title.contains("max_length"), "preserved: {title}");
assert!(title.contains("regex mismatch"), "appended: {title}");
}
#[test]
fn validator_and_form_builders_set_validator_field() {
let cv = CreateView::for_model(schema_two_fields()).validator(|_data| Ok(()));
assert!(
cv.validator.is_some(),
".validator(closure) must set the field"
);
let uv = UpdateView::for_model(schema_two_fields()).validator(|_data| Ok(()));
assert!(uv.validator.is_some());
struct Tiny;
impl crate::forms::Form for Tiny {
fn parse(_: &HashMap<String, String>) -> Result<Self, crate::forms::FormErrors> {
Ok(Tiny)
}
}
let cv2 = CreateView::for_model(schema_two_fields()).form::<Tiny>();
assert!(
cv2.validator.is_some(),
".form::<T>() must set the validator"
);
}
#[test]
fn bulk_actions_default_off_flag_flips_with_builder() {
let s = schema_two_fields();
let lv = ListView::for_model(s);
assert!(!lv.bulk_actions_enabled, "default off");
let lv2 = lv.bulk_actions(true);
assert!(lv2.bulk_actions_enabled, "true after .bulk_actions(true)");
}
#[test]
fn action_builder_dedupes_by_name() {
let s = schema_two_fields();
let h: BulkActionFn = Arc::new(|_pool, _pks| Box::pin(async { Ok(()) }));
let lv = ListView::for_model(s)
.action("publish", "Publish", h.clone())
.action("archive", "Archive", h.clone())
.action("publish", "Publish (renamed)", h);
assert_eq!(lv.actions.len(), 2);
let publish = lv.actions.iter().find(|a| a.name == "publish").unwrap();
assert_eq!(
publish.label, "Publish (renamed)",
"second .action with same name should replace"
);
}
#[test]
fn parse_bulk_action_form_requires_action_and_selection() {
let mut f: HashMap<String, Vec<String>> = HashMap::new();
f.insert("_selected_action".into(), vec!["1".into()]);
assert!(parse_bulk_action_form(&f).is_err());
let mut f: HashMap<String, Vec<String>> = HashMap::new();
f.insert("action".into(), vec!["delete_selected".into()]);
assert!(parse_bulk_action_form(&f).is_err());
let mut f: HashMap<String, Vec<String>> = HashMap::new();
f.insert("action".into(), vec!["delete_selected".into()]);
f.insert(
"_selected_action".into(),
vec!["1".into(), "2".into(), "3".into()],
);
let (action, pks) = parse_bulk_action_form(&f).unwrap();
assert_eq!(action, "delete_selected");
assert_eq!(pks, vec!["1", "2", "3"]);
}
#[test]
fn coerce_pk_typed_returns_correct_sqlvalue_per_type() {
use crate::core::FieldType;
let f = |ty: FieldType| {
Box::leak(Box::new(crate::core::FieldSchema {
name: "id",
column: "id",
ty,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
generated_as: None,
})) as &'static crate::core::FieldSchema
};
assert!(matches!(
coerce_pk_typed(f(FieldType::I64), "42"),
Ok(SqlValue::I64(42))
));
assert!(matches!(
coerce_pk_typed(f(FieldType::I32), "42"),
Ok(SqlValue::I32(42))
));
assert!(matches!(
coerce_pk_typed(f(FieldType::I16), "42"),
Ok(SqlValue::I16(42))
));
assert!(coerce_pk_typed(f(FieldType::I64), "not-a-number").is_err());
assert!(coerce_pk_typed(f(FieldType::Uuid), "not-a-uuid").is_err());
}
#[test]
fn bulk_actions_context_includes_built_in_then_user_actions() {
let s = schema_two_fields();
let h: BulkActionFn = Arc::new(|_p, _v| Box::pin(async { Ok(()) }));
let lv = ListView::for_model(s)
.bulk_actions(true)
.action("publish", "Publish", h);
let mut ctx = Context::new();
insert_bulk_actions_context(&mut ctx, &lv);
let v = ctx.into_json();
let arr = v["bulk_actions"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], serde_json::json!("delete_selected"));
assert_eq!(arr[0]["label"], serde_json::json!("Delete selected"));
assert_eq!(arr[1]["name"], serde_json::json!("publish"));
}
#[test]
fn with_delete_confirmation_flag_and_template_override() {
let s = schema_two_fields();
let lv = ListView::for_model(s);
assert!(!lv.confirm_delete, "default off");
assert!(lv.confirm_delete_template.is_none());
let lv2 = ListView::for_model(s).with_delete_confirmation(true);
assert!(lv2.confirm_delete);
assert!(
lv2.confirm_delete_template.is_none(),
"no override → resolves at request time"
);
let lv3 = ListView::for_model(s).with_delete_confirmation_template("custom.html");
assert!(
lv3.confirm_delete,
"with_delete_confirmation_template implies the flag"
);
assert_eq!(lv3.confirm_delete_template.as_deref(), Some("custom.html"));
}
#[test]
fn confirm_delete_template_name_resolves_default_or_override() {
let s = schema_two_fields();
let lv = ListView::for_model(s);
assert_eq!(
confirm_delete_template_name(&lv),
"posts_confirm_bulk_delete.html"
);
let lv2 = ListView::for_model(s).with_delete_confirmation_template("blog/confirm.html");
assert_eq!(confirm_delete_template_name(&lv2), "blog/confirm.html");
}
#[test]
fn with_fk_display_flag_default_off_then_on() {
let s = schema_two_fields();
let lv = ListView::for_model(s);
assert!(!lv.fk_display, "default off");
let lv2 = ListView::for_model(s).with_fk_display(true);
assert!(lv2.fk_display);
}
#[test]
fn json_value_as_lookup_key_handles_numbers_and_strings() {
assert_eq!(
json_value_as_lookup_key(&serde_json::json!(42)),
Some("42".to_string())
);
assert_eq!(
json_value_as_lookup_key(&serde_json::json!("550e8400-e29b-41d4-a716-446655440000")),
Some("550e8400-e29b-41d4-a716-446655440000".to_string())
);
assert_eq!(
json_value_as_lookup_key(&serde_json::json!(null)),
None,
"NULL FK has no lookup key"
);
assert_eq!(json_value_as_lookup_key(&serde_json::json!(true)), None);
}
#[test]
fn json_value_to_sql_for_fk_pk_round_trips_common_pk_types() {
match json_value_to_sql_for_fk_pk(&serde_json::json!(42)) {
SqlValue::I64(42) => {}
other => panic!("expected I64(42), got {other:?}"),
}
match json_value_to_sql_for_fk_pk(&serde_json::json!(
"550e8400-e29b-41d4-a716-446655440000"
)) {
SqlValue::Uuid(u) => assert_eq!(u.to_string(), "550e8400-e29b-41d4-a716-446655440000"),
other => panic!("expected Uuid, got {other:?}"),
}
match json_value_to_sql_for_fk_pk(&serde_json::json!("not-a-uuid")) {
SqlValue::String(s) => assert_eq!(s, "not-a-uuid"),
other => panic!("expected String, got {other:?}"),
}
}
#[test]
fn stamp_display_into_rows_writes_sibling_only_when_resolved() {
let fk = FkLookup {
local_field: "author_id",
target_table: "tv_fk_author",
target_pk_column: "id",
target_display_column: "name",
target_display_field_name: "name",
target_display_field_type: crate::core::FieldType::String,
distinct_values: vec![],
};
let mut rows = vec![
serde_json::json!({"id": 1, "title": "first", "author_id": 7}),
serde_json::json!({"id": 2, "title": "second", "author_id": 99}),
serde_json::json!({"id": 3, "title": "third", "author_id": null}),
];
let mut map: HashMap<String, serde_json::Value> = HashMap::new();
map.insert("7".into(), serde_json::json!("Alice"));
stamp_display_into_rows(&fk, &map, &mut rows);
assert_eq!(
rows[0]["author_id_display"],
serde_json::json!("Alice"),
"resolved FK gets display sibling"
);
assert!(
rows[1]
.as_object()
.unwrap()
.get("author_id_display")
.is_none(),
"missing target row → no sibling stamped: {:?}",
rows[1]
);
assert!(
rows[2]
.as_object()
.unwrap()
.get("author_id_display")
.is_none(),
"NULL FK → no sibling stamped: {:?}",
rows[2]
);
}
#[test]
fn is_form_confirmed_accepts_truthy_strings() {
let mk = |val: &str| {
let mut f: HashMap<String, Vec<String>> = HashMap::new();
f.insert("confirmed".into(), vec![val.into()]);
f
};
for truthy in ["true", "TRUE", "True", "1", "yes", "YES", "on", "On"] {
assert!(
is_form_confirmed(&mk(truthy)),
"expected {truthy:?} to read as confirmed"
);
}
for falsy in ["", "false", "0", "no", "off", "maybe", "anything-else"] {
assert!(
!is_form_confirmed(&mk(falsy)),
"expected {falsy:?} to read as NOT confirmed"
);
}
let empty: HashMap<String, Vec<String>> = HashMap::new();
assert!(!is_form_confirmed(&empty));
}
#[test]
fn bulk_actions_context_empty_when_disabled() {
let s = schema_two_fields();
let lv = ListView::for_model(s); let mut ctx = Context::new();
insert_bulk_actions_context(&mut ctx, &lv);
let v = ctx.into_json();
let arr = v["bulk_actions"].as_array().unwrap();
assert!(arr.is_empty());
}
#[cfg(feature = "tenancy")]
#[test]
fn tenant_routers_build_for_basic_model() {
let s = schema_two_fields();
let tera = Arc::new(Tera::default());
let _ = ListView::for_model(s)
.page_size(10)
.tenant_router("/posts", tera.clone());
let _ = DetailView::for_model(s).tenant_router("/posts", tera.clone());
let _ = DeleteView::for_model(s)
.success_url("/posts")
.tenant_router("/posts", tera.clone());
let _ = CreateView::for_model(s)
.success_url("/posts")
.tenant_router("/posts", tera.clone());
let _ = UpdateView::for_model(s)
.success_url("/posts")
.tenant_router("/posts", tera);
}
}