Browse Source

server: Add TransactionPostgresDataHandler

scalafmt-draft
Alexis Hernandez 7 years ago
parent
commit
8173c64497
  1. 26
      server/app/com/xsn/explorer/data/anorm/TransactionPostgresDataHandler.scala
  2. 174
      server/app/com/xsn/explorer/data/anorm/dao/TransactionPostgresDAO.scala
  3. 5
      server/app/com/xsn/explorer/data/anorm/parsers/BalanceParsers.scala
  4. 3
      server/app/com/xsn/explorer/data/anorm/parsers/BlockParsers.scala
  5. 12
      server/app/com/xsn/explorer/data/anorm/parsers/CommonParsers.scala
  6. 44
      server/app/com/xsn/explorer/data/anorm/parsers/TransactionParsers.scala
  7. 9
      server/app/com/xsn/explorer/errors/transactionErrors.scala
  8. 24
      server/app/com/xsn/explorer/models/Transaction.scala
  9. 49
      server/conf/evolutions/default/3.sql
  10. 69
      server/test/com/xsn/explorer/data/TransactionPostgresDataHandlerSpec.scala
  11. 4
      server/test/com/xsn/explorer/helpers/DataHelper.scala

26
server/app/com/xsn/explorer/data/anorm/TransactionPostgresDataHandler.scala

@ -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))
}
}

174
server/app/com/xsn/explorer/data/anorm/dao/TransactionPostgresDAO.scala

@ -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
}
}

5
server/app/com/xsn/explorer/data/anorm/parsers/BalanceParsers.scala

@ -2,11 +2,12 @@ package com.xsn.explorer.data.anorm.parsers
import anorm.SqlParser._
import anorm._
import com.xsn.explorer.models.{Address, Balance}
import com.xsn.explorer.models.Balance
object BalanceParsers {
val parseAddress = str("address").map(Address.from)
import CommonParsers._
val parseReceived = get[BigDecimal]("received")
val parseSpent = get[BigDecimal]("spent")

3
server/app/com/xsn/explorer/data/anorm/parsers/BlockParsers.scala

@ -7,6 +7,8 @@ import com.xsn.explorer.models.rpc.Block
object BlockParsers {
import CommonParsers._
val parseHash = str("hash").map(Blockhash.from)
val parseNextBlockhash = str("next_blockhash").map(Blockhash.from)
val parsePreviousBlockhash = str("previous_blockhash").map(Blockhash.from)
@ -15,7 +17,6 @@ object BlockParsers {
val parseSize = int("size").map(Size.apply)
val parseHeight = int("height").map(Height.apply)
val parseVersion = int("version")
val parseTime = long("time")
val parseMedianTime = long("median_time")
val parseNonce = int("nonce")
val parseBits = str("bits")

12
server/app/com/xsn/explorer/data/anorm/parsers/CommonParsers.scala

@ -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)
}

44
server/app/com/xsn/explorer/data/anorm/parsers/TransactionParsers.scala

@ -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)
}
}

9
server/app/com/xsn/explorer/errors/transactionErrors.scala

@ -1,6 +1,6 @@
package com.xsn.explorer.errors
import com.alexitc.playsonify.models.{FieldValidationError, InputValidationError, PublicError}
import com.alexitc.playsonify.models.{FieldValidationError, InputValidationError, PublicError, ServerError}
import play.api.i18n.{Lang, MessagesApi}
sealed trait TransactionError
@ -22,3 +22,10 @@ case object TransactionNotFoundError extends TransactionError with InputValidati
List(error)
}
}
case object TransactionUnknownError extends TransactionError with ServerError {
override def cause: Option[Throwable] = None
override def toPublicErrorList(messagesApi: MessagesApi)(implicit lang: Lang): List[PublicError] = List.empty
}

24
server/app/com/xsn/explorer/models/Transaction.scala

@ -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])
}

49
server/conf/evolutions/default/3.sql

@ -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;

69
server/test/com/xsn/explorer/data/TransactionPostgresDataHandlerSpec.scala

@ -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
}
}
}

4
server/test/com/xsn/explorer/helpers/DataHelper.scala

@ -1,12 +1,14 @@
package com.xsn.explorer.helpers
import com.xsn.explorer.models.rpc.{AddressBalance, ScriptPubKey, TransactionVOUT}
import com.xsn.explorer.models.{Address, AddressDetails, TransactionId}
import com.xsn.explorer.models.{Address, AddressDetails, Blockhash, TransactionId}
object DataHelper {
def createAddress(string: String) = Address.from(string).get
def createBlockhash(string: String) = Blockhash.from(string).get
def createTransactionId(string: String) = TransactionId.from(string).get
def createTransactionVOUT(n: Int, value: BigDecimal, scriptPubKey: ScriptPubKey) = {

Loading…
Cancel
Save