diff --git a/server/app/com/xsn/explorer/data/anorm/TransactionPostgresDataHandler.scala b/server/app/com/xsn/explorer/data/anorm/TransactionPostgresDataHandler.scala new file mode 100644 index 0000000..00740c0 --- /dev/null +++ b/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)) + } +} diff --git a/server/app/com/xsn/explorer/data/anorm/dao/TransactionPostgresDAO.scala b/server/app/com/xsn/explorer/data/anorm/dao/TransactionPostgresDAO.scala new file mode 100644 index 0000000..3811a54 --- /dev/null +++ b/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 + } +} diff --git a/server/app/com/xsn/explorer/data/anorm/parsers/BalanceParsers.scala b/server/app/com/xsn/explorer/data/anorm/parsers/BalanceParsers.scala index c5e9da1..5c85345 100644 --- a/server/app/com/xsn/explorer/data/anorm/parsers/BalanceParsers.scala +++ b/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") diff --git a/server/app/com/xsn/explorer/data/anorm/parsers/BlockParsers.scala b/server/app/com/xsn/explorer/data/anorm/parsers/BlockParsers.scala index 721569a..a10aa55 100644 --- a/server/app/com/xsn/explorer/data/anorm/parsers/BlockParsers.scala +++ b/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") diff --git a/server/app/com/xsn/explorer/data/anorm/parsers/CommonParsers.scala b/server/app/com/xsn/explorer/data/anorm/parsers/CommonParsers.scala new file mode 100644 index 0000000..3d87450 --- /dev/null +++ b/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) +} diff --git a/server/app/com/xsn/explorer/data/anorm/parsers/TransactionParsers.scala b/server/app/com/xsn/explorer/data/anorm/parsers/TransactionParsers.scala new file mode 100644 index 0000000..88c375f --- /dev/null +++ b/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) + } +} diff --git a/server/app/com/xsn/explorer/errors/transactionErrors.scala b/server/app/com/xsn/explorer/errors/transactionErrors.scala index d3d8627..0204375 100644 --- a/server/app/com/xsn/explorer/errors/transactionErrors.scala +++ b/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 +} diff --git a/server/app/com/xsn/explorer/models/Transaction.scala b/server/app/com/xsn/explorer/models/Transaction.scala new file mode 100644 index 0000000..af442bb --- /dev/null +++ b/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]) +} diff --git a/server/conf/evolutions/default/3.sql b/server/conf/evolutions/default/3.sql new file mode 100644 index 0000000..c8424ea --- /dev/null +++ b/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; diff --git a/server/test/com/xsn/explorer/data/TransactionPostgresDataHandlerSpec.scala b/server/test/com/xsn/explorer/data/TransactionPostgresDataHandlerSpec.scala new file mode 100644 index 0000000..c5c1391 --- /dev/null +++ b/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 + } + } +} diff --git a/server/test/com/xsn/explorer/helpers/DataHelper.scala b/server/test/com/xsn/explorer/helpers/DataHelper.scala index ad8bb7a..7490d17 100644 --- a/server/test/com/xsn/explorer/helpers/DataHelper.scala +++ b/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) = {