Alexis Hernandez
7 years ago
11 changed files with 414 additions and 5 deletions
@ -0,0 +1,26 @@ |
|||||
|
package com.xsn.explorer.data.anorm |
||||
|
|
||||
|
import javax.inject.Inject |
||||
|
|
||||
|
import com.alexitc.playsonify.core.ApplicationResult |
||||
|
import com.xsn.explorer.data.anorm.dao.TransactionPostgresDAO |
||||
|
import com.xsn.explorer.errors.{TransactionNotFoundError, TransactionUnknownError} |
||||
|
import com.xsn.explorer.models.{Transaction, TransactionId} |
||||
|
import org.scalactic.{One, Or} |
||||
|
import play.api.db.Database |
||||
|
|
||||
|
class TransactionPostgresDataHandler @Inject() ( |
||||
|
override val database: Database, |
||||
|
transactionPostgresDAO: TransactionPostgresDAO) |
||||
|
extends AnormPostgresDataHandler { |
||||
|
|
||||
|
def upsert(transaction: Transaction): ApplicationResult[Transaction] = withTransaction { implicit conn => |
||||
|
val maybe = transactionPostgresDAO.upsert(transaction) |
||||
|
Or.from(maybe, One(TransactionUnknownError)) |
||||
|
} |
||||
|
|
||||
|
def delete(transactionId: TransactionId): ApplicationResult[Transaction] = withTransaction { implicit conn => |
||||
|
val maybe = transactionPostgresDAO.delete(transactionId) |
||||
|
Or.from(maybe, One(TransactionNotFoundError)) |
||||
|
} |
||||
|
} |
@ -0,0 +1,174 @@ |
|||||
|
package com.xsn.explorer.data.anorm.dao |
||||
|
|
||||
|
import java.sql.Connection |
||||
|
|
||||
|
import anorm._ |
||||
|
import com.xsn.explorer.data.anorm.parsers.TransactionParsers._ |
||||
|
import com.xsn.explorer.models.{Transaction, TransactionId} |
||||
|
|
||||
|
class TransactionPostgresDAO { |
||||
|
|
||||
|
/** |
||||
|
* NOTE: Ensure the connection has an open transaction. |
||||
|
*/ |
||||
|
def upsert(transaction: Transaction)(implicit conn: Connection): Option[Transaction] = { |
||||
|
for { |
||||
|
partialTx <- upsertTransaction(transaction) |
||||
|
inputs <- upsertInputs(transaction.id, transaction.inputs) |
||||
|
outputs <- upsertOutputs(transaction.id, transaction.outputs) |
||||
|
} yield partialTx.copy(inputs = inputs, outputs = outputs) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* NOTE: Ensure the connection has an open transaction. |
||||
|
*/ |
||||
|
def delete(txid: TransactionId)(implicit conn: Connection): Option[Transaction] = { |
||||
|
val inputs = deleteInputs(txid) |
||||
|
val outputs = deleteOutputs(txid) |
||||
|
|
||||
|
val txMaybe = SQL( |
||||
|
""" |
||||
|
|DELETE FROM transactions |
||||
|
|WHERE txid = {txid} |
||||
|
|RETURNING txid, blockhash, time, size |
||||
|
""".stripMargin |
||||
|
).on( |
||||
|
'txid -> txid.string |
||||
|
).as(parseTransaction.singleOpt) |
||||
|
|
||||
|
for { |
||||
|
tx <- txMaybe.flatten |
||||
|
} yield tx.copy(inputs = inputs, outputs = outputs) |
||||
|
} |
||||
|
|
||||
|
private def upsertTransaction(transaction: Transaction)(implicit conn: Connection): Option[Transaction] = { |
||||
|
SQL( |
||||
|
""" |
||||
|
|INSERT INTO transactions |
||||
|
| (txid, blockhash, time, size) |
||||
|
|VALUES |
||||
|
| ({txid}, {blockhash}, {time}, {size}) |
||||
|
|ON CONFLICT (txid) DO UPDATE |
||||
|
| SET blockhash = EXCLUDED.blockhash, |
||||
|
| time = EXCLUDED.time, |
||||
|
| size = EXCLUDED.size |
||||
|
|RETURNING txid, blockhash, time, size |
||||
|
""".stripMargin |
||||
|
).on( |
||||
|
'txid -> transaction.id.string, |
||||
|
'blockhash -> transaction.blockhash.string, |
||||
|
'time -> transaction.time, |
||||
|
'size -> transaction.size.int |
||||
|
).as(parseTransaction.singleOpt).flatten |
||||
|
} |
||||
|
|
||||
|
private def upsertInputs( |
||||
|
transactionId: TransactionId, |
||||
|
inputs: List[Transaction.Input])( |
||||
|
implicit conn: Connection): Option[List[Transaction.Input]] = { |
||||
|
|
||||
|
val result = inputs.map { input => |
||||
|
upsertInput(transactionId, input) |
||||
|
} |
||||
|
|
||||
|
if (result.forall(_.isDefined)) { |
||||
|
Some(result.flatten) |
||||
|
} else { |
||||
|
None |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private def upsertInput( |
||||
|
transactionId: TransactionId, |
||||
|
input: Transaction.Input)( |
||||
|
implicit conn: Connection): Option[Transaction.Input] = { |
||||
|
|
||||
|
SQL( |
||||
|
""" |
||||
|
|INSERT INTO transaction_inputs |
||||
|
| (txid, index, value, address) |
||||
|
|VALUES |
||||
|
| ({txid}, {index}, {value}, {address}) |
||||
|
|ON CONFLICT (txid, index) DO UPDATE |
||||
|
| SET value = EXCLUDED.value, |
||||
|
| address = EXCLUDED.address |
||||
|
|RETURNING index, value, address |
||||
|
""".stripMargin |
||||
|
).on( |
||||
|
'txid -> transactionId.string, |
||||
|
'index -> input.index, |
||||
|
'value -> input.value, |
||||
|
'address -> input.address.map(_.string) |
||||
|
).as(parseTransactionInput.singleOpt) |
||||
|
} |
||||
|
|
||||
|
private def upsertOutputs( |
||||
|
transactionId: TransactionId, |
||||
|
outputs: List[Transaction.Output])( |
||||
|
implicit conn: Connection): Option[List[Transaction.Output]] = { |
||||
|
|
||||
|
val result = outputs.map { output => |
||||
|
upsertOutput(transactionId, output) |
||||
|
} |
||||
|
|
||||
|
if (result.forall(_.isDefined)) { |
||||
|
Some(result.flatten) |
||||
|
} else { |
||||
|
None |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private def upsertOutput( |
||||
|
transactionId: TransactionId, |
||||
|
output: Transaction.Output)( |
||||
|
implicit conn: Connection): Option[Transaction.Output] = { |
||||
|
|
||||
|
SQL( |
||||
|
""" |
||||
|
|INSERT INTO transaction_outputs |
||||
|
| (txid, index, value, address, tpos_owner_address, tpos_merchant_address) |
||||
|
|VALUES |
||||
|
| ({txid}, {index}, {value}, {address}, {tpos_owner_address}, {tpos_merchant_address}) |
||||
|
|ON CONFLICT (txid, index) DO UPDATE |
||||
|
| SET value = EXCLUDED.value, |
||||
|
| address = EXCLUDED.address, |
||||
|
| tpos_owner_address = EXCLUDED.tpos_owner_address, |
||||
|
| tpos_merchant_address = EXCLUDED.tpos_merchant_address |
||||
|
|RETURNING index, value, address, tpos_owner_address, tpos_merchant_address |
||||
|
""".stripMargin |
||||
|
).on( |
||||
|
'txid -> transactionId.string, |
||||
|
'index -> output.index, |
||||
|
'value -> output.value, |
||||
|
'address -> output.address.string, |
||||
|
'tpos_owner_address -> output.tposOwnerAddress.map(_.string), |
||||
|
'tpos_merchant_address -> output.tposMerchantAddress.map(_.string) |
||||
|
).as(parseTransactionOutput.singleOpt).flatten |
||||
|
} |
||||
|
|
||||
|
private def deleteInputs(txid: TransactionId)(implicit conn: Connection): List[Transaction.Input] = { |
||||
|
SQL( |
||||
|
""" |
||||
|
|DELETE FROM transaction_inputs |
||||
|
|WHERE txid = {txid} |
||||
|
|RETURNING index, value, address |
||||
|
""".stripMargin |
||||
|
).on( |
||||
|
'txid -> txid.string |
||||
|
).as(parseTransactionInput.*) |
||||
|
} |
||||
|
|
||||
|
private def deleteOutputs(txid: TransactionId)(implicit conn: Connection): List[Transaction.Output] = { |
||||
|
val result = SQL( |
||||
|
""" |
||||
|
|DELETE FROM transaction_outputs |
||||
|
|WHERE txid = {txid} |
||||
|
|RETURNING index, value, address, tpos_owner_address, tpos_merchant_address |
||||
|
""".stripMargin |
||||
|
).on( |
||||
|
'txid -> txid.string |
||||
|
).as(parseTransactionOutput.*) |
||||
|
|
||||
|
result.flatten |
||||
|
} |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
package com.xsn.explorer.data.anorm.parsers |
||||
|
|
||||
|
import anorm.SqlParser.{int, long, str} |
||||
|
import com.xsn.explorer.models.{Address, Blockhash, Size} |
||||
|
|
||||
|
object CommonParsers { |
||||
|
|
||||
|
val parseBlockhash = str("blockhash").map(Blockhash.from) |
||||
|
val parseAddress = str("address").map(Address.from) |
||||
|
val parseTime = long("time") |
||||
|
val parseSize = int("size").map(Size.apply) |
||||
|
} |
@ -0,0 +1,44 @@ |
|||||
|
package com.xsn.explorer.data.anorm.parsers |
||||
|
|
||||
|
import anorm.SqlParser.{get, str} |
||||
|
import anorm.~ |
||||
|
import com.xsn.explorer.models.{Address, Transaction, TransactionId} |
||||
|
|
||||
|
object TransactionParsers { |
||||
|
|
||||
|
import CommonParsers._ |
||||
|
|
||||
|
val parseTransactionId = str("txid").map(TransactionId.from) |
||||
|
val parseReceived = get[BigDecimal]("received") |
||||
|
val parseSpent = get[BigDecimal]("spent") |
||||
|
|
||||
|
val parseIndex = get[Int]("index") |
||||
|
val parseValue = get[BigDecimal]("value") |
||||
|
|
||||
|
val parseTposOwnerAddress = str("tpos_owner_address").map(Address.from) |
||||
|
val parseTposMerchantAddress = str("tpos_merchant_address").map(Address.from) |
||||
|
|
||||
|
val parseTransaction = (parseTransactionId ~ parseBlockhash ~ parseTime ~ parseSize).map { |
||||
|
case txidMaybe ~ blockhashMaybe ~ time ~ size => |
||||
|
for { |
||||
|
txid <- txidMaybe |
||||
|
blockhash <- blockhashMaybe |
||||
|
} yield Transaction(txid, blockhash, time, size, List.empty, List.empty) |
||||
|
} |
||||
|
|
||||
|
val parseTransactionInput = (parseIndex ~ parseValue.? ~ parseAddress.?).map { case index ~ value ~ address => |
||||
|
Transaction.Input(index, value, address.flatten) |
||||
|
} |
||||
|
|
||||
|
val parseTransactionOutput = ( |
||||
|
parseIndex ~ |
||||
|
parseValue ~ |
||||
|
parseAddress ~ |
||||
|
parseTposOwnerAddress.? ~ |
||||
|
parseTposMerchantAddress.?).map { |
||||
|
|
||||
|
case index ~ value ~ addressMaybe ~ tposOwnerAddress ~ tposMerchantAddress => |
||||
|
for (address <- addressMaybe) |
||||
|
yield Transaction.Output(index, value, address, tposOwnerAddress.flatten, tposMerchantAddress.flatten) |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
package com.xsn.explorer.models |
||||
|
|
||||
|
case class Transaction( |
||||
|
id: TransactionId, |
||||
|
blockhash: Blockhash, |
||||
|
time: Long, |
||||
|
size: Size, |
||||
|
inputs: List[Transaction.Input], |
||||
|
outputs: List[Transaction.Output]) |
||||
|
|
||||
|
object Transaction { |
||||
|
|
||||
|
case class Input( |
||||
|
index: Int, |
||||
|
value: Option[BigDecimal], |
||||
|
address: Option[Address]) |
||||
|
|
||||
|
case class Output( |
||||
|
index: Int, |
||||
|
value: BigDecimal, |
||||
|
address: Address, |
||||
|
tposOwnerAddress: Option[Address], |
||||
|
tposMerchantAddress: Option[Address]) |
||||
|
} |
@ -0,0 +1,49 @@ |
|||||
|
|
||||
|
# --- !Ups |
||||
|
|
||||
|
CREATE TABLE transactions( |
||||
|
txid VARCHAR(64) NOT NULL, |
||||
|
blockhash VARCHAR(64) NOT NULL, |
||||
|
time BIGINT NOT NULL, |
||||
|
size INT NOT NULL, |
||||
|
-- constraints |
||||
|
CONSTRAINT transactions_txid_pk PRIMARY KEY (txid), |
||||
|
CONSTRAINT transactions_txid_format CHECK (txid ~ '^[a-f0-9]{64}$'), |
||||
|
CONSTRAINT transactions_blockhash_format CHECK (blockhash ~ '^[a-f0-9]{64}$') |
||||
|
); |
||||
|
|
||||
|
CREATE INDEX transactions_blockhash_index ON transactions (blockhash); |
||||
|
|
||||
|
|
||||
|
CREATE TABLE transaction_inputs( |
||||
|
txid VARCHAR(64) NOT NULL, |
||||
|
index INT NOT NULL, |
||||
|
value DECIMAL(30, 15) NULL, |
||||
|
address VARCHAR(34) NULL, |
||||
|
-- constraints |
||||
|
CONSTRAINT transaction_inputs_txid_index_pk PRIMARY KEY (txid, index), |
||||
|
CONSTRAINT transaction_inputs_txid_format CHECK (txid ~ '^[a-f0-9]{64}$'), |
||||
|
CONSTRAINT transaction_inputs_address_format CHECK (address ~ '[a-zA-Z0-9]{34}$') |
||||
|
); |
||||
|
|
||||
|
CREATE TABLE transaction_outputs( |
||||
|
txid VARCHAR(64) NOT NULL, |
||||
|
index INT NOT NULL, |
||||
|
value DECIMAL(30, 15) NOT NULL, |
||||
|
address VARCHAR(34) NULL, |
||||
|
tpos_owner_address VARCHAR(34) NULL, |
||||
|
tpos_merchant_address VARCHAR(34) NULL, |
||||
|
-- constraints |
||||
|
CONSTRAINT transaction_outputs_txid_index_pk PRIMARY KEY (txid, index), |
||||
|
CONSTRAINT transaction_outputs_txid_format CHECK (txid ~ '^[a-f0-9]{64}$'), |
||||
|
CONSTRAINT transaction_outputs_address_format CHECK (address ~ '[a-zA-Z0-9]{34}$'), |
||||
|
CONSTRAINT transaction_outputs_tpos_owner_address_format CHECK (address ~ '[a-zA-Z0-9]{34}$'), |
||||
|
CONSTRAINT transaction_outputs_tpos_merchant_address_format CHECK (address ~ '[a-zA-Z0-9]{34}$') |
||||
|
); |
||||
|
|
||||
|
|
||||
|
# --- !Downs |
||||
|
|
||||
|
DROP TABLE transaction_outputs; |
||||
|
DROP TABLE transaction_inputs; |
||||
|
DROP TABLE transactions; |
@ -0,0 +1,69 @@ |
|||||
|
package com.xsn.explorer.data |
||||
|
|
||||
|
import com.xsn.explorer.data.anorm.TransactionPostgresDataHandler |
||||
|
import com.xsn.explorer.data.anorm.dao.TransactionPostgresDAO |
||||
|
import com.xsn.explorer.data.common.PostgresDataHandlerSpec |
||||
|
import com.xsn.explorer.errors.TransactionNotFoundError |
||||
|
import com.xsn.explorer.helpers.DataHelper._ |
||||
|
import com.xsn.explorer.models.{Size, Transaction} |
||||
|
import org.scalactic.{Bad, Good} |
||||
|
|
||||
|
class TransactionPostgresDataHandlerSpec extends PostgresDataHandlerSpec { |
||||
|
|
||||
|
lazy val dataHandler = new TransactionPostgresDataHandler(database, new TransactionPostgresDAO) |
||||
|
|
||||
|
val inputs = List( |
||||
|
Transaction.Input(0, None, None), |
||||
|
Transaction.Input(1, Some(BigDecimal(100)), Some(createAddress("XxQ7j37LfuXgsLd5DZAwFKhT3s2ZMkW85F"))) |
||||
|
) |
||||
|
|
||||
|
val outputs = List( |
||||
|
Transaction.Output(0, BigDecimal(50), createAddress("XxQ7j37LfuXgsLd5DZAwFKhT3s2ZMkW85F"), None, None), |
||||
|
Transaction.Output( |
||||
|
1, |
||||
|
BigDecimal(150), |
||||
|
createAddress("Xbh5pJdBNm8J9PxnEmwVcuQKRmZZ7DkpcF"), |
||||
|
Some(createAddress("XfAATXtkRgCdMTrj2fxHvLsKLLmqAjhEAt")), |
||||
|
Some(createAddress("XjfNeGJhLgW3egmsZqdbpCNGfysPs7jTNm"))) |
||||
|
) |
||||
|
|
||||
|
val transaction = Transaction( |
||||
|
createTransactionId("99c51e4fe89466faa734d6207a7ef6115fa1dd33f7156b006fafc6bb85a79eb8"), |
||||
|
createBlockhash("ad92f0dcea2fdaa357aac6eab00695cf07b487e34113598909f625c24629c981"), |
||||
|
12312312L, |
||||
|
Size(1000), |
||||
|
inputs, |
||||
|
outputs) |
||||
|
|
||||
|
"upsert" should { |
||||
|
"add a new transaction" in { |
||||
|
val result = dataHandler.upsert(transaction) |
||||
|
result mustEqual Good(transaction) |
||||
|
} |
||||
|
|
||||
|
"update an existing transaction" in { |
||||
|
val newTransaction = transaction.copy( |
||||
|
blockhash = createBlockhash("99c51e4fe89466faa734d6207a7ef6115fa1dd32f7156b006fafc6bb85a79eb8"), |
||||
|
time = 2313121L, |
||||
|
size = Size(2000)) |
||||
|
|
||||
|
dataHandler.upsert(transaction).isGood mustEqual true |
||||
|
val result = dataHandler.upsert(newTransaction) |
||||
|
result mustEqual Good(newTransaction) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
"delete" should { |
||||
|
"delete a transaction" in { |
||||
|
dataHandler.upsert(transaction).isGood mustEqual true |
||||
|
val result = dataHandler.delete(transaction.id) |
||||
|
result mustEqual Good(transaction) |
||||
|
} |
||||
|
|
||||
|
"fail to delete a non-existent transaction" in { |
||||
|
dataHandler.delete(transaction.id) |
||||
|
val result = dataHandler.delete(transaction.id) |
||||
|
result mustEqual Bad(TransactionNotFoundError).accumulating |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue