1use std::collections::HashMap;
22use std::sync::Arc;
23
24use askama::Template;
25use axum::http::StatusCode;
26use axum::response::{IntoResponse, Redirect, Response};
27use laterite_core::query::{bind_values, build as to_sql, text_cast};
28use laterite_core::validation::{validate, FieldRules, Mode, Rule};
29use laterite_core::{
30 t, Actor, AnyRowExt, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
31};
32use sea_query::{Alias, Expr, Query};
33use serde::{Deserialize, Serialize};
34
35use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
36use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
37use crate::sql::valid_ident;
38use crate::{not_found, render, render_error, AdminState};
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FormField {
46 pub name: String,
47 pub label: Text,
49 #[serde(rename = "type")]
52 pub field_type: String,
53 #[serde(default)]
55 pub options: serde_json::Value,
56 #[serde(default)]
58 pub rules: Vec<Rule>,
59 #[serde(default)]
63 pub translatable: bool,
64}
65
66impl FormField {
67 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
69 Self {
70 name: name.to_string(),
71 label: label.into(),
72 field_type: field_type.to_string(),
73 options: serde_json::Value::Null,
74 rules: Vec::new(),
75 translatable: false,
76 }
77 }
78
79 pub fn text(name: &str, label: impl Into<Text>) -> Self {
80 Self::of(name, label, "text")
81 }
82
83 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
84 Self::of(name, label, "textarea")
85 }
86
87 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
91 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
92 }
93
94 pub fn options(mut self, options: serde_json::Value) -> Self {
96 self.options = options;
97 self
98 }
99
100 pub fn required(mut self) -> Self {
102 self.rules.push(Rule::Required);
103 self
104 }
105
106 pub fn required_on(mut self, mode: Mode) -> Self {
109 self.rules.push(Rule::RequiredOn(mode));
110 self
111 }
112
113 pub fn unique(mut self) -> Self {
116 self.rules.push(Rule::Unique);
117 self
118 }
119
120 pub fn max_length(mut self, n: usize) -> Self {
122 self.rules.push(Rule::MaxLength(n));
123 self
124 }
125
126 pub fn min_length(mut self, n: usize) -> Self {
128 self.rules.push(Rule::MinLength(n));
129 self
130 }
131
132 pub fn email(mut self) -> Self {
134 self.rules.push(Rule::Email);
135 self
136 }
137
138 pub fn translatable(mut self) -> Self {
140 self.translatable = true;
141 self
142 }
143}
144
145#[derive(Debug, Clone, Serialize)]
148pub struct FormConfig {
149 pub entity: String,
150 pub title: Text,
152 pub base_path: String,
153 pub id_field: String,
154 pub fields: Vec<FormField>,
155 pub persist: Option<String>,
158 #[serde(default)]
161 pub timestamps: bool,
162}
163
164impl FormConfig {
165 fn idents_valid(&self) -> bool {
166 valid_ident(&self.entity)
167 && valid_ident(&self.id_field)
168 && self.fields.iter().all(|f| valid_ident(&f.name))
169 }
170}
171
172pub(crate) struct PreparedForm {
177 config: FormConfig,
178 fields: Vec<PreparedField>,
180 persister: Arc<dyn Persister>,
182 listeners: Vec<Arc<dyn ModelListener>>,
184}
185
186struct PreparedField {
189 field_type: Arc<dyn crate::field::FieldType>,
191 opts: ResolvedOptions,
192 rules: Vec<Rule>,
193}
194
195impl PreparedForm {
196 pub(crate) fn prepare(
200 config: FormConfig,
201 field_types: &crate::field::FieldRegistry,
202 persisters: &PersisterRegistry,
203 listeners: &[ModelListenerReg],
204 ) -> Result<Self, String> {
205 let mut fields = Vec::with_capacity(config.fields.len());
206 for f in &config.fields {
207 let ft = field_types.get(&f.field_type).ok_or_else(|| {
208 format!(
209 "field `{}` uses unregistered type `{}`",
210 f.name, f.field_type
211 )
212 })?;
213 let opts = ft
214 .resolve_options(&f.options)
215 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
216 let mut rules = ft.intrinsic_rules(&opts);
217 rules.extend(f.rules.clone());
218 fields.push(PreparedField {
219 field_type: ft.clone(),
220 opts,
221 rules,
222 });
223 }
224 let persister: Arc<dyn Persister> = match &config.persist {
225 Some(key) => persisters
226 .get(key)
227 .cloned()
228 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
229 None => Arc::new(DefaultPersister::from_config(&config)),
230 };
231 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
234 if config.timestamps {
235 resolved.push(Arc::new(laterite_core::Timestamps));
236 }
237 resolved.extend(
238 listeners
239 .iter()
240 .filter(|reg| reg.matches(&config.entity))
241 .map(|reg| reg.listener.clone()),
242 );
243 let listeners = resolved;
244 Ok(Self {
245 config,
246 fields,
247 persister,
248 listeners,
249 })
250 }
251}
252
253fn declared_record(
260 form: &PreparedForm,
261 data: &HashMap<String, String>,
262 mode: Mode,
263) -> Result<Record, String> {
264 let mut rec = Record::new(&form.config.entity);
265 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
266 let raw = data.get(&field.name).map(String::as_str);
267 if let Some(value) = prepared.field_type.to_attr(raw, &prepared.opts, mode)? {
268 rec.set(field.name.clone(), value);
269 }
270 }
271 Ok(rec)
272}
273
274fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
276 form.config
277 .fields
278 .iter()
279 .zip(&form.fields)
280 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
283 .collect()
284}
285
286fn has_required(rules: &[Rule]) -> bool {
288 rules
289 .iter()
290 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
291}
292
293pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
295 render(build(
296 state,
297 form,
298 &format!("{}/new", form.config.base_path),
299 None,
300 &HashMap::new(),
301 &ErrorBag::default(),
302 &shell,
303 ))
304}
305
306pub(crate) async fn create(
309 state: &AdminState,
310 form: &PreparedForm,
311 data: HashMap<String, String>,
312 shell: crate::Shell,
313 user: &laterite_auth::AuthenticatedUser,
314) -> Response {
315 if !form.config.idents_valid() {
316 return render_error();
317 }
318 let action = format!("{}/new", form.config.base_path);
319
320 let bag = match validate(
321 &state.db,
322 &form.config.entity,
323 &form.config.id_field,
324 &merged_field_rules(form),
325 &data,
326 Mode::Create,
327 None,
328 )
329 .await
330 {
331 Ok(bag) => bag,
332 Err(_) => return render_error(),
333 };
334 if !bag.is_empty() {
335 return (
338 StatusCode::UNPROCESSABLE_ENTITY,
339 render(build(state, form, &action, None, &data, &bag, &shell)),
340 )
341 .into_response();
342 }
343
344 let mut rec = match declared_record(form, &data, Mode::Create) {
345 Ok(rec) => rec,
346 Err(e) => {
347 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
348 return render_error();
349 }
350 };
351 let actor = Actor::from(user);
352 match persist::save(
353 persist::SaveRequest {
354 db: &state.db,
355 listeners: &form.listeners,
356 persister: form.persister.as_ref(),
357 actor: &actor,
358 op: Op::Create,
359 id: None,
360 },
361 &mut rec,
362 )
363 .await
364 {
365 Ok(()) => Redirect::to(&form.config.base_path).into_response(),
368 Err(SaveError::Invalid(bag)) => (
370 StatusCode::UNPROCESSABLE_ENTITY,
371 render(build(state, form, &action, None, &data, &bag, &shell)),
372 )
373 .into_response(),
374 Err(SaveError::Failed(msg)) => {
375 tracing::error!(error = %msg, "admin create failed");
376 render(build(
377 state,
378 form,
379 &action,
380 Some(t!("Could not save. Check the values and try again.")),
381 &data,
382 &ErrorBag::default(),
383 &shell,
384 ))
385 }
386 }
387}
388
389pub(crate) async fn edit_form(
391 state: &AdminState,
392 form: &PreparedForm,
393 id: String,
394 shell: crate::Shell,
395) -> Response {
396 if !form.config.idents_valid() {
397 return render_error();
398 }
399 let (sql, values) = {
403 let cast = text_cast(state.db.backend);
404 let mut select = Query::select();
405 for field in &form.config.fields {
406 select.expr_as(
407 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
408 Alias::new(&field.name),
409 );
410 }
411 select.from(Alias::new(&form.config.entity)).and_where(
412 Expr::col(Alias::new(&form.config.id_field))
413 .cast_as(Alias::new(cast))
414 .eq(id.clone()),
415 );
416 to_sql(state.db.backend, select)
417 };
418 let row = match bind_values(sqlx::query(&sql), values)
419 .fetch_optional(&state.db.pool)
420 .await
421 {
422 Ok(row) => row,
423 Err(_) => return render_error(),
424 };
425 let Some(row) = row else {
426 return not_found();
427 };
428
429 let values = form
432 .config
433 .fields
434 .iter()
435 .zip(&form.fields)
436 .map(|(f, prepared)| {
437 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
438 let value = prepared
439 .field_type
440 .to_control(stored.as_deref(), &prepared.opts);
441 (f.name.clone(), value)
442 })
443 .collect();
444
445 render(build(
446 state,
447 form,
448 &format!("{}/{}/edit", form.config.base_path, id),
449 None,
450 &values,
451 &ErrorBag::default(),
452 &shell,
453 ))
454}
455
456pub(crate) async fn update(
459 state: &AdminState,
460 form: &PreparedForm,
461 id: String,
462 data: HashMap<String, String>,
463 shell: crate::Shell,
464 user: &laterite_auth::AuthenticatedUser,
465) -> Response {
466 if !form.config.idents_valid() {
467 return render_error();
468 }
469 let action = format!("{}/{}/edit", form.config.base_path, id);
470
471 let bag = match validate(
472 &state.db,
473 &form.config.entity,
474 &form.config.id_field,
475 &merged_field_rules(form),
476 &data,
477 Mode::Update,
478 Some(&id),
479 )
480 .await
481 {
482 Ok(bag) => bag,
483 Err(_) => return render_error(),
484 };
485 if !bag.is_empty() {
486 return (
489 StatusCode::UNPROCESSABLE_ENTITY,
490 render(build(state, form, &action, None, &data, &bag, &shell)),
491 )
492 .into_response();
493 }
494
495 let mut rec = match declared_record(form, &data, Mode::Update) {
496 Ok(rec) => rec,
497 Err(e) => {
498 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
499 return render_error();
500 }
501 };
502 if let Ok(n) = id.parse::<i64>() {
503 rec.set_id(n);
504 }
505 let actor = Actor::from(user);
506 match persist::save(
507 persist::SaveRequest {
508 db: &state.db,
509 listeners: &form.listeners,
510 persister: form.persister.as_ref(),
511 actor: &actor,
512 op: Op::Update,
513 id: Some(&id),
514 },
515 &mut rec,
516 )
517 .await
518 {
519 Ok(()) => Redirect::to(&form.config.base_path).into_response(),
520 Err(SaveError::Invalid(bag)) => (
521 StatusCode::UNPROCESSABLE_ENTITY,
522 render(build(state, form, &action, None, &data, &bag, &shell)),
523 )
524 .into_response(),
525 Err(SaveError::Failed(msg)) => {
526 tracing::error!(error = %msg, "admin update failed");
527 render(build(
528 state,
529 form,
530 &action,
531 Some(t!("Could not save. Check the values and try again.")),
532 &data,
533 &ErrorBag::default(),
534 &shell,
535 ))
536 }
537 }
538}
539
540fn build(
541 state: &AdminState,
542 form: &PreparedForm,
543 action: &str,
544 error: Option<Text>,
545 values: &HashMap<String, String>,
546 bag: &ErrorBag,
547 shell: &crate::Shell,
548) -> FormTemplate {
549 let fields = form
550 .config
551 .fields
552 .iter()
553 .zip(&form.fields)
554 .map(|(f, pf)| {
555 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
556 let required = has_required(&pf.rules);
557 let label = shell.tt(&f.label);
560 let cx = FieldCx {
561 name: &f.name,
562 id: &f.name,
563 label: &label,
564 value: &value,
565 required,
566 opts: &pf.opts,
567 base: &shell.base,
568 };
569 let control = match state.field_types.get(&f.field_type) {
572 Some(ft) => {
573 let scope = OverrideScope {
574 surface: Surface::Field,
575 view_key: &f.field_type,
576 resource: Some(&form.config.base_path),
577 field: Some(&f.name),
578 };
579 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
580 }
581 None => String::new(),
582 };
583 FieldView {
584 id: f.name.clone(),
585 label,
586 control,
587 required,
588 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
590 }
591 })
592 .collect();
593 let keys: Vec<&str> = form
597 .config
598 .fields
599 .iter()
600 .zip(&form.fields)
601 .filter_map(|(f, pf)| {
602 state
603 .field_types
604 .get(&f.field_type)
605 .map(|ft| ft.assets(&pf.opts))
606 })
607 .flatten()
608 .collect();
609 let shell_title = shell.tt(&form.config.title);
611 let error = error.map(|m| shell.tt(&m));
612 let mut shell = shell.clone();
613 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
614 FormTemplate {
615 shell,
616 title: shell_title,
617 action: action.to_string(),
618 cancel_path: form.config.base_path.clone(),
619 error,
620 fields,
621 }
622}
623
624struct FieldView {
625 id: String,
627 label: String,
628 control: String,
631 required: bool,
632 errors: Vec<String>,
633}
634
635#[derive(Template)]
636#[template(path = "form.html")]
637struct FormTemplate {
638 shell: crate::Shell,
639 title: String,
640 action: String,
641 cancel_path: String,
642 error: Option<String>,
643 fields: Vec<FieldView>,
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use axum::http::StatusCode;
650 use laterite_core::strata::{
651 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
652 };
653 use laterite_core::testing::{connect_test, TestGuard};
654 use laterite_core::Db;
655 use laterite_core::SaveCx;
656
657 struct CreateSamples;
660 #[async_trait(?Send)]
661 impl Migration for CreateSamples {
662 fn name(&self) -> &str {
663 "0001_create_samples"
664 }
665 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
666 s.exec(
667 Table::create()
668 .table(Alias::new("samples"))
669 .if_not_exists()
670 .col(
671 ColumnDef::new(Alias::new("id"))
672 .big_integer()
673 .not_null()
674 .auto_increment()
675 .primary_key(),
676 )
677 .col(ColumnDef::new(Alias::new("code")).text().not_null())
678 .col(ColumnDef::new(Alias::new("name")).text().not_null())
679 .col(ColumnDef::new(Alias::new("created_at")).text())
682 .col(ColumnDef::new(Alias::new("updated_at")).text())
683 .to_owned(),
684 )
685 .await
686 }
687 }
688
689 fn config() -> PreparedForm {
690 let config = FormConfig {
691 entity: "samples".to_string(),
692 title: "Sample".into(),
693 base_path: "/admin/samples".to_string(),
694 id_field: "id".to_string(),
695 fields: vec![
696 FormField::text("code", "Code").required().unique(),
697 FormField::text("name", "Name").required(),
698 ],
699 persist: None,
700 timestamps: false,
701 };
702 PreparedForm::prepare(
703 config,
704 &crate::field::builtin_registry(),
705 &PersisterRegistry::new(),
706 &[],
707 )
708 .unwrap()
709 }
710
711 fn state(db: Db) -> AdminState {
712 AdminState::new(
713 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
714 db,
715 )
716 }
717
718 pub(super) async fn test_db() -> (Db, TestGuard) {
721 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
722 connect_test(&[samples]).await
723 }
724
725 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
728 let stmt = Query::select()
729 .expr_as(
730 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
731 Alias::new("v"),
732 )
733 .from(Alias::new("samples"))
734 .and_where(Expr::col(Alias::new("code")).eq(code))
735 .to_owned();
736 let (sql, values) = to_sql(db.backend, stmt);
737 let row = bind_values(sqlx::query(&sql), values)
738 .fetch_optional(&db.pool)
739 .await
740 .unwrap()?;
741 row.get_text_opt("v").ok().flatten()
742 }
743
744 async fn body_of(resp: Response) -> String {
745 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
746 .await
747 .unwrap();
748 String::from_utf8(bytes.to_vec()).unwrap()
749 }
750
751 async fn count(db: &Db) -> i64 {
752 sqlx::query_scalar("select count(*) from samples")
753 .fetch_one(&db.pool)
754 .await
755 .unwrap()
756 }
757
758 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
759 pairs
760 .iter()
761 .map(|(k, v)| (k.to_string(), v.to_string()))
762 .collect()
763 }
764
765 #[tokio::test]
766 async fn create_then_fetch() {
767 let (db, _guard) = test_db().await;
768 let cfg = config();
769 let st = state(db.clone());
770
771 let resp = create(
772 &st,
773 &cfg,
774 data(&[("code", "editor"), ("name", "Content Editor")]),
775 crate::Shell::test(),
776 &crate::audit::test_actor(),
777 )
778 .await;
779 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
780
781 assert_eq!(
782 fetch_text(&db, "name", "editor").await.as_deref(),
783 Some("Content Editor")
784 );
785 }
786
787 #[tokio::test]
788 async fn update_changes_the_row() {
789 let (db, _guard) = test_db().await;
790 let cfg = config();
791 let st = state(db.clone());
792 create(
793 &st,
794 &cfg,
795 data(&[("code", "editor"), ("name", "Editor")]),
796 crate::Shell::test(),
797 &crate::audit::test_actor(),
798 )
799 .await;
800
801 let id = fetch_text(&db, "id", "editor")
802 .await
803 .expect("row should exist after create");
804
805 let resp = update(
807 &st,
808 &cfg,
809 id,
810 data(&[("code", "editor"), ("name", "Senior Editor")]),
811 crate::Shell::test(),
812 &crate::audit::test_actor(),
813 )
814 .await;
815 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
816
817 assert_eq!(
818 fetch_text(&db, "name", "editor").await.as_deref(),
819 Some("Senior Editor")
820 );
821 }
822
823 #[tokio::test]
824 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
825 let (db, _guard) = test_db().await;
826 let cfg = config();
827 let st = state(db.clone());
828
829 let resp = create(
830 &st,
831 &cfg,
832 data(&[("code", ""), ("name", "No Code")]),
833 crate::Shell::test(),
834 &crate::audit::test_actor(),
835 )
836 .await;
837 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
839 assert_eq!(count(&db).await, 0);
840 assert!(body_of(resp).await.contains("Code is required."));
842 }
843
844 #[tokio::test]
845 async fn create_rejects_a_duplicate_unique_value() {
846 let (db, _guard) = test_db().await;
847 let cfg = config();
848 let st = state(db.clone());
849 create(
850 &st,
851 &cfg,
852 data(&[("code", "editor"), ("name", "Editor")]),
853 crate::Shell::test(),
854 &crate::audit::test_actor(),
855 )
856 .await;
857
858 let resp = create(
860 &st,
861 &cfg,
862 data(&[("code", "editor"), ("name", "Other")]),
863 crate::Shell::test(),
864 &crate::audit::test_actor(),
865 )
866 .await;
867 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
868 assert_eq!(count(&db).await, 1);
869 assert!(body_of(resp).await.contains("already taken"));
870 }
871
872 #[tokio::test]
873 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
874 let (db, _guard) = test_db().await;
875 let st = state(db.clone());
876 let cfg = PreparedForm::prepare(
879 FormConfig {
880 entity: "samples".to_string(),
881 title: "Sample".into(),
882 base_path: "/admin/samples".to_string(),
883 id_field: "id".to_string(),
884 fields: vec![
885 FormField::text("code", "Code").required(),
886 FormField::of("name", "Email", "text")
887 .options(serde_json::json!({ "input": "email" }))
888 .required(),
889 ],
890 persist: None,
891 timestamps: false,
892 },
893 &st.field_types,
894 &PersisterRegistry::new(),
895 &[],
896 )
897 .unwrap();
898
899 let resp = create(
900 &st,
901 &cfg,
902 data(&[("code", "c1"), ("name", "nope")]),
903 crate::Shell::test(),
904 &crate::audit::test_actor(),
905 )
906 .await;
907 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
908 assert_eq!(count(&db).await, 0);
909
910 let resp = create(
911 &st,
912 &cfg,
913 data(&[("code", "c1"), ("name", "a@b.test")]),
914 crate::Shell::test(),
915 &crate::audit::test_actor(),
916 )
917 .await;
918 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
919 assert_eq!(count(&db).await, 1);
920 }
921
922 #[test]
923 fn prepare_rejects_an_unregistered_field_type() {
924 let config = FormConfig {
925 entity: "samples".to_string(),
926 title: "Sample".into(),
927 base_path: "/admin/samples".to_string(),
928 id_field: "id".to_string(),
929 fields: vec![FormField::of("place", "Place", "no.such.type")],
930 persist: None,
931 timestamps: false,
932 };
933 let Err(err) = PreparedForm::prepare(
934 config,
935 &crate::field::builtin_registry(),
936 &PersisterRegistry::new(),
937 &[],
938 ) else {
939 panic!("expected prepare to reject an unregistered type");
940 };
941 assert!(err.contains("no.such.type"), "{err}");
942 }
943
944 #[test]
945 fn prepare_rejects_a_malformed_option() {
946 let config = FormConfig {
949 entity: "samples".to_string(),
950 title: "Sample".into(),
951 base_path: "/admin/samples".to_string(),
952 id_field: "id".to_string(),
953 fields: vec![
954 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
955 ],
956 persist: None,
957 timestamps: false,
958 };
959 let Err(err) = PreparedForm::prepare(
960 config,
961 &crate::field::builtin_registry(),
962 &PersisterRegistry::new(),
963 &[],
964 ) else {
965 panic!("expected prepare to reject a malformed option");
966 };
967 assert!(err.contains("`email`"), "{err}");
968 }
969
970 struct RollbackPersister;
973 #[laterite_core::strata::async_trait]
974 impl Persister for RollbackPersister {
975 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
976 let backend = cx.backend();
977 let insert = Query::insert()
978 .into_table(Alias::new("samples"))
979 .columns([Alias::new("code"), Alias::new("name")])
980 .values_panic(["rolled".into(), "back".into()])
981 .to_owned();
982 let (sql, values) = to_sql(backend, insert);
983 bind_values(sqlx::query(&sql), values)
984 .execute(cx.conn())
985 .await
986 .map_err(|e| SaveError::Failed(e.to_string()))?;
987 Err(SaveError::Failed("deliberate".to_string()))
988 }
989 async fn update(
990 &self,
991 _cx: &mut SaveCx<'_>,
992 _id: &str,
993 _rec: &Record,
994 ) -> Result<(), SaveError> {
995 Ok(())
996 }
997 }
998
999 struct RejectingPersister;
1001 #[laterite_core::strata::async_trait]
1002 impl Persister for RejectingPersister {
1003 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1004 let mut bag = ErrorBag::default();
1005 bag.add("code", t!("Code is not allowed here."));
1006 Err(SaveError::Invalid(bag))
1007 }
1008 async fn update(
1009 &self,
1010 _cx: &mut SaveCx<'_>,
1011 _id: &str,
1012 _rec: &Record,
1013 ) -> Result<(), SaveError> {
1014 Ok(())
1015 }
1016 }
1017
1018 fn config_with(persister: &str) -> FormConfig {
1019 FormConfig {
1020 entity: "samples".to_string(),
1021 title: "Sample".into(),
1022 base_path: "/admin/samples".to_string(),
1023 id_field: "id".to_string(),
1024 fields: vec![
1025 FormField::text("code", "Code").required(),
1026 FormField::text("name", "Name").required(),
1027 ],
1028 persist: Some(persister.to_string()),
1029 timestamps: false,
1030 }
1031 }
1032
1033 #[test]
1034 fn prepare_rejects_an_unregistered_persister() {
1035 let Err(err) = PreparedForm::prepare(
1036 config_with("no.such.persister"),
1037 &crate::field::builtin_registry(),
1038 &PersisterRegistry::new(),
1039 &[],
1040 ) else {
1041 panic!("expected prepare to reject an unregistered persister");
1042 };
1043 assert!(err.contains("no.such.persister"), "{err}");
1044 }
1045
1046 #[tokio::test]
1047 async fn a_persister_transaction_rolls_back_on_error() {
1048 let (db, _guard) = test_db().await;
1049 let st = state(db.clone());
1050 let mut persisters = PersisterRegistry::new();
1051 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1052 let form = PreparedForm::prepare(
1053 config_with("test.rollback"),
1054 &st.field_types,
1055 &persisters,
1056 &[],
1057 )
1058 .unwrap();
1059
1060 let resp = create(
1061 &st,
1062 &form,
1063 data(&[("code", "rolled"), ("name", "back")]),
1064 crate::Shell::test(),
1065 &crate::audit::test_actor(),
1066 )
1067 .await;
1068 assert_eq!(resp.status(), StatusCode::OK);
1071 assert_eq!(count(&db).await, 0);
1072 }
1073
1074 #[tokio::test]
1075 async fn a_persister_invalid_error_re_renders_422() {
1076 let (db, _guard) = test_db().await;
1077 let st = state(db.clone());
1078 let mut persisters = PersisterRegistry::new();
1079 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1080 let form = PreparedForm::prepare(
1081 config_with("test.reject"),
1082 &st.field_types,
1083 &persisters,
1084 &[],
1085 )
1086 .unwrap();
1087
1088 let resp = create(
1089 &st,
1090 &form,
1091 data(&[("code", "editor"), ("name", "Editor")]),
1092 crate::Shell::test(),
1093 &crate::audit::test_actor(),
1094 )
1095 .await;
1096 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1097 assert!(body_of(resp).await.contains("Code is not allowed here."));
1098 }
1099}
1100
1101#[cfg(test)]
1102mod timestamp_tests {
1103 use super::tests::*;
1104 use super::*;
1105 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1106 use laterite_core::Actor;
1107
1108 fn timestamped(on: bool) -> FormConfig {
1109 FormConfig {
1110 entity: "samples".to_string(),
1111 title: "Sample".into(),
1112 base_path: "/admin/samples".to_string(),
1113 id_field: "id".to_string(),
1114 fields: vec![
1115 FormField::text("code", "Code").required(),
1116 FormField::text("name", "Name").required(),
1117 ],
1118 persist: None,
1119 timestamps: on,
1120 }
1121 }
1122
1123 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1124 let form = PreparedForm::prepare(
1125 timestamped(on),
1126 &crate::field::builtin_registry(),
1127 &PersisterRegistry::new(),
1128 &[],
1129 )
1130 .unwrap();
1131 let mut data = HashMap::new();
1132 data.insert("code".to_string(), "c1".to_string());
1133 data.insert("name".to_string(), "Chair".to_string());
1134 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1135 let actor = Actor::system("test");
1136 persist::save(
1137 persist::SaveRequest {
1138 db,
1139 listeners: &form.listeners,
1140 persister: form.persister.as_ref(),
1141 actor: &actor,
1142 op: Op::Create,
1143 id: None,
1144 },
1145 &mut rec,
1146 )
1147 .await
1148 .expect("the insert should succeed");
1149 rec
1150 }
1151
1152 #[tokio::test]
1153 async fn opting_in_stamps_the_row() {
1154 let (db, _guard) = test_db().await;
1155 let rec = create_through_pipeline(&db, true).await;
1156 assert!(rec.datetime(CREATED_AT).is_some());
1157 assert!(rec.datetime(UPDATED_AT).is_some());
1158 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
1160 }
1161
1162 #[tokio::test]
1163 async fn leaving_it_off_writes_no_timestamps() {
1164 let (db, _guard) = test_db().await;
1165 let rec = create_through_pipeline(&db, false).await;
1166 assert!(rec.get(CREATED_AT).is_none());
1167 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
1168 }
1169}