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

430
Cargo.lock generated
View file

@ -2,6 +2,133 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "android_system_properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e"
dependencies = [
"libc",
]
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "base64"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
[[package]]
name = "bstr"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223"
dependencies = [
"lazy_static",
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
dependencies = [
"iana-time-zone",
"num-integer",
"num-traits",
"serde",
"winapi",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
[[package]]
name = "csv"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1"
dependencies = [
"bstr",
"csv-core",
"itoa 0.4.8",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90"
dependencies = [
"memchr",
]
[[package]]
name = "darling"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "fpdec" name = "fpdec"
version = "0.5.4" version = "0.5.4"
@ -28,6 +155,130 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2bfd338099682614d3ee3fe0cd72e0b6a41ca6a87f6a74a3bd593c91650501"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"js-sys",
"wasm-bindgen",
"winapi",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [
"autocfg",
"hashbrown",
"serde",
]
[[package]]
name = "itoa"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
[[package]]
name = "itoa"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754"
[[package]]
name = "js-sys"
version = "0.3.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5"
[[package]]
name = "log"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
dependencies = [
"cfg-if",
]
[[package]]
name = "memchr"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "num-integer"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"
dependencies = [
"autocfg",
]
[[package]]
name = "num_threads"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.43" version = "1.0.43"
@ -41,7 +292,10 @@ dependencies = [
name = "processor" name = "processor"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"csv",
"fpdec", "fpdec",
"serde",
"serde_with",
] ]
[[package]] [[package]]
@ -53,8 +307,184 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
[[package]]
name = "ryu"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
[[package]]
name = "serde"
version = "1.0.144"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.144"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44"
dependencies = [
"itoa 1.0.3",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89df7a26519371a3cce44fbb914c2819c84d9b897890987fa3ab096491cc0ea8"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap",
"serde",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de337f322382fcdfbb21a014f7c224ee041a23785651db67b9827403178f698f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "time"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db76ff9fa4b1458b3c7f077f3ff9887394058460d21e634355b273aaf11eea45"
dependencies = [
"itoa 1.0.3",
"libc",
"num_threads",
"serde",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.3" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf"
[[package]]
name = "wasm-bindgen"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

View file

@ -6,4 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
csv = "1.1"
fpdec = "0.5" fpdec = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_with = "2.0"

View file

@ -1,16 +1,27 @@
//! Core types used in the processing of payments. //! 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 /// Clients are anonymous, identified by globally unique ids. "16-bit ought to be enough for
/// anyone". /// 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); pub struct ClientId(pub u16);
/// Transactions are identified by a globally unique id. 32 bit is sufficient for our puposes. /// 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); pub struct TxId(pub u32);
/// Amounts are represented as exact decimals, up to four places past the decimal. /// 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 /// For ease of implementation, make use of [fpdec::Decimal] instead of implementing a custom
/// fixed-point number. /// fixed-point number.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] #[serde_as]
pub struct TxAmount(pub fpdec::Decimal); #[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. //! Define all supported transactions.
use crate::core::{ClientId, TxAmount, TxId}; use crate::core::{ClientId, TxAmount, TxId};
use serde::Deserialize;
/// A generic [Transaction]. /// 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 { pub enum Transaction {
Deposit(Deposit), Deposit(Deposit),
Withdrawal(Withdrawal), Withdrawal(Withdrawal),
@ -11,6 +14,61 @@ pub enum Transaction {
Chargeback(Chargeback), 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. /// Deposit funds into an account, i.e: increase its balance by the amount given.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Deposit { pub struct Deposit {
@ -55,3 +113,117 @@ pub struct Chargeback {
pub client: ClientId, pub client: ClientId,
pub tx: TxId, 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),
}),
]
);
}
}