DEV Community

loading...

Practical Rust Web Development - State Machine

werner profile image Werner Echezuría ・3 min read

Rust's type system allows implementing state machine in a straightforward way. In our application we might use it to define a sale's state in a specific point in time. We can define 4 different states for a sale:

Draft: An user can edit a sale, this can be used as some form of budget or presale, it does not affect inventory or accounting, you can only approve a draft sale.

Approved: The sale can't be edited, now it's an invoice that should be delivered to the client. You can cancel, pay or partially pay an approved sale.

Pay: An user payed the sale, now you can generate a collection receipt for the sale. You can only cancel a payed sale.

Partially Pay: The user received a part of the total payment, this could be used if you want to sell a product by parts using some form of credit, then you generate a collection receipt. You can cancel and pay a partially payed sale.

Cancel: This is an annulled invoice, if you commit a mistake with a sale, you need to generate a credit/debit note afterwards. This is the final state, once you cancel a sale, you can't change its state.

Let's see it in code. Take a look at src/models/sale_state.rs:

use crate::models::sale::Sale;

#[derive(DbEnum, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(juniper::GraphQLEnum)]
pub enum SaleState {
    Draft,
    Approved,
    PartiallyPayed,
    Payed,
    Cancelled
}

#[derive(Debug)]
pub enum Event {
    Approve,
    Cancel,
    PartiallyPay,
    Pay,
}

impl SaleState {
    pub fn next(self, event: Event) -> Result<SaleState, String> {
        match (self, event) {
            (SaleState::Draft, Event::Approve) => Ok(SaleState::Approved),
            (SaleState::Approved, Event::Pay) => Ok(SaleState::Payed),
            (SaleState::Approved, Event::PartiallyPay) => Ok(SaleState::PartiallyPayed),
            (SaleState::Approved, Event::Cancel) => Ok(SaleState::Cancelled),
            (SaleState::Payed, Event::Cancel) => Ok(SaleState::Cancelled),
            (SaleState::PartiallyPayed, Event::Cancel) => Ok(SaleState::Cancelled),
            (SaleState::PartiallyPayed, Event::Pay) => Ok(SaleState::Payed),
            (sale_state, sale_event) => Err(format!("You can't {:#?} from {:#?} state", sale_event, sale_state))
        }
    }
} 

The enum SaleState is used as a database enum, let's take a look at the migration migrations/2019-09-25-114234_add_state_to_sales/up.sql:

CREATE TYPE sale_state AS ENUM ('draft', 'approved', 'partially_payed', 'payed', 'cancelled');
ALTER TABLE sales ADD COLUMN state sale_state;
UPDATE sales SET state = 'approved'; 
ALTER TABLE sales ALTER COLUMN state SET NOT NULL; 

Thanks to diesel-derive-enum crate we can map a Rust enum to db enum.

In order to make sure we are respecting the rules we already defined, we might add a function in src/models/sale.rs:

    fn set_state(context: &Context, sale_id: i32, event: Event) -> FieldResult<bool> {
        use crate::schema::sales::dsl;
        use diesel::ExpressionMethods;
        use diesel::QueryDsl;
        use diesel::RunQueryDsl;

        let conn: &PgConnection = &context.conn;
        let sale_query_builder = dsl::sales
            .filter(dsl::user_id.eq(context.user_id))
            .find(sale_id);

        let sale = sale_query_builder.first::<Sale>(conn)?;
        let sale_state = sale.state.next(event)?;

        diesel::update(sale_query_builder)
            .set(dsl::state.eq(sale_state))
            .get_result::<Sale>(conn)?;

        Ok(true)
    }

We can add a filter to updateSale function, to make sure we only edit draft sales:

            let sale = diesel::update(
                dsl::sales
                    .filter(
                        dsl::user_id
                            .eq(context.user_id)
                            .and(dsl::state.eq(SaleState::Draft)),
                    )
                    .find(sale_id),
            )
            .set(&param_sale)
            .get_result::<Sale>(conn)?;

Then we add the approve, pay, partially pay and cancel functions:

    fn approveSale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        Sale::set_state(context, sale_id, Event::Approve)
    }

    fn cancelSale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        //TODO: perform credit note or debit note
        Sale::set_state(context, sale_id, Event::Cancel)
    }

    fn paySale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        //TODO: perform collection
        Sale::set_state(context, sale_id, Event::Pay)
    }

    fn partiallyPaySale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        //TODO: perform collection
        Sale::set_state(context, sale_id, Event::PartiallyPay)
    }

Full source code here

Discussion (1)

pic
Editor guide
Collapse
alexeyyunoshev profile image