Introduction
Disclaimer: Author coming from a frontend background and with very limited backend exposure. However a couple of months ago he decided to take a dive into the Rust world and thought it wouldn't be a very bad idea to share with you all his first little discoveries :)
So as it probably becomes quite obvious from the disclaimer in this article I would like to share with you some interesting challenges I 've come to face during these first 2 months working with Rust which I believe might be helpful for people who are just setting off on their Rust journey. I would like to think that even people with more experience with the language will find some of these insights not completely trivial.
My idea is to walk you through some simple examples, where we can use different features of the language but also talk a little bit about the thinking process behind the implementation. Before going through the examples I should probably mention that we are going to use diesel
(http://diesel.rs/) for everything that involves fetching/updating/inserting records to our Postgres
database and I highly recommend everyone to check this guide first http://diesel.rs/guides/getting-started/ if they would also like to code along while reading through the examples that follow.
Challenge #1
Alright and now that all the disclaimers are out let's start with our first example. So the challenge here goes something like this. Let's say we have a users
table and for each user we also store their password (among other fields). There can only be one valid password per user so every time a user tries to update their password we need to invalidate the previous valid one and add a new record for this user with the new password. We also have another field called pass_count
which essentially counts how many times a user has changed their password. This means that every time we add a new password for a user we need to check also the pass_count
value stored together with the to-be-invalidated password and when adding the new password increment the pass_count
value by 1.
The reason why we don't directly overwrite the previous record for the same user is that we want to be able to trace back all the times that this user tried to update their password and have a record of all the passwords that they have used.
Setting up the project with diesel
Ok so before jumping to the implementation, I will try to create here a simple project with all the setup that we need to work with. Please feel free to follow these steps as well or you can also just go and check the implementation directly. The process for creating our demo project is very similar to the one described in this guide http://diesel.rs/guides/getting-started/ so instead of repeating here all those steps again, I will just list down the changes that we need to make, to tailor it to our needs.
- Since we are going to work with a
users
table when creating the migration run:
diesel migration generate create_users
- The
users
table inside ourup.sql
file should look like:
up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL,
password VARCHAR NOT NULL,
status_flg BOOLEAN NOT NULL,
pass_count INTEGER NOT NULL
)
- Also inside our
down.sql
file we should add:
down.sql
DROP TABLE users
- The code inside the
schema.rs
file is auto-generated after runningdiesel migration run
and should look like this:
src/schema.rs
table! {
users (id) {
id -> Int4,
username -> Varchar,
password -> Varchar,
status_flg -> Bool,
pass_count -> Int4,
}
}
- Now let's create our
models.rs
file and inside there let's add the structs we are going to work with for this example:
src/models.rs
use super::schema::users;
#[derive(Queryable, Debug)]
pub struct User {
pub id: i32,
pub username: String,
pub password: String,
pub status_flg: bool,
pub pass_count: i32,
}
#[derive(Insertable)]
#[table_name=“users”]
pub struct NewUserEntry {
pub username: String,
pub password: String,
pub status_flg: bool,
pub pass_count: i32,
}
- Finally in order to have a nice structure for our project, under
src
directory let's create amain.rs
file where we 're going to put ourmain()
function as well as anapi
directory inside which we 're going to put our api that incorporates the logic described above. Inside theapi
directory let's create a new file with our api - let's call itupdate_password.rs
- and amod.rs
file that makes our api visible to external crates:
src/api/mod.rs
pub mod update_password;
Implementing the update_password
api
Now we are finally ready to implement our update_password.rs
api and our main.rs
that calls it. Let me first post the code here and I will further explain some of this code right after.
src/main.rs
use anyhow::Result; // add anyhow to your dependencies in Cargo.toml
mod api;
fn main() -> Result<()> {
let username = String::from("username");
let new_password = String::from("new_password"); // Friendly reminder: This is just for demonstration purposes
api::update_password::run(username, new_password)?; // here we call our api
Ok(())
}
src/api/update___password.rs
extern crate diesel_demo;
extern crate diesel;
use self::diesel_demo::*;
use self::diesel::prelude::*;
use diesel_demo::models::*;
use diesel_demo::schema::users;
use anyhow::Result;
pub fn run(username: String, password: String) -> Result<()> {
let conn = establish_connection();
let query = users::table.filter( // constructs the query
users::username.eq(username.clone()).and(users::status_flg.eq(true))
);
let new_pass_count = diesel::update(query)
.set(users::status_flg.eq(false)) // here we specify which columns we want to update
.returning(users::pass_count)
.get_result::<i32>(&conn) // if used without the returning clause would return the whole updated record - here it returns only the pass_count value
.optional()? // we use optional cause we cannot be sure if the record exists or not
.map_or_else(
|| 1, // we set the pass_count to 1 if record not found
|num| num + 1, // the new pass_count value should be the current pass_count value + 1
);
let new_entry = NewUserEntry { // a different struct is used for inserting a new user entry
username,
password,
status_flg: true,
pass_count: new_pass_count,
};
diesel::insert_into(users::table).values(&new_entry).execute(&conn)?;
Ok(())
}
get_result
+ returning
combo
Ok so looking more carefully into the update_password
implementation we can see some of the powerful features we get by using diesel
with Postgres
in this case. Here since we are only interested in the pass_count
value, we can use the get_result
api along with the returning
clause when executing the UPDATE operation to invalidate the user's current password, so that we get back only the pass_count
value instead of the whole record. Also since we are not sure whether the record exists or not, we can use .optional()
on the result and chain it with map_or_else
so that we set the new_pass_count
value directly:
-
1
, if there is no record for that user yet -
pass_count + 1
, if the record was found
Now that we have the new_pass_count
value stored, we can insert a new record with the new password for that user. Notice that for the INSERT operation a different NewUserEntry
struct is used (also previously defined by us in the model.rs
file).
build_transaction
Ok seems like we 're all set..we invalidated the previous password for that user and our user now can continue using our services with their new password! Well... It's true that our code now works as expected, however having our api handling an UPDATE and an INSERT operation in this way is not very safe and that's because a different service might try to access our database right after the UPDATE operation is finished and before the INSERT operation takes place, leading to unpredictable behavior. To prevent such a scenario we need to wrap those two operations in a transaction and make the whole thing atomic. Let's edit our code so that we guard our api against such funky outcomes:
pub fn run(username: String, password: String) -> Result<()> {
let conn = establish_connection();
conn.build_transaction().repeatable_read().run(|| {
let query = users::table.filter( // constructs the query
users::username.eq(username.clone()).and(users::status_flg.eq(true))
);
...
diesel::insert_into(users::table).values(&new_entry).execute(&conn)?;
})
Ok(())
}
As we see, this can be done in one line with diesel conn.build_transaction().repeatable_read().run(|| {...})
, where we build the transaction specifying different parameters (like in this case that we set REPEATABLE READ as the ISOLATION LEVEL) and we call run
on the builder to run the code wrapped in the transaction. Here basically everything related to the two operations (UPDATE and INSERT) becomes part of one bigger transaction.
Alright and now seems like we got all the bases covered, hopefully with no caveats this time. And actually I just realized that this article is getting too long, so better stop here for now. We 'll continue with more examples next time(?) Jaaa mata ne!