//! Loot-a-lot Database module //! //! # Description //! This module wraps all needed database operations. //! It exports a public API for integration with various clients (REST Api, CLI, ...) extern crate dotenv; #[macro_use] extern crate diesel; #[macro_use] extern crate serde_derive; use diesel::prelude::*; use diesel::query_dsl::RunQueryDsl; use diesel::r2d2::{self, ConnectionManager}; pub mod models; mod schema; /// The connection used pub type DbConnection = SqliteConnection; /// A pool of connections pub type Pool = r2d2::Pool>; /// The result of a query on DB pub type QueryResult = Result; /// The result of an action provided by DbApi pub type ActionResult = QueryResult>; /// Return status of an API Action #[derive(Serialize, Debug)] pub struct ActionStatus { /// Has the action made changes ? pub executed: bool, /// Response payload pub response: R, } impl ActionStatus<()> { fn was_updated(updated_lines: usize) -> Self { match updated_lines { 1 => Self::ok(), _ => Self::nop(), } } fn ok() -> ActionStatus<()> { Self { executed: true, response: (), } } fn nop() -> ActionStatus<()> { Self { executed: false, response: (), } } } /// A wrapper providing an API over the database /// It offers a convenient way to deal with connection /// /// # Todo list /// ```text /// struct DbApi<'q>(&'q DbConnection); /// ::new() -> DbApi<'q> (Db finds a connection by itself, usefull for cli) /// ::with_conn(conn) -> DbApi<'q> (uses a user-defined connection) /// v .fetch_players() /// v .fetch_inventory() /// v .fetch_claims() /// v .as_player(player_id) -> AsPlayer<'q> /// v .loot() -> List of items owned (Vec) /// v .claim(loot_id) -> Success status (bool) /// v .unclaim(loot_id) -> Success status (bool) /// x .sell(loot_id) -> Success status (bool, earned) /// x .buy(item_desc) -> Success status (bool, cost) /// v .update_wealth(value_in_gold) -> Success status (bool, new_wealth) /// v .as_admin() /// x .add_loot(identifier, [items_desc]) -> Success status /// x .sell_loot([players], [excluded_item_ids]) -> Success status (bool, player_share) /// x .resolve_claims() /// v .add_player(player_data) /// ``` /// pub struct DbApi<'q>(&'q DbConnection); impl<'q> DbApi<'q> { /// Returns a DbApi using the user given connection /// /// # Usage /// ``` /// use lootalot_db::{DbConnection, DbApi}; /// # use diesel::connection::Connection; /// let conn = DbConnection::establish(":memory:").unwrap(); /// let api = DbApi::with_conn(&conn); /// ``` pub fn with_conn(conn: &'q DbConnection) -> Self { Self(conn) } /// Fetch the list of all players pub fn fetch_players(self) -> QueryResult> { Ok(schema::players::table.load::(self.0)?) } /// Fetch the inventory of items pub fn fetch_inventory(self) -> QueryResult> { Ok(schema::items::table.load::(self.0)?) } /// Fetch all existing claims pub fn fetch_claims(self) -> QueryResult> { Ok(schema::claims::table.load::(self.0)?) } /// Wrapper for acting as a specific player /// /// # Usage /// ``` /// # use lootalot_db::{DbConnection, DbApi}; /// # use diesel::connection::Connection; /// # let conn = DbConnection::establish(":memory:").unwrap(); /// # let api = DbApi::with_conn(&conn); /// let player_id: i32 = 1; // Id that references player in DB /// let player = api.as_player(player_id); /// ``` pub fn as_player(self, id: i32) -> AsPlayer<'q> { AsPlayer { id, conn: self.0 } } /// Wrapper for acting as the admin pub fn as_admin(self) -> AsAdmin<'q> { AsAdmin(self.0) } } /// A wrapper for interactions of players with the database. /// Possible actions are exposed as methods pub struct AsPlayer<'q> { id: i32, conn: &'q DbConnection, } impl<'q> AsPlayer<'q> { /// Fetch the content of a player's chest /// /// # Usage /// ``` /// # extern crate diesel_migrations; /// # use lootalot_db::{DbConnection, DbApi}; /// # use diesel::connection::Connection; /// # let conn = DbConnection::establish(":memory:").unwrap(); /// # diesel_migrations::run_pending_migrations(&conn).unwrap(); /// # let api = DbApi::with_conn(&conn); /// // Get loot of player with id of 1 /// let loot = api.as_player(1).loot().unwrap(); /// assert_eq!(format!("{:?}", loot), "[]".to_string()); /// ``` pub fn loot(self) -> QueryResult> { Ok(models::Item::owned_by(self.id).load(self.conn)?) } /// Adds the value in gold to the player's wealth. /// /// Value can be negative to substract wealth. pub fn update_wealth(self, value_in_gp: f32) -> ActionResult> { use schema::players::dsl::*; let current_wealth = players .find(self.id) .select((cp, sp, gp, pp)) .first::(self.conn)?; // TODO: improve this // should be move inside a WealthUpdate method let updated_wealth = models::Wealth::from_gp(current_wealth.to_gp() + value_in_gp); // Difference in coins that is sent back let (old, new) = (current_wealth.as_tuple(), updated_wealth.as_tuple()); let diff = (new.0 - old.0, new.1 - old.1, new.2 - old.2, new.3 - old.3); diesel::update(players) .filter(id.eq(self.id)) .set(&updated_wealth) .execute(self.conn) .map(|r| match r { 1 => ActionStatus { executed: true, response: Some(diff), }, _ => ActionStatus { executed: false, response: None, }, }) } /// Put a claim on a specific item pub fn claim(self, item: i32) -> ActionResult<()> { // TODO: check that looted item exists let exists: bool = diesel::select(models::Loot::exists(item)).get_result(self.conn)?; if !exists { return Ok(ActionStatus::nop()); }; let claim = models::claim::NewClaim::new(self.id, item); diesel::insert_into(schema::claims::table) .values(&claim) .execute(self.conn) .map(ActionStatus::was_updated) } /// Withdraw claim pub fn unclaim(self, item: i32) -> ActionResult<()> { use schema::claims::dsl::*; diesel::delete( claims.filter(loot_id.eq(item)) .filter(player_id.eq(self.id)), ) .execute(self.conn) .map(ActionStatus::was_updated) } } /// Wrapper for interactions of admins with the DB. pub struct AsAdmin<'q>(&'q DbConnection); impl<'q> AsAdmin<'q> { /// Adds a player to the database /// /// Takes the player name and starting wealth (in gold value). pub fn add_player(self, name: String, start_wealth: f32) -> ActionResult<()> { diesel::insert_into(schema::players::table) .values(&models::player::NewPlayer::create(&name, start_wealth)) .execute(self.0) .map(ActionStatus::was_updated) } /// Resolve all pending claims and dispatch claimed items. /// /// When a player gets an item, it's debt is increased by this item sell value pub fn resolve_claims(self) -> ActionResult<()> { // Fetch all claims, grouped by items. let loot = models::Loot::owned_by(0).load(self.0)?; let claims = schema::claims::table .load::(self.0)? .grouped_by(&loot); // For each claimed item let data = loot.into_iter().zip(claims).collect::>(); dbg!(data); // If mutiples claims -> find highest resolve, give to this player // If only one claim -> give to claiming Ok(ActionStatus::nop()) } } /// Sets up a connection pool and returns it. /// Uses the DATABASE_URL environment variable (must be set) pub fn create_pool() -> Pool { let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL"); dbg!(&connspec); let manager = ConnectionManager::::new(connspec); r2d2::Pool::builder() .build(manager) .expect("Failed to create pool.") } #[cfg(test)] mod tests { use super::*; type TestConnection = DbConnection; /// Return a connection to a fresh database (stored in memory) fn test_connection() -> TestConnection { let test_conn = DbConnection::establish(":memory:").unwrap(); diesel_migrations::run_pending_migrations(&test_conn).unwrap(); test_conn } /// When migrations are run, a special player with id 0 and name "Groupe" /// must be created. #[test] fn test_group_is_autocreated() { let conn = test_connection(); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); assert_eq!(players.len(), 1); let group = players.get(0).unwrap(); assert_eq!(group.id, 0); assert_eq!(group.name, "Groupe".to_string()); } /// When a player updates wealth, a difference is returned by API. /// Added to the previous amount of coins, it should equal the updated weath. #[test] fn test_player_updates_wealth() { let conn = test_connection(); DbApi::with_conn(&conn).as_admin() .add_player("PlayerName".to_string(), 403.21) .unwrap(); let diff = DbApi::with_conn(&conn).as_player(1) .update_wealth(-401.21).unwrap() .response.unwrap(); // Check the returned diff assert_eq!(diff, (-1, -2, -1, -4)); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); let player = players.get(1).unwrap(); // Check that we can add old value to return diff to get resulting value assert_eq!( (player.cp, player.sp, player.gp, player.pp), (1 + diff.0, 2 + diff.1, 3 + diff.2, 4 + diff.3) ); } #[test] fn test_admin_add_player() { let conn = test_connection(); let result = DbApi::with_conn(&conn).as_admin() .add_player("PlayerName".to_string(), 403.21) .unwrap(); assert_eq!(result.executed, true); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); assert_eq!(players.len(), 2); let new_player = players.get(1).unwrap(); assert_eq!(new_player.name, "PlayerName"); assert_eq!((new_player.cp, new_player.sp, new_player.gp, new_player.pp), (1, 2, 3, 4)); } #[test] fn test_admin_resolve_claims() { } #[test] fn test_player_claim_item() { } #[test] fn test_player_unclaim_item() { } }