feat(examples): add PostgreSQL example with user relationship

Adds an example demonstrating user, comment, and follower relationship
including:
- User management with profiles
- Comments (not really useful, just for showcasing)
- Follower/follozing relationships
- Ineractive CLI interface with CRUD operations
- Database migrations for the example schema
This commit is contained in:
2025-06-05 23:56:15 +02:00
parent 9e56952dc6
commit 190c4d7b1d
23 changed files with 1199 additions and 85 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "georm-users-comments-and-followers"
workspace = "../../../"
publish = false
version.workspace = true
edition.workspace = true
[dependencies]
georm = { path = "../../.." }
sqlx = { workspace = true }
clap = { version = "4.4", features = ["derive"] }
inquire = "0.7.5"
thiserror = "2.0.11"
tokio = { version = "1.43.0", features = ["full"] }

View File

@@ -0,0 +1,129 @@
use super::{Executable, Result};
use crate::{
errors::UserInputError,
models::{Comment, CommentDefault, User},
};
use clap::{Args, Subcommand};
use georm::{Defaultable, Georm};
use std::collections::HashMap;
#[derive(Debug, Args, Clone)]
pub struct CommentArgs {
#[command(subcommand)]
pub command: CommentCommand,
}
impl Executable for CommentArgs {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
self.command.execute(pool).await
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum CommentCommand {
Create {
text: Option<String>,
username: Option<String>,
},
Remove {
id: Option<i32>,
},
RemoveFromUser {
username: Option<String>,
},
ListFromUser {
username: Option<String>,
},
List,
}
impl Executable for CommentCommand {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
match self {
CommentCommand::Create { text, username } => {
create_comment(username.clone(), text.clone(), pool).await
}
CommentCommand::Remove { id } => remove_comment(*id, pool).await,
CommentCommand::RemoveFromUser { username } => {
remove_user_comment(username.clone(), pool).await
}
CommentCommand::ListFromUser { username } => {
list_user_comments(username.clone(), pool).await
}
CommentCommand::List => list_comments(pool).await,
}
}
}
async fn create_comment(
username: Option<String>,
text: Option<String>,
pool: &sqlx::PgPool,
) -> Result {
let prompt = "Who is creating the comment?";
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
let content = match text {
Some(text) => text,
None => inquire::Text::new("Content of the comment:")
.prompt()
.map_err(UserInputError::InquireError)?,
};
let comment = CommentDefault {
author_id: user.id,
content,
id: None,
};
let comment = comment.create(pool).await?;
println!("Successfuly created comment:\n{comment}");
Ok(())
}
async fn remove_comment(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
let prompt = "Select the comment to remove:";
let comment = match id {
Some(id) => Comment::find(pool, &id)
.await
.map_err(UserInputError::DatabaseError)?
.ok_or(UserInputError::CommentDoesNotExist)?,
None => Comment::select_comment(prompt, pool).await?,
};
comment.delete(pool).await?;
Ok(())
}
async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> Result {
let prompt = "Select user whose comment you want to delete:";
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
let comments: HashMap<String, Comment> = user
.get_comments(pool)
.await?
.into_iter()
.map(|comment| (comment.content.clone(), comment))
.collect();
let selected_comment_content =
inquire::Select::new(prompt, comments.clone().into_keys().collect())
.prompt()
.map_err(UserInputError::InquireError)?;
let comment: &Comment = comments.get(&selected_comment_content).unwrap();
comment.delete(pool).await?;
Ok(())
}
async fn list_user_comments(username: Option<String>, pool: &sqlx::PgPool) -> Result {
let prompt = "User whose comment you want to list:";
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
println!("List of comments from user:\n");
for comment in user.get_comments(pool).await? {
println!("{comment}\n");
}
Ok(())
}
async fn list_comments(pool: &sqlx::PgPool) -> Result {
let comments = Comment::find_all(pool).await?;
println!("List of all comments:\n");
for comment in comments {
println!("{comment}\n")
}
Ok(())
}

View File

@@ -0,0 +1,134 @@
use super::{Executable, Result};
use crate::models::{FollowerDefault, User};
use clap::{Args, Subcommand};
use georm::Defaultable;
use std::collections::HashMap;
#[derive(Debug, Args, Clone)]
pub struct FollowersArgs {
#[command(subcommand)]
pub command: FollowersCommand,
}
impl Executable for FollowersArgs {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
self.command.execute(pool).await
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum FollowersCommand {
Follow {
follower: Option<String>,
followed: Option<String>,
},
Unfollow {
follower: Option<String>,
},
ListFollowers {
user: Option<String>,
},
ListFollowed {
user: Option<String>,
},
}
impl Executable for FollowersCommand {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
match self {
FollowersCommand::Follow { follower, followed } => {
follow_user(follower.clone(), followed.clone(), pool).await
}
FollowersCommand::Unfollow { follower } => unfollow_user(follower.clone(), pool).await,
FollowersCommand::ListFollowers { user } => {
list_user_followers(user.clone(), pool).await
}
FollowersCommand::ListFollowed { user } => list_user_followed(user.clone(), pool).await,
}
}
}
async fn follow_user(
follower: Option<String>,
followed: Option<String>,
pool: &sqlx::PgPool,
) -> Result {
let follower = User::get_user_by_username_or_select(
follower.as_deref(),
"Select who will be following someone:",
pool,
)
.await?;
let followed = User::get_user_by_username_or_select(
followed.as_deref(),
"Select who will be followed:",
pool,
)
.await?;
let follow = FollowerDefault {
id: None,
follower: follower.id,
followed: followed.id,
};
follow.create(pool).await?;
println!("User {follower} now follows {followed}");
Ok(())
}
async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result {
let follower =
User::get_user_by_username_or_select(follower.as_deref(), "Select who is following", pool)
.await?;
let followed_list: HashMap<String, User> = follower
.get_followed(pool)
.await?
.iter()
.map(|person| (person.username.clone(), person.clone()))
.collect();
let followed = inquire::Select::new(
"Who to unfollow?",
followed_list.clone().into_keys().collect(),
)
.prompt()
.unwrap();
let followed = followed_list.get(&followed).unwrap();
sqlx::query!(
"DELETE FROM Followers WHERE follower = $1 AND followed = $2",
follower.id,
followed.id
)
.execute(pool)
.await?;
println!("User {follower} unfollowed {followed}");
Ok(())
}
async fn list_user_followers(user: Option<String>, pool: &sqlx::PgPool) -> Result {
let user = User::get_user_by_username_or_select(
user.as_deref(),
"Whose followers do you want to display?",
pool,
)
.await?;
println!("List of followers of {user}:\n");
user.get_followers(pool)
.await?
.iter()
.for_each(|person| println!("{person}"));
Ok(())
}
async fn list_user_followed(user: Option<String>, pool: &sqlx::PgPool) -> Result {
let user = User::get_user_by_username_or_select(
user.as_deref(),
"Whose follows do you want to display?",
pool,
)
.await?;
println!("List of people followed by {user}:\n");
user.get_followed(pool)
.await?
.iter()
.for_each(|person| println!("{person}"));
Ok(())
}

View File

@@ -0,0 +1,40 @@
use clap::{Parser, Subcommand};
mod comments;
mod followers;
mod users;
type Result = crate::Result<()>;
pub trait Executable {
async fn execute(&self, pool: &sqlx::PgPool) -> Result;
}
#[derive(Debug, Clone, Parser)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
impl Executable for Cli {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
self.command.execute(pool).await
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum Commands {
Users(users::UserArgs),
Followers(followers::FollowersArgs),
Comments(comments::CommentArgs),
}
impl Executable for Commands {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
match self {
Commands::Users(user_args) => user_args.execute(pool).await,
Commands::Followers(followers_args) => followers_args.execute(pool).await,
Commands::Comments(comment_args) => comment_args.execute(pool).await,
}
}
}

View File

@@ -0,0 +1,113 @@
use super::{Executable, Result};
use crate::{errors::UserInputError, models::User};
use clap::{Args, Subcommand};
use georm::Georm;
use inquire::{max_length, min_length, required};
#[derive(Debug, Args, Clone)]
pub struct UserArgs {
#[command(subcommand)]
pub command: UserCommand,
}
impl Executable for UserArgs {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
self.command.execute(pool).await
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum UserCommand {
Add { username: Option<String> },
Remove { id: Option<i32> },
UpdateProfile { id: Option<i32> },
List,
}
impl Executable for UserCommand {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
match self {
UserCommand::Add { username } => add_user(username.clone(), pool).await,
UserCommand::Remove { id } => remove_user(*id, pool).await,
UserCommand::UpdateProfile { id } => update_profile(*id, pool).await,
UserCommand::List => list_all(pool).await,
}
}
}
async fn add_user(username: Option<String>, pool: &sqlx::PgPool) -> Result {
let username = match username {
Some(username) => username,
None => inquire::Text::new("Enter a username:")
.prompt()
.map_err(|_| UserInputError::InputRequired)?,
};
let user = User::try_new(&username, pool).await?;
println!("The user {user} has been created!");
Ok(())
}
async fn remove_user(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
let user = User::remove_interactive(id, pool).await?;
println!("Removed user {user} from database");
Ok(())
}
async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
let (user, mut profile) = User::update_profile(id, pool).await?;
let update_display_name = inquire::Confirm::new(
format!(
"Your current display name is \"{}\", do you want to update it?",
profile.get_display_name()
)
.as_str(),
)
.with_default(false)
.prompt()
.map_err(UserInputError::InquireError)?;
let display_name = if update_display_name {
Some(
inquire::Text::new("New display name:")
.with_help_message("Your display name should not exceed 100 characters")
.with_validator(min_length!(3))
.with_validator(max_length!(100))
.with_validator(required!())
.prompt()
.map_err(UserInputError::InquireError)?,
)
} else {
Some(profile.get_display_name())
};
let update_bio = inquire::Confirm::new(
format!(
"Your current bio is:\n===\n{}\n===\nDo you want to update it?",
profile.get_bio()
)
.as_str(),
)
.with_default(false)
.prompt()
.map_err(UserInputError::InquireError)?;
let bio = if update_bio {
Some(
inquire::Text::new("New bio:")
.with_validator(min_length!(0))
.prompt()
.map_err(UserInputError::InquireError)?,
)
} else {
Some(profile.get_bio())
};
let profile = profile.update_interactive(display_name, bio, pool).await?;
println!("Profile of {user} updated:\n{profile}");
Ok(())
}
async fn list_all(pool: &sqlx::PgPool) -> Result {
let users = User::find_all(pool).await?;
println!("List of users:\n");
for user in users {
println!("{user}");
}
Ok(())
}

View File

@@ -0,0 +1,15 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UserInputError {
#[error("Input required")]
InputRequired,
#[error("User ID does not exist")]
UserDoesNotExist,
#[error("Comment does not exist")]
CommentDoesNotExist,
#[error("Unexpected error, please try again")]
InquireError(#[from] inquire::error::InquireError),
#[error("Error from database: {0}")]
DatabaseError(#[from] sqlx::Error),
}

View File

@@ -0,0 +1,20 @@
mod cli;
mod errors;
mod models;
use clap::Parser;
use cli::{Cli, Executable};
type Result<T> = std::result::Result<T, errors::UserInputError>;
#[tokio::main]
async fn main() {
let args = Cli::parse();
let url = std::env::var("DATABASE_URL").expect("Environment variable DATABASE_URL must be set");
let pool =
sqlx::PgPool::connect_lazy(url.as_str()).expect("Failed to create database connection");
match args.command.execute(&pool).await {
Ok(_) => {}
Err(e) => eprintln!("Error: {e}"),
}
}

View File

@@ -0,0 +1,43 @@
use super::User;
use crate::{Result, errors::UserInputError};
use georm::Georm;
use std::collections::HashMap;
#[derive(Debug, Georm, Clone)]
#[georm(table = "Comments")]
pub struct Comment {
#[georm(id, defaultable)]
pub id: i32,
#[georm(relation = {
entity = User,
table = "Users",
name = "author"
})]
pub author_id: i32,
pub content: String,
}
impl Comment {
pub async fn select_comment(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
let comments: HashMap<String, Self> = Self::find_all(pool)
.await?
.into_iter()
.map(|comment| (comment.content.clone(), comment))
.collect();
let comment_content = inquire::Select::new(prompt, comments.clone().into_keys().collect())
.prompt()
.map_err(UserInputError::InquireError)?;
let comment: &Self = comments.get(&comment_content).unwrap();
Ok(comment.clone())
}
}
impl std::fmt::Display for Comment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Comment:\nID:\t{}\nAuthor:\t{}\nContent:\t{}",
self.id, self.author_id, self.content
)
}
}

View File

@@ -0,0 +1,21 @@
use super::User;
use georm::Georm;
#[derive(Debug, Clone, Georm)]
#[georm(table = "Followers")]
pub struct Follower {
#[georm(id, defaultable)]
pub id: i32,
#[georm(relation = {
entity = User,
table = "Users",
name = "followed"
})]
pub followed: i32,
#[georm(relation = {
entity = User,
table = "Users",
name = "follower"
})]
pub follower: i32,
}

View File

@@ -0,0 +1,8 @@
mod users;
pub use users::*;
mod profiles;
pub use profiles::*;
mod comments;
pub use comments::*;
mod followers;
pub use followers::*;

View File

@@ -0,0 +1,66 @@
use super::User;
use crate::{Result, errors::UserInputError};
use georm::{Defaultable, Georm};
#[derive(Debug, Georm, Default)]
#[georm(table = "Profiles")]
pub struct Profile {
#[georm(id, defaultable)]
pub id: i32,
#[georm(relation = {
entity = User,
table = "Users",
name = "user",
nullable = false
})]
pub user_id: i32,
pub bio: Option<String>,
pub display_name: Option<String>,
}
impl std::fmt::Display for Profile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Display Name:\t{}\nBiography:\n{}\n",
self.get_display_name(),
self.get_bio()
)
}
}
impl Profile {
pub fn get_display_name(&self) -> String {
self.display_name.clone().unwrap_or_default()
}
pub fn get_bio(&self) -> String {
self.bio.clone().unwrap_or_default()
}
pub async fn try_new(user_id: i32, pool: &sqlx::PgPool) -> Result<Self> {
let profile = ProfileDefault {
user_id,
id: None,
bio: None,
display_name: None,
};
profile
.create(pool)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn update_interactive(
&mut self,
display_name: Option<String>,
bio: Option<String>,
pool: &sqlx::PgPool,
) -> Result<Self> {
self.display_name = display_name;
self.bio = bio;
self.update(pool)
.await
.map_err(UserInputError::DatabaseError)
}
}

View File

@@ -0,0 +1,128 @@
use std::collections::HashMap;
use crate::{Result, errors::UserInputError};
use georm::{Defaultable, Georm};
use super::{Comment, Profile};
#[derive(Debug, Georm, Clone)]
#[georm(
table = "Users",
one_to_one = [{
name = "profile", remote_id = "user_id", table = "Profiles", entity = Profile
}],
one_to_many = [{
name = "comments", remote_id = "author_id", table = "Comments", entity = Comment
}],
many_to_many = [{
name = "followers",
table = "Users",
entity = User,
link = { table = "Followers", from = "followed", to = "follower" }
},
{
name = "followed",
table = "Users",
entity = User,
link = { table = "Followers", from = "follower", to = "followed" }
}
]
)]
pub struct User {
#[georm(id, defaultable)]
pub id: i32,
pub username: String,
}
impl std::fmt::Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (ID: {})", self.username, self.id)
}
}
impl From<&str> for UserDefault {
fn from(value: &str) -> Self {
Self {
id: None,
username: value.to_string(),
}
}
}
impl User {
async fn select_user(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
let users: HashMap<String, Self> = Self::find_all(pool)
.await?
.into_iter()
.map(|user| (user.username.clone(), user))
.collect();
let username = inquire::Select::new(prompt, users.clone().into_keys().collect())
.prompt()
.map_err(UserInputError::InquireError)?;
let user: &Self = users.get(&username).unwrap();
Ok(user.clone())
}
pub async fn get_user_by_id_or_select(
id: Option<i32>,
prompt: &str,
pool: &sqlx::PgPool,
) -> Result<Self> {
let user = match id {
Some(id) => Self::find(pool, &id)
.await?
.ok_or(UserInputError::UserDoesNotExist)?,
None => Self::select_user(prompt, pool).await?,
};
Ok(user)
}
pub async fn get_user_by_username_or_select(
username: Option<&str>,
prompt: &str,
pool: &sqlx::PgPool,
) -> Result<Self> {
let user = match username {
Some(username) => Self::find_by_username(username, pool)
.await?
.ok_or(UserInputError::UserDoesNotExist)?,
None => Self::select_user(prompt, pool).await?,
};
Ok(user)
}
pub async fn find_by_username(username: &str, pool: &sqlx::PgPool) -> Result<Option<Self>> {
sqlx::query_as!(
Self,
"SELECT * FROM Users u WHERE u.username = $1",
username
)
.fetch_optional(pool)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn try_new(username: &str, pool: &sqlx::PgPool) -> Result<Self> {
let user = UserDefault::from(username);
user.create(pool)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn remove_interactive(id: Option<i32>, pool: &sqlx::PgPool) -> Result<Self> {
let prompt = "Select a user to delete:";
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
let _ = user.clone().delete(pool).await?;
Ok(user)
}
pub async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result<(User, Profile)> {
let prompt = "Select the user whose profile you want to update";
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
let profile = match user.get_profile(pool).await? {
Some(profile) => profile,
None => Profile::try_new(user.id, pool).await?,
};
Ok((user, profile))
}
}