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