Skip to main content

rust_web_server/model_admin/
mod.rs

1//! Model admin (18.35.0, #109): list, search, show, create, edit and delete
2//! rows of the tables you register, as server-rendered pages — Django's
3//! admin, for a `DbPool` (`model-*` features). A middleware, so it works in
4//! front of sync and async apps alike.
5//!
6//! ```rust,no_run
7//! # async fn example(pool: rust_web_server::model::DbPool) {
8//! use rust_web_server::app::App;
9//! use rust_web_server::model_admin::ModelAdmin;
10//! # #[derive(rust_web_server::Model)] struct Post { #[primary_key(auto_increment)] id: i64, title: String }
11//!
12//! let admin = ModelAdmin::new(pool.clone())
13//!     .model::<Post>()                      // or .table("posts")
14//!     .allow(|request| is_admin(request));     // e.g. auth_flows::CurrentUser::of(request) is an admin
15//! // Inside the layers it needs (the first wrapped is the outermost):
16//! // FlashLayer, CsrfLayer, AuthFlows, then the admin. Pages under /admin.
17//! let app = App::with_async_state(pool).wrap(admin);
18//! # }
19//! # fn is_admin(_: &rust_web_server::request::Request) -> bool { false }
20//! ```
21//!
22//! **Nobody gets in until [`allow`](ModelAdmin::allow) says so**: without it
23//! every page answers `403`. Wrap it after your sign-in ([`AuthFlows`]),
24//! `FlashLayer` and [`CsrfLayer`](crate::csrf::CsrfLayer) — layers wrapped
25//! first are outside it and see its pages; the forms carry the CSRF field
26//! when that layer is there.
27//!
28//! The columns, their types and the primary key come from the database
29//! ([`DbPool::schema`](crate::model::DbPool::schema)), read when a page is
30//! drawn, so a migration shows at once. Form values are converted by the
31//! column's type (integers, floats, booleans as a checkbox, text; an
32//! empty field is `NULL` for a nullable column); `created_at`/`updated_at`
33//! columns are set to now when the form leaves them empty. What the
34//! database refuses (a `NOT NULL`, a unique index, a foreign key) comes
35//! back as an alert on the form. Tables need a one-column primary key to
36//! be edited; others are listed only.
37//!
38//! [`AuthFlows`]: crate::auth_flows::AuthFlows
39
40#[cfg(test)]
41mod tests;
42
43use std::collections::HashMap;
44use std::sync::Arc;
45
46use crate::application::Application;
47use crate::error::IntoResponse;
48use crate::flash::{Flash, Redirect};
49use crate::header::Header;
50use crate::middleware::Middleware;
51use crate::model::db_schema::{ColumnSchema, TableSchema};
52use crate::model::{Backend, DbPool, ModelRow, Value};
53use crate::request::Request;
54use crate::response::{Response, StatusCodeReasonPhrase, STATUS_CODE_REASON_PHRASE};
55use crate::server::ConnectionInfo;
56
57type Allow = Arc<dyn Fn(&Request) -> bool + Send + Sync>;
58
59/// Rows per page.
60pub const PAGE_SIZE: i64 = 25;
61
62/// The admin pages. See the [module docs](self).
63#[derive(Clone)]
64pub struct ModelAdmin {
65    pool: DbPool,
66    prefix: String,
67    tables: Vec<String>,
68    allow: Option<Allow>,
69    title: String,
70}
71
72impl ModelAdmin {
73    /// Pages for `pool`'s tables you register, under `/admin`.
74    pub fn new(pool: DbPool) -> Self {
75        ModelAdmin { pool, prefix: "/admin".into(), tables: Vec::new(), allow: None, title: "Admin".into() }
76    }
77
78    /// Where the pages live (default `/admin`).
79    pub fn prefix(mut self, prefix: &str) -> Self {
80        self.prefix = format!("/{}", prefix.trim_matches('/'));
81        self
82    }
83
84    /// The heading of every page (default "Admin").
85    pub fn title(mut self, title: &str) -> Self {
86        self.title = title.to_string();
87        self
88    }
89
90    /// Manage the table of `T`.
91    pub fn model<T: crate::model::Model>(self) -> Self {
92        self.table(T::table_name())
93    }
94
95    /// Manage `table` (letters, digits and `_` only; others are ignored).
96    pub fn table(mut self, table: &str) -> Self {
97        if !table.is_empty() && table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !self.tables.iter().any(|t| t == table) {
98            self.tables.push(table.to_string());
99        }
100        self
101    }
102
103    /// Who may use the pages: `check(request)` runs for each request
104    /// (after the layers outside this one, such as `AuthFlows`). Without
105    /// it, nobody.
106    pub fn allow(mut self, check: impl Fn(&Request) -> bool + Send + Sync + 'static) -> Self {
107        self.allow = Some(Arc::new(check));
108        self
109    }
110
111    fn run<T: Send + 'static>(&self, work: impl std::future::Future<Output = Result<T, crate::model::DbError>> + Send + 'static) -> Result<T, String> {
112        rws_server::async_bridge::block_on_shared(work).map_err(|e| e.to_string())
113    }
114
115    fn schema(&self, table: &str) -> Result<Option<TableSchema>, String> {
116        let pool = self.pool.clone();
117        let schema = self.run(async move { pool.schema().await })?;
118        Ok(schema.tables.into_iter().find(|t| t.name.eq_ignore_ascii_case(table)))
119    }
120
121    fn route(&self, request: &Request) -> Option<Response> {
122        let path = request.request_uri.split('?').next().unwrap_or("");
123        let rest = path.strip_prefix(&self.prefix)?;
124        if !(rest.is_empty() || rest.starts_with('/')) {
125            return None;
126        }
127        if !self.allow.as_ref().is_some_and(|allow| allow(request)) {
128            return Some(crate::error::problem::problem(STATUS_CODE_REASON_PHRASE.n403_forbidden, "not allowed"));
129        }
130        let parts: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
131        let get = request.method == "GET" || request.method == "HEAD";
132        let post = request.method == "POST";
133        let page = Page { admin: self, request };
134        let answer = match (parts.as_slice(), get, post) {
135            ([], true, _) => page.index(),
136            ([table], true, _) => page.with_table(table, |p, t| p.list(t)),
137            ([table], _, true) => page.with_table(table, |p, t| p.save(t, None)),
138            ([table, "new"], true, _) => page.with_table(table, |p, t| p.form(t, None, None)),
139            ([table, id], true, _) => page.with_table(table, |p, t| p.show(t, id)),
140            ([table, id, "edit"], true, _) => page.with_table(table, |p, t| p.edit(t, id)),
141            ([table, id], _, true) => page.with_table(table, |p, t| p.save(t, Some(id))),
142            ([table, id, "delete"], _, true) => page.with_table(table, |p, t| p.delete(t, id)),
143            _ => Err((STATUS_CODE_REASON_PHRASE.n404_not_found, "no such admin page".to_string())),
144        };
145        Some(answer.unwrap_or_else(|(status, message)| page.error(status, &message)))
146    }
147}
148
149impl Middleware for ModelAdmin {
150    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
151        match self.route(request) {
152            Some(response) => Ok(response),
153            None => next.execute(request, connection),
154        }
155    }
156
157    fn is_passthrough(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
158        !request.request_uri.starts_with(&self.prefix)
159    }
160
161    fn accepts_streamed_body(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
162        !request.request_uri.starts_with(&self.prefix)
163    }
164
165    fn layer_name(&self) -> &'static str {
166        "ModelAdmin"
167    }
168}
169
170type PageResult = Result<Response, (&'static StatusCodeReasonPhrase, String)>;
171
172fn internal(e: String) -> (&'static StatusCodeReasonPhrase, String) {
173    eprintln!("[model admin] {e}");
174    (STATUS_CODE_REASON_PHRASE.n500_internal_server_error, e)
175}
176
177/// One request's page.
178struct Page<'a> {
179    admin: &'a ModelAdmin,
180    request: &'a Request,
181}
182
183/// How a column's values are read from a form and bound.
184#[derive(Clone, Copy, PartialEq, Eq, Debug)]
185enum Kind {
186    Int,
187    Float,
188    Bool,
189    Timestamp,
190    Text,
191    /// A type bound as text and cast by PostgreSQL (`uuid`, `date`, …).
192    Other,
193}
194
195fn kind(column: &ColumnSchema) -> Kind {
196    kind_of(&column.data_type)
197}
198
199fn kind_of(data_type: &str) -> Kind {
200    let t = data_type.to_ascii_lowercase();
201    if t.contains("bool") || t == "tinyint(1)" {
202        Kind::Bool
203    } else if t.contains("int") || t == "serial" || t == "bigserial" {
204        Kind::Int
205    } else if t.contains("real") || t.contains("double") || t.contains("float") || t.contains("numeric") || t.contains("decimal") {
206        Kind::Float
207    } else if t.contains("timestamp") || t.contains("datetime") {
208        Kind::Timestamp
209    } else if t.contains("char") || t.contains("text") || t.is_empty() || t == "string" {
210        Kind::Text
211    } else {
212        Kind::Other
213    }
214}
215
216fn quote(backend: Backend, name: &str) -> String {
217    match backend {
218        #[cfg(feature = "model-mysql")]
219        Backend::MySql => format!("`{}`", name.replace('`', "")),
220        #[allow(unreachable_patterns)]
221        _ => format!("\"{}\"", name.replace('"', "")),
222    }
223}
224
225fn placeholder(backend: Backend, n: usize, column: &ColumnSchema) -> String {
226    match backend {
227        #[cfg(feature = "model-postgres")]
228        Backend::Postgres => match kind(column) {
229            Kind::Timestamp | Kind::Other => format!("CAST(${n} AS {})", column.data_type),
230            _ => format!("${n}"),
231        },
232        #[allow(unreachable_patterns)]
233        _ => {
234            let _ = (n, column);
235            "?".to_string()
236        }
237    }
238}
239
240fn escape(text: &str) -> String {
241    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
242}
243
244fn display(value: &Value) -> String {
245    match value {
246        Value::Null => String::new(),
247        Value::Bool(b) => b.to_string(),
248        Value::Int(n) => n.to_string(),
249        Value::Float(f) => f.to_string(),
250        Value::Text(s) => s.clone(),
251        Value::Bytes(b) => format!("({} bytes)", b.len()),
252    }
253}
254
255fn cell(row: &ModelRow, column: &str) -> Value {
256    row.columns.iter().find(|(c, _)| c.eq_ignore_ascii_case(column)).map(|(_, v)| v.clone()).unwrap_or(Value::Null)
257}
258
259/// `value` (from a form or a URL) as `column`'s value; `Err` names what's wrong.
260fn parse(column: &ColumnSchema, value: Option<&str>) -> Result<Value, String> {
261    parse_as(&column.name, &column.data_type, column.nullable, value)
262}
263
264fn parse_as(name: &str, data_type: &str, nullable: bool, value: Option<&str>) -> Result<Value, String> {
265    let k = kind_of(data_type);
266    if k == Kind::Bool {
267        // Every backend binds a bool (SQLite and MySQL as 0/1).
268        return Ok(Value::Bool(matches!(value, Some("on" | "true" | "1"))));
269    }
270    let text = value.unwrap_or("").trim();
271    if text.is_empty() {
272        return if nullable { Ok(Value::Null) } else if k == Kind::Text { Ok(Value::Text(String::new())) } else { Err(format!("{} is required", name)) };
273    }
274    match k {
275        Kind::Int => text.parse().map(Value::Int).map_err(|_| format!("{} must be a whole number", name)),
276        Kind::Float => text.parse().map(Value::Float).map_err(|_| format!("{} must be a number", name)),
277        _ => Ok(Value::Text(value.unwrap_or("").to_string())),
278    }
279}
280
281impl<'a> Page<'a> {
282    fn backend(&self) -> Backend {
283        self.admin.pool.backend()
284    }
285
286    fn with_table(&self, table: &str, f: impl FnOnce(&Self, &TableSchema) -> PageResult) -> PageResult {
287        if !self.admin.tables.iter().any(|t| t == table) {
288            return Err((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("{table} isn't managed here")));
289        }
290        match self.admin.schema(table).map_err(internal)? {
291            Some(schema) => f(self, &schema),
292            None => Err((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("the database has no table {table}"))),
293        }
294    }
295
296    fn pk<'t>(&self, table: &'t TableSchema) -> Option<&'t ColumnSchema> {
297        match table.primary_key.as_slice() {
298            [one] => table.column(one),
299            _ => None,
300        }
301    }
302
303    fn query(&self, sql: String, params: Vec<Value>) -> Result<Vec<ModelRow>, String> {
304        let pool = self.admin.pool.clone();
305        self.admin.run(async move { pool.query_rows(&sql, &params).await })
306    }
307
308    fn execute(&self, sql: String, params: Vec<Value>) -> Result<u64, String> {
309        let pool = self.admin.pool.clone();
310        self.admin.run(async move { pool.execute(&sql, &params).await })
311    }
312
313    fn url(&self, parts: &[&str]) -> String {
314        let mut url = self.admin.prefix.clone();
315        for part in parts {
316            url.push('/');
317            url.push_str(&crate::router::build_path("/{x}", &[("x", part)]).unwrap_or_default()[1..]);
318        }
319        url
320    }
321
322    fn query_param(&self, name: &str) -> String {
323        let query = self.request.request_uri.split_once('?').map(|(_, q)| q).unwrap_or("");
324        form_fields(query).remove(name).unwrap_or_default()
325    }
326
327    fn html(&self, status: &StatusCodeReasonPhrase, heading: &str, body: &str) -> Response {
328        let tables: String = self.admin.tables.iter().map(|t| format!("<a href=\"{}\">{}</a>", escape(&self.url(&[t])), escape(t))).collect::<Vec<_>>().join(" ");
329        let flash: String = Flash::read(self.request).messages().iter().map(|m| format!("<p class=\"{}\">{}</p>", escape(&m.kind), escape(&m.text))).collect();
330        let page = format!(
331            "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{heading} · {title}</title>\
332             <style>body{{font:15px system-ui,sans-serif;margin:0;color:#1d1b20}}header{{background:#1d1b20;color:#fff;padding:.75rem 1rem}}header a{{color:#cfc;margin-right:1rem}}main{{padding:1rem;max-width:72rem}}\
333             table{{border-collapse:collapse;width:100%}}td,th{{border-bottom:1px solid #ddd;padding:.4rem .5rem;text-align:left;vertical-align:top;max-width:24rem;overflow-wrap:anywhere}}\
334             label{{display:block;margin:.6rem 0}}input[type=text],textarea{{display:block;width:100%;max-width:36rem;padding:.4rem;box-sizing:border-box}}.alert{{color:#b00020}}.notice{{color:#1b5e20}}button{{padding:.4rem .9rem}}form.inline{{display:inline}}</style></head>\
335             <body><header><a href=\"{home}\"><strong>{title}</strong></a>{tables}</header><main><h1>{heading}</h1>{flash}{body}</main></body></html>",
336            title = escape(&self.admin.title),
337            home = escape(&self.admin.prefix),
338            heading = escape(heading),
339        );
340        let content = crate::range::Range::get_content_range(page.into_bytes(), crate::mime_type::MimeType::TEXT_HTML.to_string());
341        let mut response = Response::get_response(status, None, Some(vec![content]));
342        response.headers.push(Header::new(Header::_CACHE_CONTROL, "no-store"));
343        response
344    }
345
346    fn error(&self, status: &'static StatusCodeReasonPhrase, message: &str) -> Response {
347        self.html(status, &format!("{} {}", status.status_code, status.reason_phrase), &format!("<p>{}</p>", escape(message)))
348    }
349
350    fn csrf(&self) -> String {
351        #[cfg(feature = "csrf")]
352        if let Some(token) = crate::csrf::CsrfToken::from_request(self.request) {
353            return format!("<input type=\"hidden\" name=\"_csrf\" value=\"{}\">", escape(token.value()));
354        }
355        String::new()
356    }
357
358    fn index(&self) -> PageResult {
359        let mut rows = String::new();
360        for table in &self.admin.tables {
361            let count = match self.query(format!("SELECT COUNT(*) AS n FROM {}", quote(self.backend(), table)), Vec::new()) {
362                Ok(r) => r.first().map(|r| display(&cell(r, "n"))).unwrap_or_default(),
363                Err(_) => "(missing)".into(),
364            };
365            rows.push_str(&format!("<tr><td><a href=\"{}\">{}</a></td><td>{}</td></tr>", escape(&self.url(&[table])), escape(table), escape(&count)));
366        }
367        Ok(self.html(STATUS_CODE_REASON_PHRASE.n200_ok, &self.admin.title, &format!("<table><tr><th>Table</th><th>Rows</th></tr>{rows}</table>")))
368    }
369
370    fn list(&self, table: &TableSchema) -> PageResult {
371        let backend = self.backend();
372        let page: i64 = self.query_param("page").parse().unwrap_or(1).max(1);
373        let q = self.query_param("q");
374        let text_columns: Vec<&ColumnSchema> = table.columns.iter().filter(|c| kind(c) == Kind::Text).collect();
375        let (mut filter, mut params) = (String::new(), Vec::new());
376        if !q.is_empty() && !text_columns.is_empty() {
377            let conditions: Vec<String> = text_columns
378                .iter()
379                .enumerate()
380                .map(|(i, c)| format!("LOWER({}) LIKE LOWER({})", quote(backend, &c.name), placeholder(backend, i + 1, c)))
381                .collect();
382            filter = format!(" WHERE {}", conditions.join(" OR "));
383            params = text_columns.iter().map(|_| Value::Text(format!("%{q}%"))).collect();
384        }
385        let order = self.pk(table).map(|pk| format!(" ORDER BY {} DESC", quote(backend, &pk.name))).unwrap_or_default();
386        let t = quote(backend, &table.name);
387        let total = self.query(format!("SELECT COUNT(*) AS n FROM {t}{filter}"), params.clone()).map_err(internal)?;
388        let total = match total.first().map(|r| cell(r, "n")) {
389            Some(Value::Int(n)) => n,
390            _ => 0,
391        };
392        let rows = self
393            .query(format!("SELECT * FROM {t}{filter}{order} LIMIT {PAGE_SIZE} OFFSET {}", (page - 1) * PAGE_SIZE), params)
394            .map_err(internal)?;
395        let pk = self.pk(table);
396        let head: String = table.columns.iter().map(|c| format!("<th>{}</th>", escape(&c.name))).collect();
397        let mut body = String::new();
398        for row in &rows {
399            body.push_str("<tr>");
400            for c in &table.columns {
401                let mut text = display(&cell(row, &c.name));
402                if text.chars().count() > 80 {
403                    text = text.chars().take(80).collect::<String>() + "…";
404                }
405                match pk {
406                    Some(pk) if pk.name == c.name => {
407                        body.push_str(&format!("<td><a href=\"{}\">{}</a></td>", escape(&self.url(&[&table.name, &display(&cell(row, &pk.name))])), escape(&text)))
408                    }
409                    _ => body.push_str(&format!("<td>{}</td>", escape(&text))),
410                }
411            }
412            body.push_str("</tr>");
413        }
414        let pages = ((total + PAGE_SIZE - 1) / PAGE_SIZE).max(1);
415        let link = |p: i64| {
416            let path = crate::router::build_path(&self.url(&[&table.name]), &[("page", &p.to_string()), ("q", &q)]).unwrap_or_default();
417            escape(&path)
418        };
419        let mut nav = format!("<p>{total} rows · page {page} of {pages}");
420        if page > 1 {
421            nav.push_str(&format!(" · <a href=\"{}\">newer</a>", link(page - 1)));
422        }
423        if page < pages {
424            nav.push_str(&format!(" · <a href=\"{}\">older</a>", link(page + 1)));
425        }
426        nav.push_str("</p>");
427        let new = if pk.is_some() { format!("<p><a href=\"{}\">New {}</a></p>", escape(&self.url(&[&table.name, "new"])), escape(&table.name)) } else { "<p>(no one-column primary key: rows can't be edited here)</p>".into() };
428        let search = format!(
429            "<form method=\"get\"><input type=\"text\" name=\"q\" value=\"{}\" placeholder=\"Search\" style=\"display:inline;max-width:20rem\"> <button>Search</button></form>",
430            escape(&q)
431        );
432        Ok(self.html(STATUS_CODE_REASON_PHRASE.n200_ok, &table.name, &format!("{new}{search}{nav}<table><tr>{head}</tr>{body}</table>")))
433    }
434
435    fn find(&self, table: &TableSchema, id: &str) -> Result<ModelRow, (&'static StatusCodeReasonPhrase, String)> {
436        let pk = self.pk(table).ok_or((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("{} has no one-column primary key", table.name)))?;
437        let key = parse(pk, Some(id)).map_err(|e| (STATUS_CODE_REASON_PHRASE.n404_not_found, e))?;
438        let backend = self.backend();
439        let rows = self
440            .query(format!("SELECT * FROM {} WHERE {} = {}", quote(backend, &table.name), quote(backend, &pk.name), placeholder(backend, 1, pk)), vec![key])
441            .map_err(internal)?;
442        rows.into_iter().next().ok_or((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("no {} {id}", table.name)))
443    }
444
445    fn show(&self, table: &TableSchema, id: &str) -> PageResult {
446        let row = self.find(table, id)?;
447        let fields: String = table.columns.iter().map(|c| format!("<tr><th>{}</th><td>{}</td></tr>", escape(&c.name), escape(&display(&cell(&row, &c.name))))).collect();
448        let actions = format!(
449            "<p><a href=\"{edit}\">Edit</a> · <a href=\"{list}\">All {t}</a></p><form class=\"inline\" method=\"post\" action=\"{delete}\" onsubmit=\"return confirm('Delete this row?')\">{csrf}<button>Delete</button></form>",
450            edit = escape(&self.url(&[&table.name, id, "edit"])),
451            list = escape(&self.url(&[&table.name])),
452            delete = escape(&self.url(&[&table.name, id, "delete"])),
453            t = escape(&table.name),
454            csrf = self.csrf(),
455        );
456        Ok(self.html(STATUS_CODE_REASON_PHRASE.n200_ok, &format!("{} {id}", table.name), &format!("<table>{fields}</table>{actions}")))
457    }
458
459    fn edit(&self, table: &TableSchema, id: &str) -> PageResult {
460        let row = self.find(table, id)?;
461        self.form(table, Some(id), Some(&row))
462    }
463
464    /// Whether the form offers `column`: not the auto key.
465    fn editable(&self, table: &TableSchema, column: &ColumnSchema, editing: bool) -> bool {
466        let is_pk = self.pk(table).is_some_and(|pk| pk.name == column.name);
467        !(is_pk && (editing || kind(column) == Kind::Int))
468    }
469
470    fn form(&self, table: &TableSchema, id: Option<&str>, row: Option<&ModelRow>) -> PageResult {
471        if self.pk(table).is_none() {
472            return Err((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("{} has no one-column primary key", table.name)));
473        }
474        let mut fields = String::new();
475        for c in table.columns.iter().filter(|c| self.editable(table, c, id.is_some())) {
476            let value = row.map(|r| display(&cell(r, &c.name))).unwrap_or_default();
477            let hint = format!("{}{}", c.data_type.to_lowercase(), if c.nullable { ", optional" } else { "" });
478            let input = match kind(c) {
479                Kind::Bool => format!("<input type=\"checkbox\" name=\"{}\"{}>", escape(&c.name), if matches!(value.as_str(), "true" | "1") { " checked" } else { "" }),
480                Kind::Text if value.len() > 120 || value.contains('\n') => format!("<textarea name=\"{}\" rows=\"6\">{}</textarea>", escape(&c.name), escape(&value)),
481                _ => format!("<input type=\"text\" name=\"{}\" value=\"{}\">", escape(&c.name), escape(&value)),
482            };
483            fields.push_str(&format!("<label>{} <small>({})</small>{input}</label>", escape(&c.name), escape(&hint)));
484        }
485        let action = match id {
486            Some(id) => self.url(&[&table.name, id]),
487            None => self.url(&[&table.name]),
488        };
489        let heading = match id {
490            Some(id) => format!("Edit {} {id}", table.name),
491            None => format!("New {}", table.name),
492        };
493        let body = format!("<form method=\"post\" action=\"{}\">{}{fields}<button>Save</button></form>", escape(&action), self.csrf());
494        Ok(self.html(STATUS_CODE_REASON_PHRASE.n200_ok, &heading, &body))
495    }
496
497    fn save(&self, table: &TableSchema, id: Option<&str>) -> PageResult {
498        let pk = self.pk(table).ok_or((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("{} has no one-column primary key", table.name)))?;
499        let backend = self.backend();
500        let form = form_fields(&String::from_utf8_lossy(&self.request.body));
501        let back = match id {
502            Some(id) => self.url(&[&table.name, id, "edit"]),
503            None => self.url(&[&table.name, "new"]),
504        };
505        let mut columns = Vec::new();
506        let mut values = Vec::new();
507        for c in table.columns.iter().filter(|c| self.editable(table, c, id.is_some())) {
508            let given = form.get(&c.name).map(String::as_str);
509            // `#[timestamps]` columns: TIMESTAMPTZ / DATETIME, or TEXT on SQLite.
510            let stamp = matches!(kind(c), Kind::Timestamp | Kind::Text)
511                && given.is_none_or(|v| v.trim().is_empty())
512                && (c.name == "updated_at" || (c.name == "created_at" && id.is_none()));
513            // An edit leaves what the form didn't send alone (a checkbox
514            // sends nothing when unticked: that's `false`).
515            if id.is_some() && given.is_none() && !stamp && kind(c) != Kind::Bool {
516                continue;
517            }
518            let value = if stamp {
519                crate::model::Timestamp::now().to_value(backend)
520            } else {
521                match parse(c, given) {
522                    Ok(v) => v,
523                    Err(e) => return Ok(Redirect::to(&back).alert(&e).into_response()),
524                }
525            };
526            columns.push(c);
527            values.push(value);
528        }
529        let result = match id {
530            None => {
531                let names: Vec<String> = columns.iter().map(|c| quote(backend, &c.name)).collect();
532                let marks: Vec<String> = columns.iter().enumerate().map(|(i, c)| placeholder(backend, i + 1, c)).collect();
533                let sql = if columns.is_empty() {
534                    format!("INSERT INTO {} DEFAULT VALUES", quote(backend, &table.name))
535                } else {
536                    format!("INSERT INTO {} ({}) VALUES ({})", quote(backend, &table.name), names.join(", "), marks.join(", "))
537                };
538                self.execute(sql, values).map(|_| None)
539            }
540            Some(id) => {
541                let key = parse(pk, Some(id)).map_err(|e| (STATUS_CODE_REASON_PHRASE.n404_not_found, e))?;
542                let sets: Vec<String> = columns.iter().enumerate().map(|(i, c)| format!("{} = {}", quote(backend, &c.name), placeholder(backend, i + 1, c))).collect();
543                let sql = format!("UPDATE {} SET {} WHERE {} = {}", quote(backend, &table.name), sets.join(", "), quote(backend, &pk.name), placeholder(backend, columns.len() + 1, pk));
544                values.push(key);
545                self.execute(sql, values).map(|n| Some((n, id.to_string())))
546            }
547        };
548        match result {
549            Err(e) => Ok(Redirect::to(&back).alert(&format!("The database refused it: {e}")).into_response()),
550            Ok(Some((0, id))) => Err((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("no {} {id}", table.name))),
551            Ok(Some((_, id))) => Ok(Redirect::to(&self.url(&[&table.name, &id])).notice("Saved.").into_response()),
552            Ok(None) => Ok(Redirect::to(&self.url(&[&table.name])).notice("Created.").into_response()),
553        }
554    }
555
556    fn delete(&self, table: &TableSchema, id: &str) -> PageResult {
557        let pk = self.pk(table).ok_or((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("{} has no one-column primary key", table.name)))?;
558        let backend = self.backend();
559        let key = parse(pk, Some(id)).map_err(|e| (STATUS_CODE_REASON_PHRASE.n404_not_found, e))?;
560        let sql = format!("DELETE FROM {} WHERE {} = {}", quote(backend, &table.name), quote(backend, &pk.name), placeholder(backend, 1, pk));
561        match self.execute(sql, vec![key]) {
562            Ok(0) => Err((STATUS_CODE_REASON_PHRASE.n404_not_found, format!("no {} {id}", table.name))),
563            Ok(_) => Ok(Redirect::to(&self.url(&[&table.name])).notice("Deleted.").into_response()),
564            Err(e) => Ok(Redirect::to(&self.url(&[&table.name, id])).alert(&format!("The database refused it: {e}")).into_response()),
565        }
566    }
567}
568
569/// An urlencoded form's fields (the last of a repeated name wins).
570fn form_fields(text: &str) -> HashMap<String, String> {
571    fn decode(text: &str) -> Option<String> {
572        let bytes = text.as_bytes();
573        let mut out = Vec::with_capacity(bytes.len());
574        let mut i = 0;
575        while i < bytes.len() {
576            match bytes[i] {
577                b'+' => {
578                    out.push(b' ');
579                    i += 1;
580                }
581                b'%' => {
582                    out.push(u8::from_str_radix(text.get(i + 1..i + 3)?, 16).ok()?);
583                    i += 3;
584                }
585                b => {
586                    out.push(b);
587                    i += 1;
588                }
589            }
590        }
591        String::from_utf8(out).ok()
592    }
593    text.split('&')
594        .filter(|p| !p.is_empty())
595        .filter_map(|pair| {
596            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
597            Some((decode(k)?, decode(v)?))
598        })
599        .collect()
600}