use std::collections::HashMap;
use std::sync::Arc;
use askama::Template;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use laterite_core::query::{bind_values, build as to_sql, text_cast};
use laterite_core::validation::{validate, FieldRules, Mode, Rule};
use laterite_core::{
t, Actor, AnyRowExt, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
};
use sea_query::{Alias, Expr, Query};
use serde::{Deserialize, Serialize};
use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
use crate::sql::valid_ident;
use crate::{not_found, render, render_error, AdminState};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormField {
pub name: String,
pub label: Text,
#[serde(rename = "type")]
pub field_type: String,
#[serde(default)]
pub options: serde_json::Value,
#[serde(default)]
pub rules: Vec<Rule>,
#[serde(default)]
pub translatable: bool,
}
impl FormField {
pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
Self {
name: name.to_string(),
label: label.into(),
field_type: field_type.to_string(),
options: serde_json::Value::Null,
rules: Vec::new(),
translatable: false,
}
}
pub fn text(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "text")
}
pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "textarea")
}
pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
}
pub fn options(mut self, options: serde_json::Value) -> Self {
self.options = options;
self
}
pub fn required(mut self) -> Self {
self.rules.push(Rule::Required);
self
}
pub fn required_on(mut self, mode: Mode) -> Self {
self.rules.push(Rule::RequiredOn(mode));
self
}
pub fn unique(mut self) -> Self {
self.rules.push(Rule::Unique);
self
}
pub fn max_length(mut self, n: usize) -> Self {
self.rules.push(Rule::MaxLength(n));
self
}
pub fn min_length(mut self, n: usize) -> Self {
self.rules.push(Rule::MinLength(n));
self
}
pub fn email(mut self) -> Self {
self.rules.push(Rule::Email);
self
}
pub fn translatable(mut self) -> Self {
self.translatable = true;
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FormConfig {
pub entity: String,
pub title: Text,
pub base_path: String,
pub id_field: String,
pub fields: Vec<FormField>,
pub persist: Option<String>,
#[serde(default)]
pub timestamps: bool,
}
impl FormConfig {
fn idents_valid(&self) -> bool {
valid_ident(&self.entity)
&& valid_ident(&self.id_field)
&& self.fields.iter().all(|f| valid_ident(&f.name))
}
}
pub(crate) struct PreparedForm {
config: FormConfig,
fields: Vec<PreparedField>,
persister: Arc<dyn Persister>,
listeners: Vec<Arc<dyn ModelListener>>,
}
struct PreparedField {
field_type: Arc<dyn crate::field::FieldType>,
opts: ResolvedOptions,
rules: Vec<Rule>,
}
impl PreparedForm {
pub(crate) fn prepare(
config: FormConfig,
field_types: &crate::field::FieldRegistry,
persisters: &PersisterRegistry,
listeners: &[ModelListenerReg],
) -> Result<Self, String> {
let mut fields = Vec::with_capacity(config.fields.len());
for f in &config.fields {
let ft = field_types.get(&f.field_type).ok_or_else(|| {
format!(
"field `{}` uses unregistered type `{}`",
f.name, f.field_type
)
})?;
let opts = ft
.resolve_options(&f.options)
.map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
let mut rules = ft.intrinsic_rules(&opts);
rules.extend(f.rules.clone());
fields.push(PreparedField {
field_type: ft.clone(),
opts,
rules,
});
}
let persister: Arc<dyn Persister> = match &config.persist {
Some(key) => persisters
.get(key)
.cloned()
.ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
None => Arc::new(DefaultPersister::from_config(&config)),
};
let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
if config.timestamps {
resolved.push(Arc::new(laterite_core::Timestamps));
}
resolved.extend(
listeners
.iter()
.filter(|reg| reg.matches(&config.entity))
.map(|reg| reg.listener.clone()),
);
let listeners = resolved;
Ok(Self {
config,
fields,
persister,
listeners,
})
}
}
fn declared_record(
form: &PreparedForm,
data: &HashMap<String, String>,
mode: Mode,
) -> Result<Record, String> {
let mut rec = Record::new(&form.config.entity);
for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
let raw = data.get(&field.name).map(String::as_str);
if let Some(value) = prepared.field_type.to_attr(raw, &prepared.opts, mode)? {
rec.set(field.name.clone(), value);
}
}
Ok(rec)
}
fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
form.config
.fields
.iter()
.zip(&form.fields)
.map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
.collect()
}
fn has_required(rules: &[Rule]) -> bool {
rules
.iter()
.any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
}
pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
render(build(
state,
form,
&format!("{}/new", form.config.base_path),
None,
&HashMap::new(),
&ErrorBag::default(),
&shell,
))
}
pub(crate) async fn create(
state: &AdminState,
form: &PreparedForm,
data: HashMap<String, String>,
shell: crate::Shell,
user: &laterite_auth::AuthenticatedUser,
) -> Response {
if !form.config.idents_valid() {
return render_error();
}
let action = format!("{}/new", form.config.base_path);
let bag = match validate(
&state.db,
&form.config.entity,
&form.config.id_field,
&merged_field_rules(form),
&data,
Mode::Create,
None,
)
.await
{
Ok(bag) => bag,
Err(_) => return render_error(),
};
if !bag.is_empty() {
return (
StatusCode::UNPROCESSABLE_ENTITY,
render(build(state, form, &action, None, &data, &bag, &shell)),
)
.into_response();
}
let mut rec = match declared_record(form, &data, Mode::Create) {
Ok(rec) => rec,
Err(e) => {
tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
return render_error();
}
};
let actor = Actor::from(user);
match persist::save(
persist::SaveRequest {
db: &state.db,
listeners: &form.listeners,
persister: form.persister.as_ref(),
actor: &actor,
op: Op::Create,
id: None,
},
&mut rec,
)
.await
{
Ok(()) => Redirect::to(&form.config.base_path).into_response(),
Err(SaveError::Invalid(bag)) => (
StatusCode::UNPROCESSABLE_ENTITY,
render(build(state, form, &action, None, &data, &bag, &shell)),
)
.into_response(),
Err(SaveError::Failed(msg)) => {
tracing::error!(error = %msg, "admin create failed");
render(build(
state,
form,
&action,
Some(t!("Could not save. Check the values and try again.")),
&data,
&ErrorBag::default(),
&shell,
))
}
}
}
pub(crate) async fn edit_form(
state: &AdminState,
form: &PreparedForm,
id: String,
shell: crate::Shell,
) -> Response {
if !form.config.idents_valid() {
return render_error();
}
let (sql, values) = {
let cast = text_cast(state.db.backend);
let mut select = Query::select();
for field in &form.config.fields {
select.expr_as(
Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
Alias::new(&field.name),
);
}
select.from(Alias::new(&form.config.entity)).and_where(
Expr::col(Alias::new(&form.config.id_field))
.cast_as(Alias::new(cast))
.eq(id.clone()),
);
to_sql(state.db.backend, select)
};
let row = match bind_values(sqlx::query(&sql), values)
.fetch_optional(&state.db.pool)
.await
{
Ok(row) => row,
Err(_) => return render_error(),
};
let Some(row) = row else {
return not_found();
};
let values = form
.config
.fields
.iter()
.zip(&form.fields)
.map(|(f, prepared)| {
let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
let value = prepared
.field_type
.to_control(stored.as_deref(), &prepared.opts);
(f.name.clone(), value)
})
.collect();
render(build(
state,
form,
&format!("{}/{}/edit", form.config.base_path, id),
None,
&values,
&ErrorBag::default(),
&shell,
))
}
pub(crate) async fn update(
state: &AdminState,
form: &PreparedForm,
id: String,
data: HashMap<String, String>,
shell: crate::Shell,
user: &laterite_auth::AuthenticatedUser,
) -> Response {
if !form.config.idents_valid() {
return render_error();
}
let action = format!("{}/{}/edit", form.config.base_path, id);
let bag = match validate(
&state.db,
&form.config.entity,
&form.config.id_field,
&merged_field_rules(form),
&data,
Mode::Update,
Some(&id),
)
.await
{
Ok(bag) => bag,
Err(_) => return render_error(),
};
if !bag.is_empty() {
return (
StatusCode::UNPROCESSABLE_ENTITY,
render(build(state, form, &action, None, &data, &bag, &shell)),
)
.into_response();
}
let mut rec = match declared_record(form, &data, Mode::Update) {
Ok(rec) => rec,
Err(e) => {
tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
return render_error();
}
};
if let Ok(n) = id.parse::<i64>() {
rec.set_id(n);
}
let actor = Actor::from(user);
match persist::save(
persist::SaveRequest {
db: &state.db,
listeners: &form.listeners,
persister: form.persister.as_ref(),
actor: &actor,
op: Op::Update,
id: Some(&id),
},
&mut rec,
)
.await
{
Ok(()) => Redirect::to(&form.config.base_path).into_response(),
Err(SaveError::Invalid(bag)) => (
StatusCode::UNPROCESSABLE_ENTITY,
render(build(state, form, &action, None, &data, &bag, &shell)),
)
.into_response(),
Err(SaveError::Failed(msg)) => {
tracing::error!(error = %msg, "admin update failed");
render(build(
state,
form,
&action,
Some(t!("Could not save. Check the values and try again.")),
&data,
&ErrorBag::default(),
&shell,
))
}
}
}
fn build(
state: &AdminState,
form: &PreparedForm,
action: &str,
error: Option<Text>,
values: &HashMap<String, String>,
bag: &ErrorBag,
shell: &crate::Shell,
) -> FormTemplate {
let fields = form
.config
.fields
.iter()
.zip(&form.fields)
.map(|(f, pf)| {
let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
let required = has_required(&pf.rules);
let label = shell.tt(&f.label);
let cx = FieldCx {
name: &f.name,
id: &f.name,
label: &label,
value: &value,
required,
opts: &pf.opts,
base: &shell.base,
};
let control = match state.field_types.get(&f.field_type) {
Some(ft) => {
let scope = OverrideScope {
surface: Surface::Field,
view_key: &f.field_type,
resource: Some(&form.config.base_path),
field: Some(&f.name),
};
render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
}
None => String::new(),
};
FieldView {
id: f.name.clone(),
label,
control,
required,
errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
}
})
.collect();
let keys: Vec<&str> = form
.config
.fields
.iter()
.zip(&form.fields)
.filter_map(|(f, pf)| {
state
.field_types
.get(&f.field_type)
.map(|ft| ft.assets(&pf.opts))
})
.flatten()
.collect();
let shell_title = shell.tt(&form.config.title);
let error = error.map(|m| shell.tt(&m));
let mut shell = shell.clone();
shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
FormTemplate {
shell,
title: shell_title,
action: action.to_string(),
cancel_path: form.config.base_path.clone(),
error,
fields,
}
}
struct FieldView {
id: String,
label: String,
control: String,
required: bool,
errors: Vec<String>,
}
#[derive(Template)]
#[template(path = "form.html")]
struct FormTemplate {
shell: crate::Shell,
title: String,
action: String,
cancel_path: String,
error: Option<String>,
fields: Vec<FieldView>,
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use laterite_core::strata::{
async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
};
use laterite_core::testing::{connect_test, TestGuard};
use laterite_core::Db;
use laterite_core::SaveCx;
struct CreateSamples;
#[async_trait(?Send)]
impl Migration for CreateSamples {
fn name(&self) -> &str {
"0001_create_samples"
}
async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
s.exec(
Table::create()
.table(Alias::new("samples"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Alias::new("code")).text().not_null())
.col(ColumnDef::new(Alias::new("name")).text().not_null())
.col(ColumnDef::new(Alias::new("created_at")).text())
.col(ColumnDef::new(Alias::new("updated_at")).text())
.to_owned(),
)
.await
}
}
fn config() -> PreparedForm {
let config = FormConfig {
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required().unique(),
FormField::text("name", "Name").required(),
],
persist: None,
timestamps: false,
};
PreparedForm::prepare(
config,
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
)
.unwrap()
}
fn state(db: Db) -> AdminState {
AdminState::new(
laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db,
)
}
pub(super) async fn test_db() -> (Db, TestGuard) {
let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
connect_test(&[samples]).await
}
pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
let stmt = Query::select()
.expr_as(
Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
Alias::new("v"),
)
.from(Alias::new("samples"))
.and_where(Expr::col(Alias::new("code")).eq(code))
.to_owned();
let (sql, values) = to_sql(db.backend, stmt);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await
.unwrap()?;
row.get_text_opt("v").ok().flatten()
}
async fn body_of(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn count(db: &Db) -> i64 {
sqlx::query_scalar("select count(*) from samples")
.fetch_one(&db.pool)
.await
.unwrap()
}
fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[tokio::test]
async fn create_then_fetch() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
let resp = create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Content Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
fetch_text(&db, "name", "editor").await.as_deref(),
Some("Content Editor")
);
}
#[tokio::test]
async fn update_changes_the_row() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
let id = fetch_text(&db, "id", "editor")
.await
.expect("row should exist after create");
let resp = update(
&st,
&cfg,
id,
data(&[("code", "editor"), ("name", "Senior Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
fetch_text(&db, "name", "editor").await.as_deref(),
Some("Senior Editor")
);
}
#[tokio::test]
async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
let resp = create(
&st,
&cfg,
data(&[("code", ""), ("name", "No Code")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(count(&db).await, 0);
assert!(body_of(resp).await.contains("Code is required."));
}
#[tokio::test]
async fn create_rejects_a_duplicate_unique_value() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
let resp = create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Other")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(count(&db).await, 1);
assert!(body_of(resp).await.contains("already taken"));
}
#[tokio::test]
async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let cfg = PreparedForm::prepare(
FormConfig {
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::of("name", "Email", "text")
.options(serde_json::json!({ "input": "email" }))
.required(),
],
persist: None,
timestamps: false,
},
&st.field_types,
&PersisterRegistry::new(),
&[],
)
.unwrap();
let resp = create(
&st,
&cfg,
data(&[("code", "c1"), ("name", "nope")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(count(&db).await, 0);
let resp = create(
&st,
&cfg,
data(&[("code", "c1"), ("name", "a@b.test")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(count(&db).await, 1);
}
#[test]
fn prepare_rejects_an_unregistered_field_type() {
let config = FormConfig {
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![FormField::of("place", "Place", "no.such.type")],
persist: None,
timestamps: false,
};
let Err(err) = PreparedForm::prepare(
config,
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
) else {
panic!("expected prepare to reject an unregistered type");
};
assert!(err.contains("no.such.type"), "{err}");
}
#[test]
fn prepare_rejects_a_malformed_option() {
let config = FormConfig {
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
],
persist: None,
timestamps: false,
};
let Err(err) = PreparedForm::prepare(
config,
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
) else {
panic!("expected prepare to reject a malformed option");
};
assert!(err.contains("`email`"), "{err}");
}
struct RollbackPersister;
#[laterite_core::strata::async_trait]
impl Persister for RollbackPersister {
async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
let backend = cx.backend();
let insert = Query::insert()
.into_table(Alias::new("samples"))
.columns([Alias::new("code"), Alias::new("name")])
.values_panic(["rolled".into(), "back".into()])
.to_owned();
let (sql, values) = to_sql(backend, insert);
bind_values(sqlx::query(&sql), values)
.execute(cx.conn())
.await
.map_err(|e| SaveError::Failed(e.to_string()))?;
Err(SaveError::Failed("deliberate".to_string()))
}
async fn update(
&self,
_cx: &mut SaveCx<'_>,
_id: &str,
_rec: &Record,
) -> Result<(), SaveError> {
Ok(())
}
}
struct RejectingPersister;
#[laterite_core::strata::async_trait]
impl Persister for RejectingPersister {
async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
let mut bag = ErrorBag::default();
bag.add("code", t!("Code is not allowed here."));
Err(SaveError::Invalid(bag))
}
async fn update(
&self,
_cx: &mut SaveCx<'_>,
_id: &str,
_rec: &Record,
) -> Result<(), SaveError> {
Ok(())
}
}
fn config_with(persister: &str) -> FormConfig {
FormConfig {
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::text("name", "Name").required(),
],
persist: Some(persister.to_string()),
timestamps: false,
}
}
#[test]
fn prepare_rejects_an_unregistered_persister() {
let Err(err) = PreparedForm::prepare(
config_with("no.such.persister"),
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
) else {
panic!("expected prepare to reject an unregistered persister");
};
assert!(err.contains("no.such.persister"), "{err}");
}
#[tokio::test]
async fn a_persister_transaction_rolls_back_on_error() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let mut persisters = PersisterRegistry::new();
persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
let form = PreparedForm::prepare(
config_with("test.rollback"),
&st.field_types,
&persisters,
&[],
)
.unwrap();
let resp = create(
&st,
&form,
data(&[("code", "rolled"), ("name", "back")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(count(&db).await, 0);
}
#[tokio::test]
async fn a_persister_invalid_error_re_renders_422() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let mut persisters = PersisterRegistry::new();
persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
let form = PreparedForm::prepare(
config_with("test.reject"),
&st.field_types,
&persisters,
&[],
)
.unwrap();
let resp = create(
&st,
&form,
data(&[("code", "editor"), ("name", "Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(body_of(resp).await.contains("Code is not allowed here."));
}
}
#[cfg(test)]
mod timestamp_tests {
use super::tests::*;
use super::*;
use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
use laterite_core::Actor;
fn timestamped(on: bool) -> FormConfig {
FormConfig {
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::text("name", "Name").required(),
],
persist: None,
timestamps: on,
}
}
async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
let form = PreparedForm::prepare(
timestamped(on),
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
)
.unwrap();
let mut data = HashMap::new();
data.insert("code".to_string(), "c1".to_string());
data.insert("name".to_string(), "Chair".to_string());
let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
let actor = Actor::system("test");
persist::save(
persist::SaveRequest {
db,
listeners: &form.listeners,
persister: form.persister.as_ref(),
actor: &actor,
op: Op::Create,
id: None,
},
&mut rec,
)
.await
.expect("the insert should succeed");
rec
}
#[tokio::test]
async fn opting_in_stamps_the_row() {
let (db, _guard) = test_db().await;
let rec = create_through_pipeline(&db, true).await;
assert!(rec.datetime(CREATED_AT).is_some());
assert!(rec.datetime(UPDATED_AT).is_some());
assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
}
#[tokio::test]
async fn leaving_it_off_writes_no_timestamps() {
let (db, _guard) = test_db().await;
let rec = create_through_pipeline(&db, false).await;
assert!(rec.get(CREATED_AT).is_none());
assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
}
}