use std::collections::HashMap;
use std::sync::Arc;
use askama::Template;
use axum::extract::{Form, Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::Extension;
use laterite_auth::AuthenticatedUser;
use laterite_core::query::{build, text_cast};
use laterite_core::{t, Actor, Db, Text};
use sea_query::{Alias, Expr, Query as Sql};
use serde::{Deserialize, Serialize};
use crate::bulk::{self, BulkContext};
use crate::form::{self, FormConfig, PreparedForm, RelationSection};
use crate::list::{self, ListConfig, ListParams};
use crate::session::{FlashLevel, SessionHandle};
use crate::{not_found, render_error, AdminState, Shell};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct RelationConfig {
pub name: String,
pub title: Text,
pub label: Text,
pub entity: String,
pub foreign_key: Option<String>,
pub pivot: Option<PivotConfig>,
pub list: ListConfig,
pub form: FormConfig,
pub permission: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct PivotConfig {
pub table: String,
pub parent_key: String,
pub related_key: String,
pub source: String,
pub id_field: String,
}
impl PivotConfig {
pub fn new(
table: impl Into<String>,
parent_key: impl Into<String>,
related_key: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
table: table.into(),
parent_key: parent_key.into(),
related_key: related_key.into(),
source: source.into(),
id_field: "id".to_string(),
}
}
pub fn id_field(mut self, id_field: impl Into<String>) -> Self {
self.id_field = id_field.into();
self
}
}
impl Default for PivotConfig {
fn default() -> Self {
Self::new("", "", "", "")
}
}
impl RelationConfig {
pub fn has_many(
name: impl Into<String>,
label: impl Into<Text>,
entity: impl Into<String>,
foreign_key: impl Into<String>,
list: ListConfig,
form: FormConfig,
) -> Self {
let name = name.into();
Self {
title: crate::descriptor::humanize(&name),
name,
label: label.into(),
entity: entity.into(),
foreign_key: Some(foreign_key.into()),
pivot: None,
list,
form,
permission: None,
}
}
pub fn belongs_to_many(
name: impl Into<String>,
label: impl Into<Text>,
entity: impl Into<String>,
pivot: PivotConfig,
list: ListConfig,
) -> Self {
let name = name.into();
Self {
title: crate::descriptor::humanize(&name),
name,
label: label.into(),
entity: entity.into(),
foreign_key: None,
pivot: Some(pivot),
list,
form: FormConfig::default(),
permission: None,
}
}
pub fn form(mut self, form: FormConfig) -> Self {
self.form = form;
self
}
pub fn title(mut self, title: impl Into<Text>) -> Self {
self.title = title.into();
self
}
pub fn permission(mut self, code: impl Into<String>) -> Self {
self.permission = Some(code.into());
self
}
}
impl Default for RelationConfig {
fn default() -> Self {
Self {
name: String::new(),
title: Text::from(""),
label: Text::from(""),
entity: String::new(),
foreign_key: None,
pivot: None,
list: ListConfig::default(),
form: FormConfig::default(),
permission: None,
}
}
}
impl crate::keyed::Keyed for RelationConfig {
fn key(&self) -> &str {
&self.name
}
fn set_key(&mut self, key: String) {
if self.title.source().is_empty() || self.title.source() == self.name {
self.title = crate::descriptor::humanize(&key);
}
self.name = key;
}
}
pub(crate) const RESERVED: [&str; 6] = ["new", "edit", "delete", "import", "export", "columns"];
enum Kind {
HasMany {
foreign_key: String,
},
ManyToMany {
pivot: PivotConfig,
attach: Arc<PreparedForm>,
},
}
pub(crate) struct PreparedRelation {
pub config: RelationConfig,
pub parent_base: String,
pub parent_entity: String,
pub parent_id_field: String,
kind: Kind,
form: Arc<PreparedForm>,
bulk: Arc<BulkContext>,
}
fn hide(form: &mut FormConfig, name: &str) {
match form.fields.iter_mut().find(|f| f.name == name) {
Some(field) => {
field.hidden = true;
if field.field_type == "text" {
field.field_type = "id".to_string();
}
}
None => {
let mut field = form::FormField::of(name, name.to_string(), "id");
field.hidden = true;
form.fields.push(field);
}
}
}
impl PreparedRelation {
pub(crate) fn prepare(
parent: &crate::Resource,
mut config: RelationConfig,
field_types: &crate::field::FieldRegistry,
column_types: &list::ColumnRegistry,
permission_codes: &std::collections::HashSet<String>,
persisters: &crate::persist::PersisterRegistry,
listeners: &[laterite_core::ModelListenerReg],
) -> Result<Self, String> {
let name = config.name.clone();
let fail = |message: String| format!("relation `{name}`: {message}");
if !crate::sql::valid_ident(&config.name) || RESERVED.contains(&config.name.as_str()) {
return Err(fail("the name is one the parent's own routes use".into()));
}
config.list.entity = config.entity.clone();
list::check(&config.list, column_types, permission_codes).map_err(fail)?;
if config.form.title.source().is_empty() {
config.form.title = config.label.clone();
}
let prepare = |form: FormConfig| {
PreparedForm::prepare(form, field_types, persisters, listeners).map_err(fail)
};
let (kind, form) = match (config.foreign_key.clone(), config.pivot.clone()) {
(Some(foreign_key), None) => {
if !crate::sql::valid_ident(&foreign_key) {
return Err(fail("the foreign key is not a valid identifier".into()));
}
config.form.entity = config.entity.clone();
config.form.id_field = config.list.id_field.clone();
hide(&mut config.form, &foreign_key);
(Kind::HasMany { foreign_key }, prepare(config.form.clone())?)
}
(None, Some(pivot)) => {
if ![
&pivot.table,
&pivot.parent_key,
&pivot.related_key,
&pivot.id_field,
]
.iter()
.all(|ident| crate::sql::valid_ident(ident))
{
return Err(fail("the pivot names an invalid identifier".into()));
}
if pivot.source.is_empty() {
return Err(fail("the pivot names no picker source".into()));
}
config.form.entity = pivot.table.clone();
config.form.id_field = pivot.id_field.clone();
hide(&mut config.form, &pivot.parent_key);
let mut attach = config.form.clone();
if !attach.fields.iter().any(|f| f.name == pivot.related_key) {
attach.fields.insert(
0,
form::FormField::reference(
&pivot.related_key,
config.label.clone(),
&pivot.source,
)
.required(),
);
}
hide(&mut config.form, &pivot.related_key);
let attach = Arc::new(prepare(attach)?);
(
Kind::ManyToMany { pivot, attach },
prepare(config.form.clone())?,
)
}
_ => return Err(fail("declare exactly one of foreign_key and pivot".into())),
};
let persister = form.persister();
let entity = form.config.entity.clone();
let bulk = Arc::new(BulkContext {
base_path: String::new(),
listeners: listeners
.iter()
.filter(|reg| reg.matches(&entity))
.map(|reg| reg.listener.clone())
.collect(),
entity,
persister,
});
Ok(Self {
parent_base: parent.base_path.clone(),
parent_entity: parent.list.entity.clone(),
parent_id_field: parent.list.id_field.clone(),
config,
kind,
form: Arc::new(form),
bulk,
})
}
pub(crate) fn path(&self, parent_id: &str) -> String {
format!("{}/{}/{}", self.parent_base, parent_id, self.config.name)
}
fn parent_edit(&self, parent_id: &str) -> String {
format!("{}/{}/edit", self.parent_base, parent_id)
}
fn rebase(&self, form: &PreparedForm, parent_id: &str) -> Arc<PreparedForm> {
let mut form = form.clone();
form.config.base_path = self.path(parent_id);
form.config.done_path = Some(self.parent_edit(parent_id));
Arc::new(form)
}
fn new_form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
match &self.kind {
Kind::HasMany { .. } => self.rebase(&self.form, parent_id),
Kind::ManyToMany { attach, .. } => self.rebase(attach, parent_id),
}
}
fn form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
self.rebase(&self.form, parent_id)
}
fn list_for(&self, parent_id: &str) -> ListConfig {
let mut list = self.config.list.clone();
list.remember = false;
match &self.kind {
Kind::HasMany { foreign_key } => {
list.edit_base = Some(self.path(parent_id));
list.creatable = true;
list.tree = None;
list.scope = Some(list::Scope::Column {
column: foreign_key.clone(),
value: Some(parent_id.to_string()),
});
}
Kind::ManyToMany { pivot, .. } => {
list.reorder = None;
list.edit_base =
(!self.config.form.fields.is_empty()).then(|| self.path(parent_id));
list.creatable = false;
list.scope = Some(list::Scope::Pivot {
table: pivot.table.clone(),
parent_key: pivot.parent_key.clone(),
related_key: pivot.related_key.clone(),
id_field: pivot.id_field.clone(),
value: parent_id.to_string(),
});
}
}
list
}
pub(crate) fn reorderable(&self) -> bool {
matches!(self.kind, Kind::HasMany { .. }) && self.config.list.reorder.is_some()
}
fn row_action(&self, path: &str, shell: &Shell) -> list::RowAction {
let (label, confirm, icon) = match &self.kind {
Kind::HasMany { .. } => (t!("Delete"), t!("Delete this record?"), "trash-2"),
Kind::ManyToMany { .. } => (t!("Remove"), t!("Remove this record?"), "unlink"),
};
list::RowAction {
path: format!("{path}/delete"),
label: shell.tt(&label),
confirm: shell.tt(&confirm),
icon,
}
}
fn new_label(&self, shell: &Shell) -> String {
let label = shell.tt(&self.config.label);
match &self.kind {
Kind::HasMany { .. } => shell.tt(&t!("New {label}", label = label)),
Kind::ManyToMany { .. } => shell.tt(&t!("Attach {label}", label = label)),
}
}
async fn exists(
&self,
db: &Db,
table: &str,
pairs: &[(&str, &str)],
) -> Result<bool, sqlx::Error> {
let (sql, values) = {
let cast = text_cast(db.backend);
let mut select = Sql::select();
select.expr(Expr::val(1).count()).from(Alias::new(table));
for (column, value) in pairs {
select.and_where(
Expr::col(Alias::new(*column))
.cast_as(Alias::new(cast))
.eq(value.to_string()),
);
}
build(db.backend, select)
};
let (n,): (i64,) = laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
.fetch_one(&db.pool)
.await?;
Ok(n > 0)
}
async fn parent_exists(&self, db: &Db, parent_id: &str) -> Result<bool, sqlx::Error> {
self.exists(
db,
&self.parent_entity,
&[(&self.parent_id_field, parent_id)],
)
.await
}
async fn owned(&self, db: &Db, parent_id: &str, child_id: &str) -> Result<bool, sqlx::Error> {
match &self.kind {
Kind::HasMany { foreign_key } => {
self.exists(
db,
&self.config.entity,
&[
(&self.config.list.id_field, child_id),
(foreign_key, parent_id),
],
)
.await
}
Kind::ManyToMany { pivot, .. } => {
self.exists(
db,
&pivot.table,
&[(&pivot.id_field, child_id), (&pivot.parent_key, parent_id)],
)
.await
}
}
}
async fn pivot_related(
&self,
db: &Db,
pivot: &PivotConfig,
parent_id: &str,
child_id: &str,
) -> Result<Option<String>, sqlx::Error> {
let (sql, values) = {
let cast = text_cast(db.backend);
build(
db.backend,
Sql::select()
.expr_as(
Expr::col(Alias::new(&pivot.related_key)).cast_as(Alias::new(cast)),
Alias::new("related"),
)
.from(Alias::new(&pivot.table))
.and_where(
Expr::col(Alias::new(&pivot.id_field))
.cast_as(Alias::new(cast))
.eq(child_id.to_string()),
)
.and_where(
Expr::col(Alias::new(&pivot.parent_key))
.cast_as(Alias::new(cast))
.eq(parent_id.to_string()),
)
.to_owned(),
)
};
let row: Option<(String,)> =
laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(row.map(|(related,)| related))
}
pub(crate) async fn section(
&self,
state: &AdminState,
parent_id: &str,
user: &AuthenticatedUser,
shell: &Shell,
) -> Option<RelationSection> {
if let Some(code) = &self.config.permission {
if !user.permissions.allows(code) {
return None;
}
}
let path = self.path(parent_id);
let html = list::fragment(
state,
&self.list_for(parent_id),
&path,
ListParams::default(),
&HashMap::new(),
user,
shell.clone(),
&format!("lat-rel-{}", self.config.name),
Some(self.row_action(&path, shell)),
)
.await
.ok()?;
Some(RelationSection {
toolbar: crate::ui::Toolbar::section(self.config.title.clone())
.button(
crate::ui::Button::link(self.new_label(shell), format!("{path}/new")).small(),
)
.render(shell),
html,
})
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn index(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Path(parent_id): Path<String>,
Query(params): Query<ListParams>,
Query(raw): Query<HashMap<String, String>>,
headers: axum::http::HeaderMap,
rel: Arc<PreparedRelation>,
) -> Response {
match rel.parent_exists(&state.db, &parent_id).await {
Ok(true) => {}
Ok(false) => return not_found(),
Err(_) => return render_error(),
}
let path = rel.path(&parent_id);
let html = match list::fragment(
&state,
&rel.list_for(&parent_id),
&path,
params,
&raw,
&user,
shell.clone(),
&format!("lat-rel-{}", rel.config.name),
Some(rel.row_action(&path, &shell)),
)
.await
{
Ok(html) => html,
Err(()) => return render_error(),
};
if form::is_htmx(&headers) {
return axum::response::Html(html.into_string()).into_response();
}
let page = SectionPage {
shell: &shell,
back: rel.parent_edit(&parent_id),
toolbar: crate::ui::Toolbar::default()
.button(crate::ui::Button::link(rel.new_label(&shell), format!("{path}/new")).small())
.render(&shell),
html,
};
match crate::html::Markup::from_template(&page) {
Ok(body) => shell.page(&rel.config.title, body),
Err(_) => render_error(),
}
}
#[derive(Template)]
#[template(path = "relation_section.html")]
struct SectionPage<'a> {
shell: &'a Shell,
back: String,
toolbar: crate::html::Markup,
html: crate::html::Markup,
}
pub(crate) async fn new_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path(parent_id): Path<String>,
rel: Arc<PreparedRelation>,
) -> Response {
match rel.parent_exists(&state.db, &parent_id).await {
Ok(true) => {}
Ok(false) => return not_found(),
Err(_) => return render_error(),
}
form::new_form(&state, &rel.new_form_for(&parent_id), shell, Vec::new())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<SessionHandle>,
Path(parent_id): Path<String>,
headers: axum::http::HeaderMap,
Form(mut data): Form<HashMap<String, String>>,
rel: Arc<PreparedRelation>,
) -> Response {
match rel.parent_exists(&state.db, &parent_id).await {
Ok(true) => {}
Ok(false) => return not_found(),
Err(_) => return render_error(),
}
match &rel.kind {
Kind::HasMany { foreign_key } => {
data.insert(foreign_key.clone(), parent_id.clone());
}
Kind::ManyToMany { pivot, .. } => {
data.insert(pivot.parent_key.clone(), parent_id.clone());
let related = data
.get(&pivot.related_key)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
if let Some(related) = related {
let present = rel
.exists(
&state.db,
&rel.config.entity,
&[(&rel.config.list.id_field, &related)],
)
.await;
let attached = rel
.exists(
&state.db,
&pivot.table,
&[
(&pivot.parent_key, &parent_id),
(&pivot.related_key, &related),
],
)
.await;
let refusal = match (present, attached) {
(Ok(false), _) => Some(t!("There is no such record to attach.")),
(_, Ok(true)) => Some(t!("That record is already attached.")),
(Err(_), _) | (_, Err(_)) => return render_error(),
_ => None,
};
if let Some(message) = refusal {
session.push_flash(FlashLevel::Error, message);
let back = format!("{}/new", rel.path(&parent_id));
return form::saved_response(form::is_htmx(&headers), &back);
}
}
}
}
form::create(
&state,
&rel.new_form_for(&parent_id),
data,
shell,
&user,
&session,
&headers,
)
.await
}
pub(crate) async fn edit_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path((parent_id, child_id)): Path<(String, String)>,
rel: Arc<PreparedRelation>,
) -> Response {
match rel.owned(&state.db, &parent_id, &child_id).await {
Ok(true) => {}
Ok(false) => return not_found(),
Err(_) => return render_error(),
}
form::edit_form(
&state,
&rel.form_for(&parent_id),
child_id,
shell,
Vec::new(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn update(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<SessionHandle>,
Path((parent_id, child_id)): Path<(String, String)>,
headers: axum::http::HeaderMap,
Form(mut data): Form<HashMap<String, String>>,
rel: Arc<PreparedRelation>,
) -> Response {
match &rel.kind {
Kind::HasMany { foreign_key } => {
match rel.owned(&state.db, &parent_id, &child_id).await {
Ok(true) => {}
Ok(false) => return not_found(),
Err(_) => return render_error(),
}
data.insert(foreign_key.clone(), parent_id.clone());
}
Kind::ManyToMany { pivot, .. } => {
match rel
.pivot_related(&state.db, pivot, &parent_id, &child_id)
.await
{
Ok(Some(related)) => {
data.insert(pivot.related_key.clone(), related);
}
Ok(None) => return not_found(),
Err(_) => return render_error(),
}
data.insert(pivot.parent_key.clone(), parent_id.clone());
}
}
form::update(
&state,
&rel.form_for(&parent_id),
child_id,
data,
shell,
&user,
&session,
&headers,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn reorder(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<SessionHandle>,
Path(parent_id): Path<String>,
headers: axum::http::HeaderMap,
Form(pairs): Form<Vec<(String, String)>>,
rel: Arc<PreparedRelation>,
) -> Response {
match rel.parent_exists(&state.db, &parent_id).await {
Ok(true) => {}
Ok(false) => return not_found(),
Err(_) => return render_error(),
}
let path = rel.path(&parent_id);
let row_action = Some(rel.row_action(&path, &shell));
list::reorder_handler(
&state,
&rel.list_for(&parent_id),
&path,
&format!("lat-rel-{}", rel.config.name),
row_action,
&user,
&session,
shell,
&headers,
&pairs,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn delete(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<SessionHandle>,
Path(parent_id): Path<String>,
headers: axum::http::HeaderMap,
Form(pairs): Form<Vec<(String, String)>>,
rel: Arc<PreparedRelation>,
) -> Response {
let ids: Vec<String> = pairs
.iter()
.filter(|(k, _)| k == "id")
.map(|(_, v)| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let mut owned = Vec::new();
for id in &ids {
match rel.owned(&state.db, &parent_id, id).await {
Ok(true) => owned.push(id.clone()),
Ok(false) => {}
Err(_) => return render_error(),
}
}
let actor = Actor::from(&user);
let outcome = bulk::delete_selected(&state, &rel.bulk, &owned, &actor).await;
if outcome.deleted > 0 {
let n = outcome.deleted as i64;
let message = match &rel.kind {
Kind::HasMany { .. } => {
laterite_core::tn!("Deleted {n} record.", "Deleted {n} records.", n = n)
}
Kind::ManyToMany { .. } => {
laterite_core::tn!("Removed {n} record.", "Removed {n} records.", n = n)
}
};
session.push_flash(FlashLevel::Success, message);
}
for message in outcome.refused {
session.push_flash(FlashLevel::Error, message);
}
if outcome.failed > 0 {
session.push_flash(FlashLevel::Error, t!("Some records could not be deleted."));
}
let _ = shell;
form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
}