transaction: add CSV deserialization

It is unfortunate that both [1] and [2] conspire to make this code way
worse than it could otherwise be with a saner (de)serialization format.

We both need to introduce `TransactionRecord` due to tagged enums not
being powerful enough in CSV, and make its `amount` field optional to
deal with the varying number of fields for each kind of transaction.

[1]: https://github.com/BurntSushi/rust-csv/issues/211
[2]: https://github.com/BurntSushi/rust-csv/issues/172
This commit is contained in:
Bruno BELANYI 2022-08-23 11:24:34 +02:00
parent c7e64692e6
commit ffd6a20a30
4 changed files with 621 additions and 5 deletions

View file

@ -1,16 +1,27 @@
//! Core types used in the processing of payments.
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
/// Clients are anonymous, identified by globally unique ids. "16-bit ought to be enough for
/// anyone".
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
)]
#[serde(transparent)]
pub struct ClientId(pub u16);
/// Transactions are identified by a globally unique id. 32 bit is sufficient for our puposes.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
)]
#[serde(transparent)]
pub struct TxId(pub u32);
/// Amounts are represented as exact decimals, up to four places past the decimal.
/// For ease of implementation, make use of [fpdec::Decimal] instead of implementing a custom
/// fixed-point number.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct TxAmount(pub fpdec::Decimal);
#[serde_as]
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(transparent)]
pub struct TxAmount(#[serde_as(as = "DisplayFromStr")] pub fpdec::Decimal);

View file

@ -1,8 +1,11 @@
//! Define all supported transactions.
use crate::core::{ClientId, TxAmount, TxId};
use serde::Deserialize;
/// A generic [Transaction].
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(try_from = "TransactionRecord")]
pub enum Transaction {
Deposit(Deposit),
Withdrawal(Withdrawal),
@ -11,6 +14,61 @@ pub enum Transaction {
Chargeback(Chargeback),
}
impl Transaction {
/// Build a [csv::ReaderBuilder] configured to read a CSV formatted [Transaction] stream.
pub fn configured_csv_reader_builder() -> csv::ReaderBuilder {
let mut builder = csv::ReaderBuilder::new();
builder
// Expect header input
.has_headers(true)
// Allow whitespace
.trim(csv::Trim::All)
// Allow trailing fields to be omitted
.flexible(true);
builder
}
}
// A type used to deserialize [Transaction] from an input CSV stream.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
struct TransactionRecord<'a> {
#[serde(rename = "type")]
type_: &'a str,
client: ClientId,
tx: TxId,
amount: Option<TxAmount>,
}
impl TryFrom<TransactionRecord<'_>> for Transaction {
// FIXME: use an actual error type.
type Error = String;
fn try_from(value: TransactionRecord<'_>) -> Result<Self, Self::Error> {
let TransactionRecord {
type_,
client,
tx,
amount,
} = value;
let transaction = match type_ {
"deposit" => {
let amount = amount.ok_or("Missing amount for transaction")?;
Transaction::Deposit(Deposit { client, tx, amount })
}
"withdrawal" => {
let amount = amount.ok_or("Missing amount for transaction")?;
Transaction::Withdrawal(Withdrawal { client, tx, amount })
}
"dispute" => Transaction::Dispute(Dispute { client, tx }),
"resolve" => Transaction::Resolve(Resolve { client, tx }),
"chargeback" => Transaction::Chargeback(Chargeback { client, tx }),
_ => return Err(format!("Unkown transaction type '{}'", type_)),
};
Ok(transaction)
}
}
/// Deposit funds into an account, i.e: increase its balance by the amount given.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Deposit {
@ -55,3 +113,117 @@ pub struct Chargeback {
pub client: ClientId,
pub tx: TxId,
}
#[cfg(test)]
mod test {
use super::*;
use fpdec::{Dec, Decimal};
fn parse_transaction(input: &str) -> Transaction {
let rdr = Transaction::configured_csv_reader_builder().from_reader(input.as_bytes());
rdr.into_deserialize().next().unwrap().unwrap()
}
#[test]
fn deserialize_deposit() {
let data = "type,client,tx,amount\ndeposit,1,2,3.0";
assert_eq!(
parse_transaction(data),
Transaction::Deposit(Deposit {
client: ClientId(1),
tx: TxId(2),
amount: TxAmount(Dec!(3.0))
}),
);
}
#[test]
fn deserialize_withdrawal() {
let data = "type,client,tx,amount\nwithdrawal,1,2,3.0";
assert_eq!(
parse_transaction(data),
Transaction::Withdrawal(Withdrawal {
client: ClientId(1),
tx: TxId(2),
amount: TxAmount(Dec!(3.0))
}),
);
}
#[test]
fn deserialize_dispute() {
let data = "type,client,tx,amount\ndispute,1,2";
assert_eq!(
parse_transaction(data),
Transaction::Dispute(Dispute {
client: ClientId(1),
tx: TxId(2),
}),
);
}
#[test]
fn deserialize_resolve() {
let data = "type,client,tx,amount\nresolve,1,2";
assert_eq!(
parse_transaction(data),
Transaction::Resolve(Resolve {
client: ClientId(1),
tx: TxId(2),
}),
);
}
#[test]
fn deserialize_chargeback() {
let data = "type,client,tx,amount\nchargeback,1,2";
assert_eq!(
parse_transaction(data),
Transaction::Chargeback(Chargeback {
client: ClientId(1),
tx: TxId(2),
}),
);
}
#[test]
fn deserialize_transactions() {
let data = concat!(
"type,client,tx,amount\n",
"deposit, 1, 2, 12.0000\n",
"withdrawal , 3,4, 42.27 \n",
"dispute, 5 , 6, \n",
" resolve,7,8,\n",
"chargeback,9,10",
);
let rdr = Transaction::configured_csv_reader_builder().from_reader(data.as_bytes());
let transactions: Result<Vec<Transaction>, _> = rdr.into_deserialize().collect();
assert_eq!(
transactions.unwrap(),
vec![
Transaction::Deposit(Deposit {
client: ClientId(1),
tx: TxId(2),
amount: TxAmount(Dec!(12.0000)),
}),
Transaction::Withdrawal(Withdrawal {
client: ClientId(3),
tx: TxId(4),
amount: TxAmount(Dec!(42.27)),
}),
Transaction::Dispute(Dispute {
client: ClientId(5),
tx: TxId(6),
}),
Transaction::Resolve(Resolve {
client: ClientId(7),
tx: TxId(8),
}),
Transaction::Chargeback(Chargeback {
client: ClientId(9),
tx: TxId(10),
}),
]
);
}
}