Skip to main content

laterite_admin/
relation.rs

1//! Related records on a form: a resource declares which other entity's rows
2//! belong to each of its records, and the edit page carries a section for them.
3//!
4//! A section is the list engine scoped to the parent, with its own New, Edit
5//! and Delete; a related record's form is the form engine mounted under the
6//! parent's path, its foreign key forced from the URL rather than trusted from
7//! the client. A many-to-many relation lists the records a pivot table joins
8//! to the parent: Attach picks one and writes the pivot row, Edit changes
9//! what the pivot row carries, Remove deletes the pivot row and leaves the
10//! record. Nothing binds before the parent exists: a create page names the
11//! sections it will have, and "Save and continue editing" lands on them.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use askama::Template;
17use axum::extract::{Form, Path, Query, State};
18use axum::response::{IntoResponse, Response};
19use axum::Extension;
20use laterite_auth::AuthenticatedUser;
21use laterite_core::query::{build, text_cast};
22use laterite_core::{t, Actor, Db, Text};
23use sea_query::{Alias, Expr, Query as Sql};
24use serde::{Deserialize, Serialize};
25
26use crate::bulk::{self, BulkContext};
27use crate::form::{self, FormConfig, PreparedForm, RelationSection};
28use crate::list::{self, ListConfig, ListParams};
29use crate::session::{FlashLevel, SessionHandle};
30use crate::{not_found, render_error, AdminState, Shell};
31
32/// One relation a resource declares, listed and edited on the parent's edit
33/// page: rows of `entity` whose `foreign_key` holds the parent's id (has-many),
34/// or rows of `entity` a `pivot` table joins to the parent (many-to-many).
35#[derive(Clone, Debug, Serialize, Deserialize)]
36#[serde(deny_unknown_fields, default)]
37#[non_exhaustive]
38pub struct RelationConfig {
39    /// The relation's name: the key in a descriptor, the path segment under
40    /// the parent (`/products/7/variants`). Not `new`, `edit`, `delete`,
41    /// `import`, `export` or `columns`, which the parent's own routes use.
42    pub name: String,
43    /// The section heading, plural (`Variants`). Defaults to the name,
44    /// humanized.
45    pub title: Text,
46    /// One related record, singular (`Variant`), for the New or Attach button
47    /// and the child form's title.
48    pub label: Text,
49    /// The related table.
50    pub entity: String,
51    /// Has-many: the column on the related table holding the parent's id.
52    pub foreign_key: Option<String>,
53    /// Many-to-many: the table joining the parent to the related table.
54    pub pivot: Option<PivotConfig>,
55    /// The section's list over the related table.
56    pub list: ListConfig,
57    /// Has-many: the related record's form; the foreign key is written by the
58    /// framework, and a field of that name here is kept hidden. Many-to-many:
59    /// the fields kept on the pivot row, asked for on Attach and editable from
60    /// the section; none means a membership carries nothing of its own.
61    pub form: FormConfig,
62    /// A permission the operator must hold for the section and its pages,
63    /// besides the parent's. `None` asks for the parent's alone.
64    pub permission: Option<String>,
65}
66
67/// The table joining a many-to-many relation's two sides, one row per
68/// membership.
69#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(deny_unknown_fields, default)]
71#[non_exhaustive]
72pub struct PivotConfig {
73    /// The pivot table.
74    pub table: String,
75    /// The column holding the parent's id.
76    pub parent_key: String,
77    /// The column holding the related record's id.
78    pub related_key: String,
79    /// The picker source offering records to attach: a registered
80    /// [`PickerSourceReg`](crate::picker::PickerSourceReg) name.
81    pub source: String,
82    /// The pivot row's id column. Default `id`.
83    pub id_field: String,
84}
85
86impl PivotConfig {
87    /// A pivot table, with records to attach chosen from `source`.
88    pub fn new(
89        table: impl Into<String>,
90        parent_key: impl Into<String>,
91        related_key: impl Into<String>,
92        source: impl Into<String>,
93    ) -> Self {
94        Self {
95            table: table.into(),
96            parent_key: parent_key.into(),
97            related_key: related_key.into(),
98            source: source.into(),
99            id_field: "id".to_string(),
100        }
101    }
102
103    /// The pivot row's id column.
104    pub fn id_field(mut self, id_field: impl Into<String>) -> Self {
105        self.id_field = id_field.into();
106        self
107    }
108}
109
110impl Default for PivotConfig {
111    fn default() -> Self {
112        Self::new("", "", "", "")
113    }
114}
115
116impl RelationConfig {
117    /// A has-many relation: rows of `entity` whose `foreign_key` names the
118    /// parent, listed by `list` and edited through `form`.
119    pub fn has_many(
120        name: impl Into<String>,
121        label: impl Into<Text>,
122        entity: impl Into<String>,
123        foreign_key: impl Into<String>,
124        list: ListConfig,
125        form: FormConfig,
126    ) -> Self {
127        let name = name.into();
128        Self {
129            title: crate::descriptor::humanize(&name),
130            name,
131            label: label.into(),
132            entity: entity.into(),
133            foreign_key: Some(foreign_key.into()),
134            pivot: None,
135            list,
136            form,
137            permission: None,
138        }
139    }
140
141    /// A many-to-many relation: rows of `entity` that `pivot` joins to the
142    /// parent, listed by `list`. Fields kept on the pivot row go on [`form`].
143    ///
144    /// [`form`]: RelationConfig::form
145    pub fn belongs_to_many(
146        name: impl Into<String>,
147        label: impl Into<Text>,
148        entity: impl Into<String>,
149        pivot: PivotConfig,
150        list: ListConfig,
151    ) -> Self {
152        let name = name.into();
153        Self {
154            title: crate::descriptor::humanize(&name),
155            name,
156            label: label.into(),
157            entity: entity.into(),
158            foreign_key: None,
159            pivot: Some(pivot),
160            list,
161            form: FormConfig::default(),
162            permission: None,
163        }
164    }
165
166    /// The fields kept on a many-to-many relation's pivot row.
167    pub fn form(mut self, form: FormConfig) -> Self {
168        self.form = form;
169        self
170    }
171
172    /// The section heading.
173    pub fn title(mut self, title: impl Into<Text>) -> Self {
174        self.title = title.into();
175        self
176    }
177
178    /// A permission the operator must also hold.
179    pub fn permission(mut self, code: impl Into<String>) -> Self {
180        self.permission = Some(code.into());
181        self
182    }
183}
184
185impl Default for RelationConfig {
186    fn default() -> Self {
187        Self {
188            name: String::new(),
189            title: Text::from(""),
190            label: Text::from(""),
191            entity: String::new(),
192            foreign_key: None,
193            pivot: None,
194            list: ListConfig::default(),
195            form: FormConfig::default(),
196            permission: None,
197        }
198    }
199}
200
201impl crate::keyed::Keyed for RelationConfig {
202    fn key(&self) -> &str {
203        &self.name
204    }
205    fn set_key(&mut self, key: String) {
206        if self.title.source().is_empty() || self.title.source() == self.name {
207            self.title = crate::descriptor::humanize(&key);
208        }
209        self.name = key;
210    }
211}
212
213/// The names a relation may not take: the parent's own routes under `{id}`.
214pub(crate) const RESERVED: [&str; 6] = ["new", "edit", "delete", "import", "export", "columns"];
215
216/// How a relation's rows are tied to the parent.
217enum Kind {
218    HasMany {
219        foreign_key: String,
220    },
221    ManyToMany {
222        pivot: PivotConfig,
223        /// The Attach form: the pivot fields plus a picker for the record.
224        attach: Arc<PreparedForm>,
225    },
226}
227
228/// A relation resolved at boot: its forms prepared, its delete context built,
229/// ready to be scoped to a parent per request.
230pub(crate) struct PreparedRelation {
231    pub config: RelationConfig,
232    /// The parent's path under the admin mount (`/admin/products`).
233    pub parent_base: String,
234    pub parent_entity: String,
235    pub parent_id_field: String,
236    kind: Kind,
237    /// Has-many: the child form. Many-to-many: the pivot row's form.
238    form: Arc<PreparedForm>,
239    bulk: Arc<BulkContext>,
240}
241
242/// Keeps a key field of this name out of sight: a declared one is hidden, an
243/// undeclared one is added hidden so the submission carries it. Either way it
244/// is stored as an id (a number), which a bigint column takes on every
245/// backend; a declared text field is retyped, any other type is kept.
246fn hide(form: &mut FormConfig, name: &str) {
247    match form.fields.iter_mut().find(|f| f.name == name) {
248        Some(field) => {
249            field.hidden = true;
250            if field.field_type == "text" {
251                field.field_type = "id".to_string();
252            }
253        }
254        None => {
255            let mut field = form::FormField::of(name, name.to_string(), "id");
256            field.hidden = true;
257            form.fields.push(field);
258        }
259    }
260}
261
262impl PreparedRelation {
263    pub(crate) fn prepare(
264        parent: &crate::Resource,
265        mut config: RelationConfig,
266        field_types: &crate::field::FieldRegistry,
267        column_types: &list::ColumnRegistry,
268        permission_codes: &std::collections::HashSet<String>,
269        persisters: &crate::persist::PersisterRegistry,
270        listeners: &[laterite_core::ModelListenerReg],
271    ) -> Result<Self, String> {
272        let name = config.name.clone();
273        let fail = |message: String| format!("relation `{name}`: {message}");
274        if !crate::sql::valid_ident(&config.name) || RESERVED.contains(&config.name.as_str()) {
275            return Err(fail("the name is one the parent's own routes use".into()));
276        }
277        config.list.entity = config.entity.clone();
278        list::check(&config.list, column_types, permission_codes).map_err(fail)?;
279        if config.form.title.source().is_empty() {
280            config.form.title = config.label.clone();
281        }
282        let prepare = |form: FormConfig| {
283            PreparedForm::prepare(form, field_types, persisters, listeners).map_err(fail)
284        };
285        let (kind, form) = match (config.foreign_key.clone(), config.pivot.clone()) {
286            (Some(foreign_key), None) => {
287                if !crate::sql::valid_ident(&foreign_key) {
288                    return Err(fail("the foreign key is not a valid identifier".into()));
289                }
290                config.form.entity = config.entity.clone();
291                config.form.id_field = config.list.id_field.clone();
292                // The foreign key is the framework's to write.
293                hide(&mut config.form, &foreign_key);
294                (Kind::HasMany { foreign_key }, prepare(config.form.clone())?)
295            }
296            (None, Some(pivot)) => {
297                if ![
298                    &pivot.table,
299                    &pivot.parent_key,
300                    &pivot.related_key,
301                    &pivot.id_field,
302                ]
303                .iter()
304                .all(|ident| crate::sql::valid_ident(ident))
305                {
306                    return Err(fail("the pivot names an invalid identifier".into()));
307                }
308                if pivot.source.is_empty() {
309                    return Err(fail("the pivot names no picker source".into()));
310                }
311                config.form.entity = pivot.table.clone();
312                config.form.id_field = pivot.id_field.clone();
313                hide(&mut config.form, &pivot.parent_key);
314                // Attach asks which record, through the picker; afterwards the
315                // membership is fixed and the form hides it.
316                let mut attach = config.form.clone();
317                if !attach.fields.iter().any(|f| f.name == pivot.related_key) {
318                    attach.fields.insert(
319                        0,
320                        form::FormField::reference(
321                            &pivot.related_key,
322                            config.label.clone(),
323                            &pivot.source,
324                        )
325                        .required(),
326                    );
327                }
328                hide(&mut config.form, &pivot.related_key);
329                let attach = Arc::new(prepare(attach)?);
330                (
331                    Kind::ManyToMany { pivot, attach },
332                    prepare(config.form.clone())?,
333                )
334            }
335            _ => return Err(fail("declare exactly one of foreign_key and pivot".into())),
336        };
337        let persister = form.persister();
338        let entity = form.config.entity.clone();
339        let bulk = Arc::new(BulkContext {
340            base_path: String::new(),
341            listeners: listeners
342                .iter()
343                .filter(|reg| reg.matches(&entity))
344                .map(|reg| reg.listener.clone())
345                .collect(),
346            entity,
347            persister,
348        });
349        Ok(Self {
350            parent_base: parent.base_path.clone(),
351            parent_entity: parent.list.entity.clone(),
352            parent_id_field: parent.list.id_field.clone(),
353            config,
354            kind,
355            form: Arc::new(form),
356            bulk,
357        })
358    }
359
360    /// The section's path under a parent record.
361    pub(crate) fn path(&self, parent_id: &str) -> String {
362        format!("{}/{}/{}", self.parent_base, parent_id, self.config.name)
363    }
364
365    /// The parent's edit page, where a save returns to.
366    fn parent_edit(&self, parent_id: &str) -> String {
367        format!("{}/{}/edit", self.parent_base, parent_id)
368    }
369
370    /// A form mounted under this parent.
371    fn rebase(&self, form: &PreparedForm, parent_id: &str) -> Arc<PreparedForm> {
372        let mut form = form.clone();
373        form.config.base_path = self.path(parent_id);
374        form.config.done_path = Some(self.parent_edit(parent_id));
375        Arc::new(form)
376    }
377
378    /// The form behind New (has-many) or Attach (many-to-many).
379    fn new_form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
380        match &self.kind {
381            Kind::HasMany { .. } => self.rebase(&self.form, parent_id),
382            Kind::ManyToMany { attach, .. } => self.rebase(attach, parent_id),
383        }
384    }
385
386    /// The form behind a row's Edit.
387    fn form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
388        self.rebase(&self.form, parent_id)
389    }
390
391    /// The section's list scoped to this parent.
392    fn list_for(&self, parent_id: &str) -> ListConfig {
393        let mut list = self.config.list.clone();
394        list.remember = false;
395        match &self.kind {
396            Kind::HasMany { foreign_key } => {
397                list.edit_base = Some(self.path(parent_id));
398                list.creatable = true;
399                // A section is one parent's rows, never a tree of its own.
400                list.tree = None;
401                list.scope = Some(list::Scope::Column {
402                    column: foreign_key.clone(),
403                    value: Some(parent_id.to_string()),
404                });
405            }
406            Kind::ManyToMany { pivot, .. } => {
407                // The order would be the pivot's to hold, and the list is not
408                // over the pivot.
409                list.reorder = None;
410                // A membership with nothing of its own has nothing to edit.
411                list.edit_base =
412                    (!self.config.form.fields.is_empty()).then(|| self.path(parent_id));
413                list.creatable = false;
414                list.scope = Some(list::Scope::Pivot {
415                    table: pivot.table.clone(),
416                    parent_key: pivot.parent_key.clone(),
417                    related_key: pivot.related_key.clone(),
418                    id_field: pivot.id_field.clone(),
419                    value: parent_id.to_string(),
420                });
421            }
422        }
423        list
424    }
425
426    /// Whether the section's rows keep an order of their own: a has-many
427    /// whose list declares `reorder`.
428    pub(crate) fn reorderable(&self) -> bool {
429        matches!(self.kind, Kind::HasMany { .. }) && self.config.list.reorder.is_some()
430    }
431
432    /// The per-row action: Delete for a record, Remove for a membership.
433    fn row_action(&self, path: &str, shell: &Shell) -> list::RowAction {
434        let (label, confirm, icon) = match &self.kind {
435            Kind::HasMany { .. } => (t!("Delete"), t!("Delete this record?"), "trash-2"),
436            Kind::ManyToMany { .. } => (t!("Remove"), t!("Remove this record?"), "unlink"),
437        };
438        list::RowAction {
439            path: format!("{path}/delete"),
440            label: shell.tt(&label),
441            confirm: shell.tt(&confirm),
442            icon,
443        }
444    }
445
446    /// The New or Attach button's label.
447    fn new_label(&self, shell: &Shell) -> String {
448        let label = shell.tt(&self.config.label);
449        match &self.kind {
450            Kind::HasMany { .. } => shell.tt(&t!("New {label}", label = label)),
451            Kind::ManyToMany { .. } => shell.tt(&t!("Attach {label}", label = label)),
452        }
453    }
454
455    /// Whether one row of `table` has every column equal to its value.
456    async fn exists(
457        &self,
458        db: &Db,
459        table: &str,
460        pairs: &[(&str, &str)],
461    ) -> Result<bool, sqlx::Error> {
462        let (sql, values) = {
463            let cast = text_cast(db.backend);
464            let mut select = Sql::select();
465            select.expr(Expr::val(1).count()).from(Alias::new(table));
466            for (column, value) in pairs {
467                select.and_where(
468                    Expr::col(Alias::new(*column))
469                        .cast_as(Alias::new(cast))
470                        .eq(value.to_string()),
471                );
472            }
473            build(db.backend, select)
474        };
475        let (n,): (i64,) = laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
476            .fetch_one(&db.pool)
477            .await?;
478        Ok(n > 0)
479    }
480
481    /// Whether the parent record exists; a child page under a parent that does
482    /// not is a 404, never a write with a dangling key.
483    async fn parent_exists(&self, db: &Db, parent_id: &str) -> Result<bool, sqlx::Error> {
484        self.exists(
485            db,
486            &self.parent_entity,
487            &[(&self.parent_id_field, parent_id)],
488        )
489        .await
490    }
491
492    /// Whether `child_id` is this parent's: a has-many's related record, or a
493    /// many-to-many's pivot row. A child page under the wrong parent is a 404,
494    /// so an id cannot be walked across parents.
495    async fn owned(&self, db: &Db, parent_id: &str, child_id: &str) -> Result<bool, sqlx::Error> {
496        match &self.kind {
497            Kind::HasMany { foreign_key } => {
498                self.exists(
499                    db,
500                    &self.config.entity,
501                    &[
502                        (&self.config.list.id_field, child_id),
503                        (foreign_key, parent_id),
504                    ],
505                )
506                .await
507            }
508            Kind::ManyToMany { pivot, .. } => {
509                self.exists(
510                    db,
511                    &pivot.table,
512                    &[(&pivot.id_field, child_id), (&pivot.parent_key, parent_id)],
513                )
514                .await
515            }
516        }
517    }
518
519    /// The related record a pivot row names, when the row is this parent's.
520    async fn pivot_related(
521        &self,
522        db: &Db,
523        pivot: &PivotConfig,
524        parent_id: &str,
525        child_id: &str,
526    ) -> Result<Option<String>, sqlx::Error> {
527        let (sql, values) = {
528            let cast = text_cast(db.backend);
529            build(
530                db.backend,
531                Sql::select()
532                    .expr_as(
533                        Expr::col(Alias::new(&pivot.related_key)).cast_as(Alias::new(cast)),
534                        Alias::new("related"),
535                    )
536                    .from(Alias::new(&pivot.table))
537                    .and_where(
538                        Expr::col(Alias::new(&pivot.id_field))
539                            .cast_as(Alias::new(cast))
540                            .eq(child_id.to_string()),
541                    )
542                    .and_where(
543                        Expr::col(Alias::new(&pivot.parent_key))
544                            .cast_as(Alias::new(cast))
545                            .eq(parent_id.to_string()),
546                    )
547                    .to_owned(),
548            )
549        };
550        let row: Option<(String,)> =
551            laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
552                .fetch_optional(&db.pool)
553                .await?;
554        Ok(row.map(|(related,)| related))
555    }
556
557    /// The section as the parent's edit page shows it: page one, unsorted.
558    pub(crate) async fn section(
559        &self,
560        state: &AdminState,
561        parent_id: &str,
562        user: &AuthenticatedUser,
563        shell: &Shell,
564    ) -> Option<RelationSection> {
565        if let Some(code) = &self.config.permission {
566            if !user.permissions.allows(code) {
567                return None;
568            }
569        }
570        let path = self.path(parent_id);
571        let html = list::fragment(
572            state,
573            &self.list_for(parent_id),
574            &path,
575            ListParams::default(),
576            &HashMap::new(),
577            user,
578            shell.clone(),
579            &format!("lat-rel-{}", self.config.name),
580            Some(self.row_action(&path, shell)),
581        )
582        .await
583        .ok()?;
584        Some(RelationSection {
585            toolbar: crate::ui::Toolbar::section(self.config.title.clone())
586                .button(
587                    crate::ui::Button::link(self.new_label(shell), format!("{path}/new")).small(),
588                )
589                .render(shell),
590            html,
591        })
592    }
593}
594
595/// The handlers, each over one relation under one parent.
596#[allow(clippy::too_many_arguments)]
597pub(crate) async fn index(
598    State(state): State<AdminState>,
599    Extension(shell): Extension<Shell>,
600    Extension(user): Extension<AuthenticatedUser>,
601    Path(parent_id): Path<String>,
602    Query(params): Query<ListParams>,
603    Query(raw): Query<HashMap<String, String>>,
604    headers: axum::http::HeaderMap,
605    rel: Arc<PreparedRelation>,
606) -> Response {
607    match rel.parent_exists(&state.db, &parent_id).await {
608        Ok(true) => {}
609        Ok(false) => return not_found(),
610        Err(_) => return render_error(),
611    }
612    let path = rel.path(&parent_id);
613    let html = match list::fragment(
614        &state,
615        &rel.list_for(&parent_id),
616        &path,
617        params,
618        &raw,
619        &user,
620        shell.clone(),
621        &format!("lat-rel-{}", rel.config.name),
622        Some(rel.row_action(&path, &shell)),
623    )
624    .await
625    {
626        Ok(html) => html,
627        Err(()) => return render_error(),
628    };
629    if form::is_htmx(&headers) {
630        return axum::response::Html(html.into_string()).into_response();
631    }
632    // Without scripting, a sort or pager link lands here: the section on a
633    // page of its own, under the parent.
634    let page = SectionPage {
635        shell: &shell,
636        back: rel.parent_edit(&parent_id),
637        toolbar: crate::ui::Toolbar::default()
638            .button(crate::ui::Button::link(rel.new_label(&shell), format!("{path}/new")).small())
639            .render(&shell),
640        html,
641    };
642    match crate::html::Markup::from_template(&page) {
643        Ok(body) => shell.page(&rel.config.title, body),
644        Err(_) => render_error(),
645    }
646}
647
648/// A section on a page of its own, for a request without scripting.
649#[derive(Template)]
650#[template(path = "relation_section.html")]
651struct SectionPage<'a> {
652    shell: &'a Shell,
653    back: String,
654    toolbar: crate::html::Markup,
655    html: crate::html::Markup,
656}
657
658pub(crate) async fn new_form(
659    State(state): State<AdminState>,
660    Extension(shell): Extension<Shell>,
661    Path(parent_id): Path<String>,
662    rel: Arc<PreparedRelation>,
663) -> Response {
664    match rel.parent_exists(&state.db, &parent_id).await {
665        Ok(true) => {}
666        Ok(false) => return not_found(),
667        Err(_) => return render_error(),
668    }
669    form::new_form(&state, &rel.new_form_for(&parent_id), shell, Vec::new())
670}
671
672#[allow(clippy::too_many_arguments)]
673pub(crate) async fn create(
674    State(state): State<AdminState>,
675    Extension(shell): Extension<Shell>,
676    Extension(user): Extension<AuthenticatedUser>,
677    Extension(session): Extension<SessionHandle>,
678    Path(parent_id): Path<String>,
679    headers: axum::http::HeaderMap,
680    Form(mut data): Form<HashMap<String, String>>,
681    rel: Arc<PreparedRelation>,
682) -> Response {
683    match rel.parent_exists(&state.db, &parent_id).await {
684        Ok(true) => {}
685        Ok(false) => return not_found(),
686        Err(_) => return render_error(),
687    }
688    // The parent is the URL's, whatever the submission said.
689    match &rel.kind {
690        Kind::HasMany { foreign_key } => {
691            data.insert(foreign_key.clone(), parent_id.clone());
692        }
693        Kind::ManyToMany { pivot, .. } => {
694            data.insert(pivot.parent_key.clone(), parent_id.clone());
695            // A blank is the form's to refuse, as a required field.
696            let related = data
697                .get(&pivot.related_key)
698                .map(|v| v.trim().to_string())
699                .filter(|v| !v.is_empty());
700            if let Some(related) = related {
701                let present = rel
702                    .exists(
703                        &state.db,
704                        &rel.config.entity,
705                        &[(&rel.config.list.id_field, &related)],
706                    )
707                    .await;
708                let attached = rel
709                    .exists(
710                        &state.db,
711                        &pivot.table,
712                        &[
713                            (&pivot.parent_key, &parent_id),
714                            (&pivot.related_key, &related),
715                        ],
716                    )
717                    .await;
718                let refusal = match (present, attached) {
719                    (Ok(false), _) => Some(t!("There is no such record to attach.")),
720                    (_, Ok(true)) => Some(t!("That record is already attached.")),
721                    (Err(_), _) | (_, Err(_)) => return render_error(),
722                    _ => None,
723                };
724                if let Some(message) = refusal {
725                    session.push_flash(FlashLevel::Error, message);
726                    let back = format!("{}/new", rel.path(&parent_id));
727                    return form::saved_response(form::is_htmx(&headers), &back);
728                }
729            }
730        }
731    }
732    form::create(
733        &state,
734        &rel.new_form_for(&parent_id),
735        data,
736        shell,
737        &user,
738        &session,
739        &headers,
740    )
741    .await
742}
743
744pub(crate) async fn edit_form(
745    State(state): State<AdminState>,
746    Extension(shell): Extension<Shell>,
747    Path((parent_id, child_id)): Path<(String, String)>,
748    rel: Arc<PreparedRelation>,
749) -> Response {
750    match rel.owned(&state.db, &parent_id, &child_id).await {
751        Ok(true) => {}
752        Ok(false) => return not_found(),
753        Err(_) => return render_error(),
754    }
755    form::edit_form(
756        &state,
757        &rel.form_for(&parent_id),
758        child_id,
759        shell,
760        Vec::new(),
761    )
762    .await
763}
764
765#[allow(clippy::too_many_arguments)]
766pub(crate) async fn update(
767    State(state): State<AdminState>,
768    Extension(shell): Extension<Shell>,
769    Extension(user): Extension<AuthenticatedUser>,
770    Extension(session): Extension<SessionHandle>,
771    Path((parent_id, child_id)): Path<(String, String)>,
772    headers: axum::http::HeaderMap,
773    Form(mut data): Form<HashMap<String, String>>,
774    rel: Arc<PreparedRelation>,
775) -> Response {
776    // The parent is the URL's and the membership is the row's, whatever the
777    // submission said.
778    match &rel.kind {
779        Kind::HasMany { foreign_key } => {
780            match rel.owned(&state.db, &parent_id, &child_id).await {
781                Ok(true) => {}
782                Ok(false) => return not_found(),
783                Err(_) => return render_error(),
784            }
785            data.insert(foreign_key.clone(), parent_id.clone());
786        }
787        Kind::ManyToMany { pivot, .. } => {
788            match rel
789                .pivot_related(&state.db, pivot, &parent_id, &child_id)
790                .await
791            {
792                Ok(Some(related)) => {
793                    data.insert(pivot.related_key.clone(), related);
794                }
795                Ok(None) => return not_found(),
796                Err(_) => return render_error(),
797            }
798            data.insert(pivot.parent_key.clone(), parent_id.clone());
799        }
800    }
801    form::update(
802        &state,
803        &rel.form_for(&parent_id),
804        child_id,
805        data,
806        shell,
807        &user,
808        &session,
809        &headers,
810    )
811    .await
812}
813
814/// Moves one related record within its parent's section.
815#[allow(clippy::too_many_arguments)]
816pub(crate) async fn reorder(
817    State(state): State<AdminState>,
818    Extension(shell): Extension<Shell>,
819    Extension(user): Extension<AuthenticatedUser>,
820    Extension(session): Extension<SessionHandle>,
821    Path(parent_id): Path<String>,
822    headers: axum::http::HeaderMap,
823    Form(pairs): Form<Vec<(String, String)>>,
824    rel: Arc<PreparedRelation>,
825) -> Response {
826    match rel.parent_exists(&state.db, &parent_id).await {
827        Ok(true) => {}
828        Ok(false) => return not_found(),
829        Err(_) => return render_error(),
830    }
831    let path = rel.path(&parent_id);
832    let row_action = Some(rel.row_action(&path, &shell));
833    list::reorder_handler(
834        &state,
835        &rel.list_for(&parent_id),
836        &path,
837        &format!("lat-rel-{}", rel.config.name),
838        row_action,
839        &user,
840        &session,
841        shell,
842        &headers,
843        &pairs,
844    )
845    .await
846}
847
848/// Removes the selected rows (a has-many's records, a many-to-many's
849/// memberships), then shows the parent page again.
850#[allow(clippy::too_many_arguments)]
851pub(crate) async fn delete(
852    State(state): State<AdminState>,
853    Extension(shell): Extension<Shell>,
854    Extension(user): Extension<AuthenticatedUser>,
855    Extension(session): Extension<SessionHandle>,
856    Path(parent_id): Path<String>,
857    headers: axum::http::HeaderMap,
858    Form(pairs): Form<Vec<(String, String)>>,
859    rel: Arc<PreparedRelation>,
860) -> Response {
861    let ids: Vec<String> = pairs
862        .iter()
863        .filter(|(k, _)| k == "id")
864        .map(|(_, v)| v.trim().to_string())
865        .filter(|v| !v.is_empty())
866        .collect();
867    let mut owned = Vec::new();
868    for id in &ids {
869        match rel.owned(&state.db, &parent_id, id).await {
870            Ok(true) => owned.push(id.clone()),
871            Ok(false) => {}
872            Err(_) => return render_error(),
873        }
874    }
875    let actor = Actor::from(&user);
876    let outcome = bulk::delete_selected(&state, &rel.bulk, &owned, &actor).await;
877    if outcome.deleted > 0 {
878        let n = outcome.deleted as i64;
879        let message = match &rel.kind {
880            Kind::HasMany { .. } => {
881                laterite_core::tn!("Deleted {n} record.", "Deleted {n} records.", n = n)
882            }
883            Kind::ManyToMany { .. } => {
884                laterite_core::tn!("Removed {n} record.", "Removed {n} records.", n = n)
885            }
886        };
887        session.push_flash(FlashLevel::Success, message);
888    }
889    for message in outcome.refused {
890        session.push_flash(FlashLevel::Error, message);
891    }
892    if outcome.failed > 0 {
893        session.push_flash(FlashLevel::Error, t!("Some records could not be deleted."));
894    }
895    // Back to the parent page whole, so the flash saying what happened shows.
896    let _ = shell;
897    form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
898}