Working create_transaction endpoint

Creates the transaction record and updates the merchandise quantity in one db transaction.

Managed to do the merchandise update in one UPDATE query, but the error that's thrown when an item to buy is not found is pretty confusing, so I convert it to a 404.

I also added some DB indexes.
This commit is contained in:
Tyler Hallada 2020-10-31 20:34:20 -04:00
parent c87c35021e
commit 08c8dcb07b
16 changed files with 249 additions and 167 deletions

View File

@ -34,15 +34,23 @@ CREATE TABLE "merchandise_lists" (
"created_at" timestamp(3) NOT NULL, "created_at" timestamp(3) NOT NULL,
"updated_at" timestamp(3) NOT NULL "updated_at" timestamp(3) NOT NULL
); );
CREATE INDEX "merchandise_lists_mod_name_and_local_form_id" ON "merchandise_lists" USING GIN (form_list jsonb_path_ops);
CREATE TABLE "transactions" ( CREATE TABLE "transactions" (
"id" SERIAL PRIMARY KEY NOT NULL, "id" SERIAL PRIMARY KEY NOT NULL,
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL UNIQUE, "shop_id" INTEGER REFERENCES "shops"(id) NOT NULL,
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL, "owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
"mod_name" VARCHAR(260) NOT NULL, "mod_name" VARCHAR(260) NOT NULL,
"local_form_id" INTEGER NOT NULL, "local_form_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"form_type" INTEGER NOT NULL,
"is_food" BOOLEAN NOT NULL,
"price" INTEGER NOT NULL,
"is_sell" BOOLEAN NOT NULL, "is_sell" BOOLEAN NOT NULL,
"quantity" INTEGER NOT NULL, "quantity" INTEGER NOT NULL,
"amount" INTEGER NOT NULL, "amount" INTEGER NOT NULL,
"created_at" timestamp(3) NOT NULL, "created_at" timestamp(3) NOT NULL,
"updated_at" timestamp(3) NOT NULL "updated_at" timestamp(3) NOT NULL
); );
CREATE INDEX "transactions_shop_id" ON "transactions" ("shop_id");
CREATE INDEX "transactions_owner_id" ON "transactions" ("owner_id");
CREATE INDEX "transactions_mod_name_and_local_form_id" ON "transactions" ("mod_name", "local_form_id");

View File

@ -2,4 +2,5 @@ DROP TABLE owners CASCADE;
DROP TABLE shops CASCADE; DROP TABLE shops CASCADE;
DROP TABLE interior_ref_lists CASCADE; DROP TABLE interior_ref_lists CASCADE;
DROP TABLE merchandise_lists CASCADE; DROP TABLE merchandise_lists CASCADE;
DROP TABLE transactions CASCADE;
DROP TABLE refinery_schema_history CASCADE; DROP TABLE refinery_schema_history CASCADE;

View File

@ -4,7 +4,7 @@ use uuid::Uuid;
use warp::reply::{json, with_header, with_status}; use warp::reply::{json, with_header, with_status};
use warp::{Rejection, Reply}; use warp::{Rejection, Reply};
use crate::models::{InteriorRefList, ListParams, Model, UpdateableModel}; use crate::models::{InteriorRefList, ListParams};
use crate::problem::reject_anyhow; use crate::problem::reject_anyhow;
use crate::Environment; use crate::Environment;

View File

@ -4,7 +4,7 @@ use uuid::Uuid;
use warp::reply::{json, with_header, with_status}; use warp::reply::{json, with_header, with_status};
use warp::{Rejection, Reply}; use warp::{Rejection, Reply};
use crate::models::{ListParams, MerchandiseList, MerchandiseParams, Model, UpdateableModel}; use crate::models::{ListParams, MerchandiseList};
use crate::problem::reject_anyhow; use crate::problem::reject_anyhow;
use crate::Environment; use crate::Environment;
@ -169,42 +169,3 @@ pub async fn delete(
env.caches.list_merchandise_lists.clear().await; env.caches.list_merchandise_lists.clear().await;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn buy_merchandise(
shop_id: i32,
merchandise_params: MerchandiseParams,
api_key: Option<Uuid>,
env: Environment,
) -> Result<impl Reply, Rejection> {
let _owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
// TODO: create transaction
let updated_merchandise_list = MerchandiseList::update_merchandise_quantity(
&env.db,
shop_id,
&(merchandise_params.mod_name),
merchandise_params.local_form_id,
merchandise_params.quantity_delta,
)
.await
.map_err(reject_anyhow)?;
let url = updated_merchandise_list
.url(&env.api_url)
.map_err(reject_anyhow)?;
let reply = json(&updated_merchandise_list);
let reply = with_header(reply, "Location", url.as_str());
let reply = with_status(reply, StatusCode::CREATED);
env.caches
.merchandise_list
.delete_response(
updated_merchandise_list
.id
.expect("saved merchandise_list has no id"),
)
.await;
env.caches
.merchandise_list_by_shop_id
.delete_response(updated_merchandise_list.shop_id)
.await;
env.caches.list_merchandise_lists.clear().await;
Ok(reply)
}

View File

@ -6,7 +6,7 @@ use uuid::Uuid;
use warp::reply::{json, with_header, with_status}; use warp::reply::{json, with_header, with_status};
use warp::{Rejection, Reply}; use warp::{Rejection, Reply};
use crate::models::{ListParams, Model, Owner, UpdateableModel}; use crate::models::{ListParams, Owner};
use crate::problem::{reject_anyhow, unauthorized_no_api_key}; use crate::problem::{reject_anyhow, unauthorized_no_api_key};
use crate::Environment; use crate::Environment;

View File

@ -5,7 +5,7 @@ use uuid::Uuid;
use warp::reply::{json, with_header, with_status}; use warp::reply::{json, with_header, with_status};
use warp::{Rejection, Reply}; use warp::{Rejection, Reply};
use crate::models::{InteriorRefList, ListParams, MerchandiseList, Model, Shop, UpdateableModel}; use crate::models::{InteriorRefList, ListParams, MerchandiseList, Shop};
use crate::problem::reject_anyhow; use crate::problem::reject_anyhow;
use crate::Environment; use crate::Environment;

View File

@ -1,10 +1,10 @@
use anyhow::Result; use anyhow::{anyhow, Result};
use http::StatusCode; use http::StatusCode;
use uuid::Uuid; use uuid::Uuid;
use warp::reply::{json, with_header, with_status}; use warp::reply::{json, with_header, with_status};
use warp::{Rejection, Reply}; use warp::{Rejection, Reply};
use crate::models::{ListParams, Model, Transaction}; use crate::models::{ListParams, MerchandiseList, Transaction};
use crate::problem::reject_anyhow; use crate::problem::reject_anyhow;
use crate::Environment; use crate::Environment;
@ -60,10 +60,35 @@ pub async fn create(
owner_id: Some(owner_id), owner_id: Some(owner_id),
..transaction ..transaction
}; };
let mut tx = env
.db
.begin()
.await
.map_err(|error| reject_anyhow(anyhow!(error)))?;
let saved_transaction = transaction_with_owner_id let saved_transaction = transaction_with_owner_id
.create(&env.db) .create(&mut tx)
.await .await
.map_err(reject_anyhow)?; .map_err(reject_anyhow)?;
let quantity_delta = match transaction.is_sell {
true => transaction.quantity,
false => transaction.quantity * -1,
};
let updated_merchandise_list = MerchandiseList::update_merchandise_quantity(
&mut tx,
saved_transaction.shop_id,
&(saved_transaction.mod_name),
saved_transaction.local_form_id,
&(saved_transaction.name),
saved_transaction.form_type,
saved_transaction.is_food,
saved_transaction.price,
quantity_delta,
)
.await
.map_err(reject_anyhow)?;
tx.commit()
.await
.map_err(|error| reject_anyhow(anyhow!(error)))?;
let url = saved_transaction.url(&env.api_url).map_err(reject_anyhow)?; let url = saved_transaction.url(&env.api_url).map_err(reject_anyhow)?;
let reply = json(&saved_transaction); let reply = json(&saved_transaction);
let reply = with_header(reply, "Location", url.as_str()); let reply = with_header(reply, "Location", url.as_str());
@ -71,6 +96,19 @@ pub async fn create(
// TODO: will this make these caches effectively useless? // TODO: will this make these caches effectively useless?
env.caches.list_transactions.clear().await; env.caches.list_transactions.clear().await;
env.caches.list_transactions_by_shop_id.clear().await; env.caches.list_transactions_by_shop_id.clear().await;
env.caches
.merchandise_list
.delete_response(
updated_merchandise_list
.id
.expect("saved merchandise_list has no id"),
)
.await;
env.caches
.merchandise_list_by_shop_id
.delete_response(updated_merchandise_list.shop_id)
.await;
env.caches.list_merchandise_lists.clear().await;
Ok(reply) Ok(reply)
} }
@ -80,7 +118,6 @@ pub async fn delete(
env: Environment, env: Environment,
) -> Result<impl Reply, Rejection> { ) -> Result<impl Reply, Rejection> {
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?; let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
let transaction = Transaction::get(&env.db, id).await.map_err(reject_anyhow)?;
Transaction::delete(&env.db, owner_id, id) Transaction::delete(&env.db, owner_id, id)
.await .await
.map_err(reject_anyhow)?; .map_err(reject_anyhow)?;

View File

@ -19,9 +19,7 @@ mod models;
mod problem; mod problem;
use caches::Caches; use caches::Caches;
use models::{ use models::{InteriorRefList, ListParams, MerchandiseList, Owner, Shop, Transaction};
InteriorRefList, ListParams, MerchandiseList, MerchandiseParams, Owner, Shop, Transaction,
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Environment { pub struct Environment {
@ -273,16 +271,6 @@ async fn main() -> Result<()> {
.and(with_env(env.clone())) .and(with_env(env.clone()))
.and_then(handlers::merchandise_list::get_by_shop_id), .and_then(handlers::merchandise_list::get_by_shop_id),
); );
let buy_merchandise_handler = warp::path("shops").and(
warp::path::param()
.and(warp::path("buy_merchandise"))
.and(warp::path::end())
.and(warp::post())
.and(warp::query::<MerchandiseParams>())
.and(warp::header::optional("api-key"))
.and(with_env(env.clone()))
.and_then(handlers::merchandise_list::buy_merchandise),
);
let get_transaction_handler = warp::path("transactions").and( let get_transaction_handler = warp::path("transactions").and(
warp::path::param() warp::path::param()
.and(warp::path::end()) .and(warp::path::end())
@ -341,7 +329,6 @@ async fn main() -> Result<()> {
update_interior_ref_list_by_shop_id_handler, update_interior_ref_list_by_shop_id_handler,
update_merchandise_list_by_shop_id_handler, update_merchandise_list_by_shop_id_handler,
list_transactions_by_shop_id_handler, list_transactions_by_shop_id_handler,
buy_merchandise_handler,
get_interior_ref_list_handler, get_interior_ref_list_handler,
delete_interior_ref_list_handler, delete_interior_ref_list_handler,
update_interior_ref_list_handler, update_interior_ref_list_handler,

View File

@ -1,13 +1,12 @@
use anyhow::{Error, Result}; use anyhow::{anyhow, Error, Result};
use async_trait::async_trait;
use chrono::prelude::*; use chrono::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use sqlx::types::Json; use sqlx::types::Json;
use sqlx::PgPool;
use tracing::instrument; use tracing::instrument;
use url::Url;
use super::ListParams; use super::ListParams;
use super::{Model, UpdateableModel};
use crate::problem::forbidden_permission; use crate::problem::forbidden_permission;
// sqlx queries for this model need to be `query_as_unchecked!` because `query_as!` does not // sqlx queries for this model need to be `query_as_unchecked!` because `query_as!` does not
@ -40,19 +39,29 @@ pub struct InteriorRefList {
pub updated_at: Option<NaiveDateTime>, pub updated_at: Option<NaiveDateTime>,
} }
#[async_trait] impl InteriorRefList {
impl Model for InteriorRefList { pub fn resource_name() -> &'static str {
fn resource_name() -> &'static str {
"interior_ref_list" "interior_ref_list"
} }
fn pk(&self) -> Option<i32> { pub fn pk(&self) -> Option<i32> {
self.id self.id
} }
pub fn url(&self, api_url: &Url) -> Result<Url> {
if let Some(pk) = self.pk() {
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), pk))?)
} else {
Err(anyhow!(
"Cannot get URL for {} with no primary key",
Self::resource_name()
))
}
}
// TODO: this model will probably never need to be accessed through it's ID, should these methods be removed/unimplemented? // TODO: this model will probably never need to be accessed through it's ID, should these methods be removed/unimplemented?
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn get(db: &PgPool, id: i32) -> Result<Self> { pub async fn get(db: &PgPool, id: i32) -> Result<Self> {
sqlx::query_as_unchecked!(Self, "SELECT * FROM interior_ref_lists WHERE id = $1", id) sqlx::query_as_unchecked!(Self, "SELECT * FROM interior_ref_lists WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await .await
@ -60,7 +69,7 @@ impl Model for InteriorRefList {
} }
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn create(self, db: &PgPool) -> Result<Self> { pub async fn create(self, db: &PgPool) -> Result<Self> {
// TODO: // TODO:
// * Decide if I'll need to make the same changes to merchandise and transactions // * Decide if I'll need to make the same changes to merchandise and transactions
// - answer depends on how many rows of each I expect to insert in one go // - answer depends on how many rows of each I expect to insert in one go
@ -80,7 +89,7 @@ impl Model for InteriorRefList {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> { pub async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
let interior_ref_list = let interior_ref_list =
sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id) sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
@ -97,7 +106,7 @@ impl Model for InteriorRefList {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> { pub async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
let result = if let Some(order_by) = list_params.get_order_by() { let result = if let Some(order_by) = list_params.get_order_by() {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Self, Self,
@ -125,12 +134,9 @@ impl Model for InteriorRefList {
}; };
Ok(result) Ok(result)
} }
}
#[async_trait]
impl UpdateableModel for InteriorRefList {
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> { pub async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
let interior_ref_list = let interior_ref_list =
sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id) sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
@ -152,9 +158,7 @@ impl UpdateableModel for InteriorRefList {
return Err(forbidden_permission()); return Err(forbidden_permission());
} }
} }
}
impl InteriorRefList {
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
pub async fn get_by_shop_id(db: &PgPool, shop_id: i32) -> Result<Self> { pub async fn get_by_shop_id(db: &PgPool, shop_id: i32) -> Result<Self> {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(

View File

@ -1,13 +1,16 @@
use anyhow::{Error, Result}; use anyhow::{anyhow, Context, Error, Result};
use async_trait::async_trait;
use chrono::prelude::*; use chrono::prelude::*;
use http::StatusCode;
use http_api_problem::HttpApiProblem;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use serde_json::json;
use sqlx::pool::PoolConnection;
use sqlx::types::Json; use sqlx::types::Json;
use sqlx::{PgConnection, PgPool, Transaction};
use tracing::instrument; use tracing::instrument;
use url::Url;
use super::ListParams; use super::ListParams;
use super::{Model, UpdateableModel};
use crate::problem::forbidden_permission; use crate::problem::forbidden_permission;
// sqlx queries for this model need to be `query_as_unchecked!` because `query_as!` does not // sqlx queries for this model need to be `query_as_unchecked!` because `query_as!` does not
@ -36,26 +39,29 @@ pub struct MerchandiseList {
pub updated_at: Option<NaiveDateTime>, pub updated_at: Option<NaiveDateTime>,
} }
#[derive(Debug, Eq, PartialEq, Hash, Clone, Deserialize)] impl MerchandiseList {
pub struct MerchandiseParams { pub fn resource_name() -> &'static str {
pub mod_name: String,
pub local_form_id: i32,
pub quantity_delta: i32,
}
#[async_trait]
impl Model for MerchandiseList {
fn resource_name() -> &'static str {
"merchandise_list" "merchandise_list"
} }
fn pk(&self) -> Option<i32> { pub fn pk(&self) -> Option<i32> {
self.id self.id
} }
pub fn url(&self, api_url: &Url) -> Result<Url> {
if let Some(pk) = self.pk() {
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), pk))?)
} else {
Err(anyhow!(
"Cannot get URL for {} with no primary key",
Self::resource_name()
))
}
}
// TODO: this model will probably never need to be accessed through it's ID, should these methods be removed/unimplemented? // TODO: this model will probably never need to be accessed through it's ID, should these methods be removed/unimplemented?
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn get(db: &PgPool, id: i32) -> Result<Self> { pub async fn get(db: &PgPool, id: i32) -> Result<Self> {
sqlx::query_as_unchecked!(Self, "SELECT * FROM merchandise_lists WHERE id = $1", id) sqlx::query_as_unchecked!(Self, "SELECT * FROM merchandise_lists WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await .await
@ -63,7 +69,7 @@ impl Model for MerchandiseList {
} }
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn create(self, db: &PgPool) -> Result<Self> { pub async fn create(self, db: &PgPool) -> Result<Self> {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
"INSERT INTO merchandise_lists "INSERT INTO merchandise_lists
@ -79,7 +85,7 @@ impl Model for MerchandiseList {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> { pub async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
let merchandise_list = let merchandise_list =
sqlx::query!("SELECT owner_id FROM merchandise_lists WHERE id = $1", id) sqlx::query!("SELECT owner_id FROM merchandise_lists WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
@ -96,7 +102,7 @@ impl Model for MerchandiseList {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> { pub async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
let result = if let Some(order_by) = list_params.get_order_by() { let result = if let Some(order_by) = list_params.get_order_by() {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Self, Self,
@ -124,12 +130,9 @@ impl Model for MerchandiseList {
}; };
Ok(result) Ok(result)
} }
}
#[async_trait]
impl UpdateableModel for MerchandiseList {
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> { pub async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
let merchandise_list = let merchandise_list =
sqlx::query!("SELECT owner_id FROM merchandise_lists WHERE id = $1", id) sqlx::query!("SELECT owner_id FROM merchandise_lists WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
@ -151,9 +154,7 @@ impl UpdateableModel for MerchandiseList {
return Err(forbidden_permission()); return Err(forbidden_permission());
} }
} }
}
impl MerchandiseList {
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
pub async fn get_by_shop_id(db: &PgPool, shop_id: i32) -> Result<Self> { pub async fn get_by_shop_id(db: &PgPool, shop_id: i32) -> Result<Self> {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
@ -195,26 +196,43 @@ impl MerchandiseList {
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
pub async fn update_merchandise_quantity( pub async fn update_merchandise_quantity(
db: &PgPool, db: &mut Transaction<PoolConnection<PgConnection>>,
shop_id: i32, shop_id: i32,
mod_name: &str, mod_name: &str,
local_form_id: i32, local_form_id: i32,
name: &str,
form_type: i32,
is_food: bool,
price: i32,
quantity_delta: i32, quantity_delta: i32,
) -> Result<Self> { ) -> Result<Self> {
let add_item = json!([{
"mod_name": mod_name,
"local_form_id": local_form_id,
"name": name,
"quantity": quantity_delta,
"form_type": form_type,
"is_food": is_food,
"price": price,
}]);
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
"UPDATE "UPDATE
merchandise_lists merchandise_lists
SET SET
form_list = CASE form_list = CASE
WHEN quantity::int + $4 = 0 WHEN elem_index IS NULL AND quantity IS NULL AND $4 > 0
THEN form_list || $5
WHEN elem_index IS NOT NULL AND quantity IS NOT NULL AND quantity::int + $4 = 0
THEN form_list - elem_index::int THEN form_list - elem_index::int
ELSE jsonb_set( WHEN elem_index IS NOT NULL AND quantity IS NOT NULL
form_list, THEN jsonb_set(
array[elem_index::text, 'quantity'], form_list,
to_jsonb(quantity::int + $4), array[elem_index::text, 'quantity'],
true to_jsonb(quantity::int + $4),
) true
)
ELSE NULL
END END
FROM ( FROM (
SELECT SELECT
@ -227,6 +245,10 @@ impl MerchandiseList {
shop_id = $1 AND shop_id = $1 AND
elem->>'mod_name' = $2::text AND elem->>'mod_name' = $2::text AND
elem->>'local_form_id' = $3::text elem->>'local_form_id' = $3::text
UNION ALL
SELECT
NULL as elem_index, NULL as quantity
LIMIT 1
) sub ) sub
WHERE WHERE
shop_id = $1 shop_id = $1
@ -235,8 +257,26 @@ impl MerchandiseList {
mod_name, mod_name,
local_form_id, local_form_id,
quantity_delta, quantity_delta,
add_item,
) )
.fetch_one(db) .fetch_one(db)
.await?) .await
.map_err(|error| {
let anyhow_error = anyhow!(error);
if let Some(sqlx::error::Error::Database(db_error)) =
anyhow_error.downcast_ref::<sqlx::error::Error>()
{
if db_error.code() == Some("23502") && db_error.column_name() == Some("form_list") {
return anyhow!(HttpApiProblem::with_title_and_type_from_status(
StatusCode::NOT_FOUND
)
.set_detail(format!(
"Cannot find merchandise to buy with mod_name: {} and local_form_id: {:#010X}",
mod_name, local_form_id
)));
}
}
anyhow_error
})?)
} }
} }

View File

@ -10,7 +10,7 @@ pub mod shop;
pub mod transaction; pub mod transaction;
pub use interior_ref_list::InteriorRefList; pub use interior_ref_list::InteriorRefList;
pub use merchandise_list::{MerchandiseList, MerchandiseParams}; pub use merchandise_list::MerchandiseList;
pub use model::{Model, UpdateableModel}; pub use model::{Model, UpdateableModel};
pub use owner::Owner; pub use owner::Owner;
pub use shop::Shop; pub use shop::Shop;

View File

@ -5,6 +5,14 @@ use url::Url;
use super::ListParams; use super::ListParams;
// TODO: I stopped using this because I needed to accept a transaction instead of a &PgPool for these methods on certain models.
// It would be nice to find a way to impl this trait for all my models so I don't have to keep redoing the `url` function on
// each. But, maybe I'm trying to use Traits in an OOP way and that's bad, idk.
//
// @NyxCode on discord: "on 0.4, you can use impl Executor<'_, Database = Postgres>. I use it everywhere, and it works for
// &PgPool, &mut PgConnection and &mut Transaction"
//
// I attempted to use `impl Executor<Database = Postgres>` in 0.3.5 but it created a recursive type error :(
#[async_trait] #[async_trait]
pub trait Model pub trait Model
where where
@ -34,4 +42,4 @@ where
Self: std::marker::Sized, Self: std::marker::Sized,
{ {
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self>; async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self>;
} }

View File

@ -1,14 +1,13 @@
use anyhow::{Error, Result}; use anyhow::{anyhow, Error, Result};
use async_trait::async_trait;
use chrono::prelude::*; use chrono::prelude::*;
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use sqlx::PgPool;
use tracing::instrument; use tracing::instrument;
use url::Url;
use uuid::Uuid; use uuid::Uuid;
use super::ListParams; use super::ListParams;
use super::{Model, UpdateableModel};
use crate::problem::forbidden_permission; use crate::problem::forbidden_permission;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@ -24,18 +23,28 @@ pub struct Owner {
pub updated_at: Option<NaiveDateTime>, pub updated_at: Option<NaiveDateTime>,
} }
#[async_trait] impl Owner {
impl Model for Owner { pub fn resource_name() -> &'static str {
fn resource_name() -> &'static str {
"owner" "owner"
} }
fn pk(&self) -> Option<i32> { pub fn pk(&self) -> Option<i32> {
self.id self.id
} }
pub fn url(&self, api_url: &Url) -> Result<Url> {
if let Some(pk) = self.pk() {
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), pk))?)
} else {
Err(anyhow!(
"Cannot get URL for {} with no primary key",
Self::resource_name()
))
}
}
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn get(db: &PgPool, id: i32) -> Result<Self> { pub async fn get(db: &PgPool, id: i32) -> Result<Self> {
sqlx::query_as!(Self, "SELECT * FROM owners WHERE id = $1", id) sqlx::query_as!(Self, "SELECT * FROM owners WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await .await
@ -43,7 +52,7 @@ impl Model for Owner {
} }
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn create(self, db: &PgPool) -> Result<Self> { pub async fn create(self, db: &PgPool) -> Result<Self> {
Ok(sqlx::query_as!( Ok(sqlx::query_as!(
Self, Self,
"INSERT INTO owners "INSERT INTO owners
@ -60,7 +69,7 @@ impl Model for Owner {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> { pub async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
let owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id) let owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await?; .await?;
@ -74,7 +83,7 @@ impl Model for Owner {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> { pub async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
let result = if let Some(order_by) = list_params.get_order_by() { let result = if let Some(order_by) = list_params.get_order_by() {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
@ -102,12 +111,9 @@ impl Model for Owner {
}; };
Ok(result) Ok(result)
} }
}
#[async_trait]
impl UpdateableModel for Owner {
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> { pub async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
let owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id) let owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await?; .await?;
@ -130,4 +136,4 @@ impl UpdateableModel for Owner {
return Err(forbidden_permission()); return Err(forbidden_permission());
} }
} }
} }

View File

@ -1,12 +1,11 @@
use anyhow::{Error, Result}; use anyhow::{anyhow, Error, Result};
use async_trait::async_trait;
use chrono::prelude::*; use chrono::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use sqlx::PgPool;
use tracing::instrument; use tracing::instrument;
use url::Url;
use super::ListParams; use super::ListParams;
use super::{Model, UpdateableModel};
use crate::problem::forbidden_permission; use crate::problem::forbidden_permission;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@ -24,18 +23,28 @@ pub struct Shop {
pub updated_at: Option<NaiveDateTime>, pub updated_at: Option<NaiveDateTime>,
} }
#[async_trait] impl Shop {
impl Model for Shop { pub fn resource_name() -> &'static str {
fn resource_name() -> &'static str {
"shop" "shop"
} }
fn pk(&self) -> Option<i32> { pub fn pk(&self) -> Option<i32> {
self.id self.id
} }
pub fn url(&self, api_url: &Url) -> Result<Url> {
if let Some(pk) = self.pk() {
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), pk))?)
} else {
Err(anyhow!(
"Cannot get URL for {} with no primary key",
Self::resource_name()
))
}
}
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn get(db: &PgPool, id: i32) -> Result<Self> { pub async fn get(db: &PgPool, id: i32) -> Result<Self> {
sqlx::query_as!(Self, "SELECT * FROM shops WHERE id = $1", id) sqlx::query_as!(Self, "SELECT * FROM shops WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await .await
@ -43,7 +52,7 @@ impl Model for Shop {
} }
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn create(self, db: &PgPool) -> Result<Self> { pub async fn create(self, db: &PgPool) -> Result<Self> {
Ok(sqlx::query_as!( Ok(sqlx::query_as!(
Self, Self,
"INSERT INTO shops "INSERT INTO shops
@ -59,7 +68,7 @@ impl Model for Shop {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> { pub async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
let shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id) let shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await?; .await?;
@ -73,7 +82,7 @@ impl Model for Shop {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> { pub async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
let result = if let Some(order_by) = list_params.get_order_by() { let result = if let Some(order_by) = list_params.get_order_by() {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
@ -101,12 +110,9 @@ impl Model for Shop {
}; };
Ok(result) Ok(result)
} }
}
#[async_trait]
impl UpdateableModel for Shop {
#[instrument(level = "debug", skip(self, db))] #[instrument(level = "debug", skip(self, db))]
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> { pub async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
let shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id) let shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await?; .await?;

View File

@ -1,12 +1,12 @@
use anyhow::{Error, Result}; use anyhow::{anyhow, Error, Result};
use async_trait::async_trait;
use chrono::prelude::*; use chrono::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use sqlx::pool::PoolConnection;
use sqlx::{PgConnection, PgPool};
use tracing::instrument; use tracing::instrument;
use url::Url;
use super::ListParams; use super::ListParams;
use super::Model;
use crate::problem::forbidden_permission; use crate::problem::forbidden_permission;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@ -16,6 +16,10 @@ pub struct Transaction {
pub owner_id: Option<i32>, pub owner_id: Option<i32>,
pub mod_name: String, pub mod_name: String,
pub local_form_id: i32, pub local_form_id: i32,
pub name: String,
pub form_type: i32,
pub is_food: bool,
pub price: i32,
pub is_sell: bool, pub is_sell: bool,
pub quantity: i32, pub quantity: i32,
pub amount: i32, pub amount: i32,
@ -23,18 +27,28 @@ pub struct Transaction {
pub updated_at: Option<NaiveDateTime>, pub updated_at: Option<NaiveDateTime>,
} }
#[async_trait] impl Transaction {
impl Model for Transaction { pub fn resource_name() -> &'static str {
fn resource_name() -> &'static str {
"transaction" "transaction"
} }
fn pk(&self) -> Option<i32> { pub fn pk(&self) -> Option<i32> {
self.id self.id
} }
pub fn url(&self, api_url: &Url) -> Result<Url> {
if let Some(pk) = self.pk() {
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), pk))?)
} else {
Err(anyhow!(
"Cannot get URL for {} with no primary key",
Self::resource_name()
))
}
}
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn get(db: &PgPool, id: i32) -> Result<Self> { pub async fn get(db: &PgPool, id: i32) -> Result<Self> {
sqlx::query_as!(Self, "SELECT * FROM transactions WHERE id = $1", id) sqlx::query_as!(Self, "SELECT * FROM transactions WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await .await
@ -42,17 +56,25 @@ impl Model for Transaction {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn create(self, db: &PgPool) -> Result<Self> { pub async fn create(
self,
db: &mut sqlx::Transaction<PoolConnection<PgConnection>>,
) -> Result<Self> {
Ok(sqlx::query_as!( Ok(sqlx::query_as!(
Self, Self,
"INSERT INTO transactions "INSERT INTO transactions
(shop_id, owner_id, mod_name, local_form_id, is_sell, quantity, amount, created_at, updated_at) (shop_id, owner_id, mod_name, local_form_id, name, form_type, is_food, price,
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now()) is_sell, quantity, amount, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), now())
RETURNING *", RETURNING *",
self.shop_id, self.shop_id,
self.owner_id, self.owner_id,
self.mod_name, self.mod_name,
self.local_form_id, self.local_form_id,
self.name,
self.form_type,
self.is_food,
self.price,
self.is_sell, self.is_sell,
self.quantity, self.quantity,
self.amount, self.amount,
@ -62,7 +84,7 @@ impl Model for Transaction {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> { pub async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
let transaction = sqlx::query!("SELECT owner_id FROM transactions WHERE id = $1", id) let transaction = sqlx::query!("SELECT owner_id FROM transactions WHERE id = $1", id)
.fetch_one(db) .fetch_one(db)
.await?; .await?;
@ -76,7 +98,7 @@ impl Model for Transaction {
} }
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> { pub async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
let result = if let Some(order_by) = list_params.get_order_by() { let result = if let Some(order_by) = list_params.get_order_by() {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
@ -104,9 +126,7 @@ impl Model for Transaction {
}; };
Ok(result) Ok(result)
} }
}
impl Transaction {
#[instrument(level = "debug", skip(db))] #[instrument(level = "debug", skip(db))]
pub async fn list_by_shop_id( pub async fn list_by_shop_id(
db: &PgPool, db: &PgPool,

View File

@ -1,7 +1,11 @@
{ {
"shop_id": 1, "shop_id": 1,
"mod_name": "Skyrim.esm", "mod_name": "Skyrim.esm",
"local_form_id": 1, "local_form_id": 5,
"name": "New Thing",
"form_type": 41,
"is_food": false,
"price": 100,
"is_sell": false, "is_sell": false,
"quantity": 1, "quantity": 1,
"amount": 100 "amount": 100