phonograph/interim-models/src/form_transition.rs
2025-10-09 08:01:01 +00:00

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(())
}
}