162 lines
5.5 KiB
Rust
162 lines
5.5 KiB
Rust
use derive_builder::Builder;
|
|
use serde::Serialize;
|
|
use sqlx::{Row as _, postgres::PgRow, query, query_as, types::Json};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{client::AppDbClient, expression::PgExpressionAny};
|
|
|
|
/// A form transition directionally connects two portals within the same
|
|
/// workspace, representing a potential navigation of a user between two forms.
|
|
/// If the user submits a form, form transitions with `source_id` corresponding
|
|
/// to that portal will be evaluated one by one (in order by ID---that is, by
|
|
/// creation time), and the first with a condition evaluating to true will be
|
|
/// used to direct the user to the form corresponding to portal `dest_id`.
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct FormTransition {
|
|
/// Primary key (defaults to UUIDv7).
|
|
pub id: Uuid,
|
|
|
|
/// When a user is filling out a sequence of forms, this is the ID of the
|
|
/// portal for which they have just submitted a form for.
|
|
///
|
|
/// **Source portal is expected to belong to the same workspace as the
|
|
/// destination portal.**
|
|
pub source_id: Uuid,
|
|
|
|
/// When a user is filling out a sequence of forms, this is the ID of the
|
|
/// portal for which they will be directed to if the condition evaluates to
|
|
/// true.
|
|
///
|
|
/// **Destination portal is expected to belong to the same workspace as the
|
|
/// source portal.**
|
|
pub dest_id: Uuid,
|
|
|
|
/// Represents a semi-arbitrary Postgres expression which will permit this
|
|
/// transition to be followed, only if the expression evaluates to true at
|
|
/// the time of the source form's submission.
|
|
pub condition: Json<Option<PgExpressionAny>>,
|
|
}
|
|
|
|
impl FormTransition {
|
|
/// Build a multi-row update statement to replace all transtitions for a
|
|
/// given source portal.
|
|
pub fn replace_for_portal(portal_id: Uuid) -> ReplaceBuilder {
|
|
ReplaceBuilder {
|
|
portal_id: Some(portal_id),
|
|
..ReplaceBuilder::default()
|
|
}
|
|
}
|
|
|
|
/// Build a single-field query by source portal ID.
|
|
pub fn with_source(id: Uuid) -> WithSourceQuery {
|
|
WithSourceQuery { id }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct WithSourceQuery {
|
|
id: Uuid,
|
|
}
|
|
|
|
impl WithSourceQuery {
|
|
pub async fn fetch_all(
|
|
self,
|
|
app_db: &mut AppDbClient,
|
|
) -> Result<Vec<FormTransition>, sqlx::Error> {
|
|
query_as!(
|
|
FormTransition,
|
|
r#"
|
|
select
|
|
id,
|
|
source_id,
|
|
dest_id,
|
|
condition as "condition: Json<Option<PgExpressionAny>>"
|
|
from form_transitions
|
|
where source_id = $1
|
|
"#,
|
|
self.id,
|
|
)
|
|
.fetch_all(app_db.get_conn())
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[derive(Builder, Clone, Debug)]
|
|
pub struct Replacement {
|
|
pub dest_id: Uuid,
|
|
pub condition: Option<PgExpressionAny>,
|
|
}
|
|
|
|
#[derive(Builder, Clone, Debug)]
|
|
pub struct Replace {
|
|
#[builder(setter(custom))]
|
|
portal_id: Uuid,
|
|
|
|
replacements: Vec<Replacement>,
|
|
}
|
|
|
|
impl Replace {
|
|
/// Insert zero or more form transitions, and then remove all others
|
|
/// associated with the same portal. When they are being used, form
|
|
/// transitions are evaluated from first to last by ID (that is, by
|
|
/// creation timestamp, because IDs are UUIDv7s), so none of the newly added
|
|
/// transitions will supersede their predecessors until the latter are
|
|
/// deleted in one fell swoop. However, there will be a (hopefully brief)
|
|
/// period during which both the old and the new transitions will be
|
|
/// evaluated together in order, so this is not quite equivalent to an
|
|
/// atomic update.
|
|
///
|
|
/// FIXME: There is a race condition in which executing [`Replace::execute`]
|
|
/// for the same portal two or more times simultaneously may remove *all*
|
|
/// form transitions for that portal, new and old. This would require
|
|
/// impeccable timing, but it should absolutely be fixed... at some point.
|
|
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
|
|
let ids: Vec<Uuid> = if self.replacements.is_empty() {
|
|
vec![]
|
|
} else {
|
|
// Nice to do this in one query to avoid generating even more
|
|
// intermediate database state purgatory. Credit to [@greglearns](
|
|
// https://github.com/launchbadge/sqlx/issues/294#issuecomment-716149160
|
|
// ) for the clever syntax. Too bad it doesn't seem plausible to
|
|
// dovetail this with the [`query!`] macro, but that makes sense
|
|
// given the circuitous query structure.
|
|
query(
|
|
r#"
|
|
insert into form_transitions (source_id, dest_id, condition)
|
|
select * from unnest($1, $2, $3)
|
|
returning id
|
|
"#,
|
|
)
|
|
.bind(
|
|
self.replacements
|
|
.iter()
|
|
.map(|_| self.portal_id)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.bind(
|
|
self.replacements
|
|
.iter()
|
|
.map(|value| value.dest_id)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.bind(
|
|
self.replacements
|
|
.iter()
|
|
.map(|value| Json(value.condition.clone()))
|
|
.collect::<Vec<_>>() as Vec<Json<Option<PgExpressionAny>>>,
|
|
)
|
|
.map(|row: PgRow| -> Uuid { row.get(0) })
|
|
.fetch_all(app_db.get_conn())
|
|
.await?
|
|
};
|
|
|
|
query!(
|
|
"delete from form_transitions where id <> any($1)",
|
|
ids.as_slice(),
|
|
)
|
|
.execute(app_db.get_conn())
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|