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>, } 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, sqlx::Error> { query_as!( FormTransition, r#" select id, source_id, dest_id, condition as "condition: Json>" 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, } #[derive(Builder, Clone, Debug)] pub struct Replace { #[builder(setter(custom))] portal_id: Uuid, replacements: Vec, } 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 = 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::>(), ) .bind( self.replacements .iter() .map(|value| value.dest_id) .collect::>(), ) .bind( self.replacements .iter() .map(|value| Json(value.condition.clone())) .collect::>() as Vec>>, ) .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(()) } }