Introduction
It has been enjoyable using Rust to build a CLI app, working with both Clap as the CLI crate and SeaOrm as the ORM. In this hands-on tutorial, we'll walk through the process of creating a fully functional Todo application using Rust. This guide stems from my concern in lack of Rust tutorials that using Clap and SeaOrm altogether. If you're just as newbie as me and looking for light guide to become a Crab Master π¦, you're in the right place!
Here's the preview
Prerequisites
- Postgres (my PostgreSql version is 16.0)
- Rust 1.73.0
- Cargo
Step 1: Setting up the Project
Cargo new pagawean
cd pagawean
I name the project as pagawean means tasks in sundanese. It's a funny thing to me to name it, so name the project as you like because we will call it in our own cli as the command.
Add required crates just like below.
This is inside the Cargo.toml
file until this step:
[package]
name = "pagawean"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4.31"
clap = { version = "4.4.6", features = ["derive"] }
serde = "1.0.188"
serde_json = "1.0.107"
uuid = { version = "1.4.1", features = ["v4", "serde"]}
sea-orm = { version = "0.12", features = [ "sqlx-mysql", "runtime-tokio-rustls", "macros" ] }
dotenvy_macro = "0.15.7"
tokio = { version = "1.33.0", features = ["full"] }
Step 2: Installing sea-orm and Migrating
Run this command:
cargo install sea-orm-cli
After installing sea-orm-cli we could use that command to do migration. The step we would take is to migrate and then using that migration table scheme to generate an entity later on.
sea-orm-cli migrate init
sea-orm-cli migrate generate "create_todo"
sea-orm-cli migrate generate "create_user"
Once it's completed, SeaOrm will generate a module called migration
in the root project. Inside the src
directory, there would be some files that at the end of its name is mblablabla_blabla_create_todo.rs
and mblablabla_blabla_create_user.rs
as a result from the command we'd done previously. FYI, the convention is mYYYYMMDD_HHMMSS_migration_name.rs
from the generated file. If you see a file m20220101_000001_create_table.rs
it comes from the sea-orm-cli migrate init
. Don't worry just ignore it. You will get the template migration code inside it but we need to change it the way we wanted. We'll get into it later.
This is the project structure should look like:
.
βββ migration
β βββ Cargo.toml
β βββ README.md
β βββ src
β βββ lib.rs
β βββ m20220101_000001_create_table.rs
β βββ mblablabla_blabla_create_user.rs
β βββ mblablabla_blabla_create_todo.rs
β βββ main.rs
βββ src
βββ main.rs
βββ Cargo.lock
βββ Cargo.toml
Inside the migration/Cargo.toml
, uncomment inside the features [dependencies.sea-orm-migration]
like so.
File: migration/Cargo.toml
[dependencies.sea-orm-migration]
version = "0.12.0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
"sqlx-postgres", # `DATABASE_DRIVER` feature
]
In this case, the database we will create just a basic entity which is user and todo. User can have many todo, but each todo can only have one that belongs to user. For a much more clarity, look at this diagram:
Open the file, then edit inside mblablabla_blabla_create_user.rs
based on the diagram. Don't forget to remove todo!()
and save it.
File: migration/src/mblablabla_blabla_create_user.rs
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.string()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(User::Name).string().not_null())
.col(ColumnDef::new(User::Email).string().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum User {
Table,
Id,
Name,
Email,
}
Change inside the mblablabla_blabla_create_todo.rs
also like this:
File: migration/src/mblablabla_blabla_create_todo.rs
use sea_orm_migration::prelude::*;
use super::m20231102_030846_create_user::User;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Todo::Table)
.if_not_exists()
.col(
ColumnDef::new(Todo::Id)
.string()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Todo::Title).string().not_null())
.col(ColumnDef::new(Todo::Task).string().not_null())
.col(ColumnDef::new(Todo::CreatedAt).date_time().not_null())
.col(ColumnDef::new(Todo::DueDate).date_time().not_null())
.col(ColumnDef::new(Todo::UserId).string().not_null())
.foreign_key(ForeignKey::create()
.name("fk_todo_user_id")
.from_col(Todo::UserId)
.to_tbl(User::Table)
.to_col(User::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Todo::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Todo {
Table,
Id,
Title,
Task,
CreatedAt,
DueDate,
UserId
}
Create a .env
to set URL for the database environment variable:
DATABASE_URL=protocol://username:password@host/database
Based on seaorm docs, this will guide you:
protocol can be mysql:, postgres: or sqlite:. host is usually localhost, a domain name or an IP address.
read this page that linked to the docs database connection
Then, run the migration
sea-orm-cli migrate up
SeaOrm will apply the migrations based on the schema we did earlier.
Step 3: Generating Entities
First we need to create a lib module that's called entity
cargo new entity --lib
Then, we generate entities based on the migration we did previously using sea-orm-cli
with the output inside the entity lib module before. The SeaOrm will use the database connection from DATABASE_URL
environment variable.
sea-orm-cli generate entity -o entity/src
Then, add sea-orm dependency into it
File: entity/Cargo.toml
[dependencies]
sea-orm = { version = "0.12" }
You should see that we have a generated entity in entity module
File: entity/src/todo.rs
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "todo")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub title: String,
pub task: String,
pub created_at: DateTime,
pub due_date: DateTime,
pub user_id: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
This is for the user entity
File: entity/src/user.rs
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub email: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
Rename the mod.rs
to lib.rs
inside the entity/src
Now in order all the newly modules we created can work together and not getting error squiggling, we need to add entity
and migration
to the workspace and dependencies in our root project
File: Cargo.toml
[workspace]
members = [".", "migration", "entity"]
[dependencies]
entity = { path = "entity" }
migration = { path = "migration" }
So now your whole toml
file should look like this
File: Cargo.toml
[package]
name = "pagawean"
version = "0.1.0"
edition = "2021"
[workspace]
members = [".", "migration", "entity"]
[dependencies]
entity = { path = "entity" }
migration = { path = "migration" }
chrono = "0.4.31"
clap = { version = "4.4.6", features = ["derive"] }
serde = "1.0.188"
serde_json = "1.0.107"
uuid = { version = "1.4.1", features = ["v4", "serde"]}
sea-orm = { version = "0.12", features = [ "sqlx-mysql", "runtime-tokio-rustls", "macros" ] }
dotenvy_macro = "0.15.7"
tokio = { version = "1.33.0", features = ["full"] }
little tip: Refresh your text editor if your error squiggle still appearing. DO THIS ESPECIALLY IF YOU'RE USING VS CODE (rust analyzer)
So now this is your project structure until this step
.
βββ entity
β βββ Cargo.toml
β βββ src
β βββ lib.rs
β βββ prelude.rs
β βββ todo.rs
β βββ user.rs
βββ migration
β βββ Cargo.toml
β βββ Cargo.lock
β βββ README.md
β βββ src
β βββ lib.rs
β βββ m20220101_000001_create_table.rs
β βββ mblablabla_blabla_create_user.rs
β βββ mblablabla_blabla_create_todo.rs
β βββ main.rs
βββ src
βββ main.rs
βββ Cargo.lock
βββ Cargo.toml
Step 4: Coding with Clap
For starters, we gotta need to breakdown what we want to build. We can create 2 possible inputs from our user that they want to create which is an account and a todo list. To get a better view, take a look at this diagram for the main concept:
We're gonna create an args take take an input from the user. For the detail command args that we're gonna build you can look at this diagram:
Now create an args
file inside the src
root project. And write code like below:
File: src/args.rs
use clap::{Args, Parser, Subcommand};
#[derive(Parser, Debug)]
#[clap(author, version, about)]
pub struct PagaweanArgs {
#[clap(subcommand)]
pub entity_type: EntityType,
}
#[derive(Debug, Subcommand)]
pub enum EntityType {
/// Create, Update, Delete, Read user
User(UserCommand),
/// Create, Update, Delete, Read todos
Todo(TodoCommand),
}
#[derive(Debug, Args)]
pub struct UserCommand {
#[clap(subcommand)]
pub command: UserSubCommand,
}
#[derive(Debug, Subcommand)]
pub enum UserSubCommand {
/// Create a new user
Create(CreateUser),
/// Update an existing user
Update(UpdateUser),
/// Delete an existing user
Delete(DeleteEntity),
/// Show all users
Show,
}
#[derive(Debug, Args)]
pub struct CreateUser {
/// name of the user
pub name: String,
/// email of the user
pub email: String,
}
#[derive(Debug, Args)]
pub struct UpdateUser {
/// id of the user
pub id: String,
/// name of the user
pub name: String,
/// email of the user
pub email: String,
}
#[derive(Debug, Args)]
pub struct DeleteEntity {
/// id of the user
pub id: String,
}
#[derive(Debug, Args)]
pub struct TodoCommand {
#[clap(subcommand)]
pub command: TodoSubCommand,
}
#[derive(Debug, Subcommand)]
pub enum TodoSubCommand {
/// Create a new todo
Create(CreateTodo),
/// Update an existing todo
Update(UpdateTodo),
/// Delete an existing todo
Delete(DeleteTodo),
/// Show all todos
Read,
/// Show all todos by user id
ReadByUserId(ReadTodoByUserId),
}
#[derive(Debug, Args)]
pub struct CreateTodo {
/// title of the todo
pub title: String,
/// task of the todo
pub task: String,
/// due date of the todo
pub duedate: String,
/// user id of the todo
pub user_id: String,
}
#[derive(Debug, Args)]
pub struct UpdateTodo {
/// id of the todo
pub id: String,
/// title of the todo
pub title: String,
/// task of the todo
pub task: String,
/// due date of the todo
pub duedate: String,
/// user id of the todo
pub user_id: String
}
#[derive(Debug, Args)]
pub struct DeleteTodo {
/// id of the todo
pub id: String,
}
#[derive(Debug, Args)]
pub struct ReadTodoByUserId {
/// user id of the todo
pub user_id: String,
}
Here's what you gotta understand is that, the derive
features is used heavily in this code. So what I could tell you is that #[clap(subcommnad)]
tells you the field underneath it represents a subcommand when parsing command-line arguments.
Take a look at this snippet code that we wrote before:
File: src/args.rs
#[derive(Parser, Debug)]
#[clap(author, version, about)]
pub struct PagaweanArgs {
#[clap(subcommand)]
pub entity_type: EntityType,
}
By using this attribute, you're telling clap that the value provided for entity_type
should correspond to one of the subcommands defined in the EntityType
enum.
In this case, clap
will recognize that entity_type
is a subcommand field and expect the user to provide a subcommand, such as user
or todo
. Which is:
File: src/args.rs
#[derive(Debug, Subcommand)]
pub enum EntityType {
/// Create, Update, Delete, Read user
User(UserCommand),
/// Create, Update, Delete, Read todos
Todo(TodoCommand),
}
By defining a struct for this purpose, you are providing a clear and structured way for users to input the details of a new todo item from the command line.
File: src/args.rs
#[derive(Debug, Args)]
pub struct CreateTodo {
pub title: String,
pub task: String,
pub duedate: String,
pub user_id: String,
}
From the code above, when your program is executed with the appropriate command and arguments, clap will use the structure of this CreateTodo
struct to parse and validate the provided command-line inputs. This applies to other structs we wrote earlier.
Step 5: Integrating with SeaOrm
First we're gonna create a db.rs
that will establish a connection to our local database, so that when we create a handler, the db
module will connect to our local database.
Create a new file called db.rs
, then write this code:
File: src/db.rs
use std::time::Duration;
use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbErr, DbConn};
pub async fn establish_connection(database_uri: &str) -> Result<DbConn, DbErr>{
let mut opt = ConnectOptions::new(database_uri);
opt.max_connections(100)
.min_connections(5)
.idle_timeout(Duration::from_secs(8));
let database: Result<DatabaseConnection, DbErr> = Database::connect(opt).await;
match database {
Ok(db) => {
println!("Connected to database");
Ok(db.into())
},
Err(err) => {
println!("Failed to connect to database");
Err(err)
}
}
}
Next We're gonna create an ops as a handler that operate every time a certain command is called.
First let's create a new directory called ops
, inside that directory create files called todo_ops.rs
and user_ops.rs
. In order rust knows that there is a new module called ops
, we're gonna need to create mod.rs
. And inside that mod.rs
write this code:
File: src/ops/mod.rs
pub mod todo_ops;
pub mod user_ops;
You might still get a error squiggle, so add this code inside the src/main.rs
on the top of the file.
File: src/main.rs
mod ops;
Write user_ops
handler
In our user_ops.rs
, it will handle the UserCommand
and it will have a function that acts as an entry point that will responsible for a match enum based on the UserCommand
input whether it's CREATE or UPDATE or DELETE or SHOW.
File: src/ops/user_ops.rs
use dotenvy_macro::dotenv;
use sea_orm::{EntityTrait, Set, ActiveModelTrait, DeleteResult, DbConn, DbErr};
use uuid::Uuid;
use crate::db::{self, establish_connection};
use crate::args::{CreateUser, UpdateUser, DeleteEntity, UserSubCommand, UserCommand};
pub async fn handle_user_command(user: UserCommand) {
let command = user.command;
match command {
UserSubCommand::Create(user) => {
create_user(user).await;
},
UserSubCommand::Update(user) => {
update_user(user).await;
}
UserSubCommand::Delete(user) => {
delete_user(user).await;
}
UserSubCommand::Show => {
show_users().await;
}
}
}
async fn create_user(user: CreateUser) {
println!("Creating user: {:?}", user);
let database_uri = dotenv!("DATABASE_URL");
let db = db::establish_connection(database_uri).await;
let id = Uuid::new_v4();
let new_user = entity::user::ActiveModel {
id: Set(id.to_string()),
name: Set(user.name),
email: Set(user.email),
..Default::default()
};
match db {
Ok(db) => {
let user = new_user.insert(&db).await.unwrap();
println!("User created: {:?}", user);
},
Err(err) => {
eprint!("Failed to establish a database connection and create user: {}", err)
}
}
}
async fn update_user(user: UpdateUser) {
println!("Updating user: {:?}", user);
let database_uri = dotenv!("DATABASE_URL");
let db = db::establish_connection(database_uri).await;
let id = Uuid::parse_str(&user.id).unwrap();
type UserModel = entity::user::Model;
match db {
Ok(db) => {
let find_user: Option<UserModel> = entity::user::Entity::find_by_id(id.clone())
.one(&db)
.await
.unwrap();
let user_active_model : entity::user::ActiveModel = find_user.unwrap().into();
let updated_user = entity::user::ActiveModel {
id: Set(id.to_string()),
name: Set(user.name),
email: Set(user.email),
..user_active_model
};
let updated_user: entity::user::Model = updated_user.update(&db).await.unwrap();
println!("User updated: {:?}", updated_user);
},
Err(err) => {
eprint!("Failed to establish a database connection and update user: {}", err)
}
}
}
async fn delete_user(user: DeleteEntity) {
println!("Deleting user: {:?}", user);
let database_uri = dotenv!("DATABASE_URL");
let db = establish_connection(database_uri).await;
let id = Uuid::parse_str(&user.id).unwrap();
match db {
Ok(db) => {
let res: DeleteResult = entity::user::Entity::delete_by_id(id.clone())
.exec(&db)
.await
.unwrap();
println!("User deleted: {:?}", res);
}
Err(err) => {
eprint!("Failed to establish a database connection and delete user: {}", err)
}
}
}
async fn show_users() {
println!("Showing users");
let database_uri = dotenv!("DATABASE_URL");
let db: Result<DbConn, DbErr> = establish_connection(database_uri).await;
match db {
Ok(db) => {
let users: Vec<entity::user::Model> = entity::user::Entity::find()
.all(&db)
.await
.unwrap();
println!("Users: {:?}", users);
},
Err(err) => {
eprint!("Failed to establish a database connection and show users: {}", err)
}
}
}
Most of the code we wrote in user_ops.rs
has the same pattern. we establish database connection, then if we try to find/read first to the database then wanna do something to it we're gonna use an Entity
to do database stuff then using ActiveModel
if we want to have ActiveValue
from it.
Write todo_ops
handler
In our todo_ops.rs
, it will handle the TodoCommand
and it will have a function that acts as an entry point that will responsible for a match enum based on the TodoCommand
input whether it's CREATE or UPDATE or DELETE or READ, READBYUSERID.
Based on my explanation, You know I just got a bit tired right?HAHAHAA. But I assure you that it's not so much different with user_ops.rs
Maybe there's a few I want to add that you gotta pay attention with Set()
whenever we want to update
or create
an ActiveModel
.
Write helper
module
This is actually not critical, but in my case I want to have a function that I'm gonna reuse it many times so I create a helper
module to parse a date.
File: src/helper/parse_date.rs
use chrono::NaiveDateTime;
// parse the date string into a NaiveDate
pub fn parse_date(date: &str) -> NaiveDateTime {
let date: NaiveDateTime = NaiveDateTime::parse_from_str(date, "%Y-%m-%d %H:%M:%S").unwrap();
date
}
Also don't forget to create mod.rs
inside helper
module and make it public.
File: src/helper/mod.rs
pub mod parse_date;
Step 6: Bringing it all together
Now we finally will bring it all into one place which is src/main.rs
. Inside here gonna be a main entry from all the input command line we just created. We're gonna parse the arguments and will be using pattern matching based on the entity type
.
File: src/main.rs
mod args;
mod db;
mod ops;
mod helper;
use args::EntityType;
use args::PagaweanArgs;
use clap::Parser;
use ops::{todo_ops, user_ops};
#[tokio::main]
async fn main() {
let args = PagaweanArgs::parse();
match args.entity_type {
EntityType::User(user) => user_ops::handle_user_command(user).await,
EntityType::Todo(todo) => todo_ops::handle_todo_command(todo).await
}
}
The magic is that clap will parse then handle it based on the enum SubCommand inside the pattern match
-ing. The other magic is we're using [tokio::main]
to have a await
feature or asynchronous function.
Done. That is it. I know it's not a perfect code but I hope you'll get something from it. This post is not for you only to learn but also for me since I'm forgetful :) Definitely I'm gonna look at this post and try to relearn.
LET'S BECOME A CRAB MASTER!!!π¦
Top comments (0)