diff --git a/server/app/com/xsn/explorer/data/anorm/BalancePostgresDataHandler.scala b/server/app/com/xsn/explorer/data/anorm/BalancePostgresDataHandler.scala new file mode 100644 index 0000000..e8bd330 --- /dev/null +++ b/server/app/com/xsn/explorer/data/anorm/BalancePostgresDataHandler.scala @@ -0,0 +1,21 @@ +package com.xsn.explorer.data.anorm + +import javax.inject.Inject + +import com.xsn.explorer.data.anorm.dao.BalancePostgresDAO +import com.xsn.explorer.errors.BalanceUnknownError +import com.xsn.explorer.models.Balance +import org.scalactic.{One, Or} +import play.api.db.Database + +class BalancePostgresDataHandler @Inject() ( + override val database: Database, + balancePostgresDAO: BalancePostgresDAO) + extends AnormPostgresDataHandler { + + def upsert(balance: Balance) = withConnection { implicit conn => + val maybe = balancePostgresDAO.upsert(balance) + + Or.from(maybe, One(BalanceUnknownError)) + } +} diff --git a/server/app/com/xsn/explorer/data/anorm/dao/BalancePostgresDAO.scala b/server/app/com/xsn/explorer/data/anorm/dao/BalancePostgresDAO.scala new file mode 100644 index 0000000..baf38d6 --- /dev/null +++ b/server/app/com/xsn/explorer/data/anorm/dao/BalancePostgresDAO.scala @@ -0,0 +1,31 @@ +package com.xsn.explorer.data.anorm.dao + +import java.sql.Connection + +import anorm._ +import com.xsn.explorer.data.anorm.parsers.BalanceParsers._ +import com.xsn.explorer.models.Balance + +class BalancePostgresDAO { + + def upsert(balance: Balance)(implicit conn: Connection): Option[Balance] = { + SQL( + """ + |INSERT INTO balances + | (address, received, spent, available) + |VALUES + | ({address}, {received}, {spent}, {available}) + |ON CONFLICT (address) DO UPDATE + | SET received = balances.received + EXCLUDED.received, + | spent = balances.spent + EXCLUDED.spent, + | available = balances.available + EXCLUDED.available + |RETURNING address, received, spent + """.stripMargin + ).on( + 'address -> balance.address.string, + 'received -> balance.received, + 'spent -> balance.spent, + 'available -> balance.available + ).as(parseBalance.singleOpt).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 new file mode 100644 index 0000000..c5e9da1 --- /dev/null +++ b/server/app/com/xsn/explorer/data/anorm/parsers/BalanceParsers.scala @@ -0,0 +1,16 @@ +package com.xsn.explorer.data.anorm.parsers + +import anorm.SqlParser._ +import anorm._ +import com.xsn.explorer.models.{Address, Balance} + +object BalanceParsers { + + val parseAddress = str("address").map(Address.from) + val parseReceived = get[BigDecimal]("received") + val parseSpent = get[BigDecimal]("spent") + + val parseBalance = (parseAddress ~ parseReceived ~ parseSpent).map { case address ~ received ~ spent => + address.map { Balance(_, received, spent) } + } +} diff --git a/server/app/com/xsn/explorer/errors/balanceErrors.scala b/server/app/com/xsn/explorer/errors/balanceErrors.scala new file mode 100644 index 0000000..4cfaa9e --- /dev/null +++ b/server/app/com/xsn/explorer/errors/balanceErrors.scala @@ -0,0 +1,13 @@ +package com.xsn.explorer.errors + +import com.alexitc.playsonify.models.{PublicError, ServerError} +import play.api.i18n.{Lang, MessagesApi} + +sealed trait BalanceError + +case object BalanceUnknownError extends BalanceError 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/Balance.scala b/server/app/com/xsn/explorer/models/Balance.scala new file mode 100644 index 0000000..6281526 --- /dev/null +++ b/server/app/com/xsn/explorer/models/Balance.scala @@ -0,0 +1,9 @@ +package com.xsn.explorer.models + +case class Balance( + address: Address, + received: BigDecimal = BigDecimal(0), + spent: BigDecimal = BigDecimal(0)) { + + def available: BigDecimal = received - spent +} diff --git a/server/conf/evolutions/default/2.sql b/server/conf/evolutions/default/2.sql new file mode 100644 index 0000000..0155042 --- /dev/null +++ b/server/conf/evolutions/default/2.sql @@ -0,0 +1,37 @@ + +# --- !Ups + +-- we pre-compute the balances while storing transactions in order to perform +-- simpler queries while requiring addresses and the available amounts. +CREATE TABLE balances( + address VARCHAR(34) NOT NULL, + received DECIMAL(30, 15) NOT NULL, + spent DECIMAL(30, 15) NOT NULL, + available DECIMAL(30, 15) NOT NULL, + -- constraints + CONSTRAINT balances_address_pk PRIMARY KEY (address), + CONSTRAINT balances_address_format CHECK (address ~ '[a-zA-Z0-9]{34}$') + -- TODO: useful to add them if we can support them +-- CONSTRAINT balances_available_check CHECK (available = received - spent), +-- CONSTRAINT balances_received_ge_spent_check CHECK (received >= spent), +-- CONSTRAINT balances_received_positive_check CHECK (received >= 0), +-- CONSTRAINT balances_spent_positive_check CHECK (spent >= 0) +); + +CREATE INDEX balances_available_index ON balances (available); + + +-- there are certain addresses that we need to hide from public, like the one +-- used for the coin swap. +CREATE TABLE hidden_addresses( + address VARCHAR(34) NOT NULL, + -- constraints + CONSTRAINT hidden_addresses_address_pk PRIMARY KEY (address), + CONSTRAINT hidden_addresses_address_format CHECK (address ~ '[a-zA-Z0-9]{34}$') +); + + +# --- !Downs + +DROP TABLE hidden_addresses; +DROP TABLE balances; diff --git a/server/test/com/xsn/explorer/data/BalancePostgresDataHandlerSpec.scala b/server/test/com/xsn/explorer/data/BalancePostgresDataHandlerSpec.scala new file mode 100644 index 0000000..cc61b43 --- /dev/null +++ b/server/test/com/xsn/explorer/data/BalancePostgresDataHandlerSpec.scala @@ -0,0 +1,114 @@ +package com.xsn.explorer.data + +import com.xsn.explorer.data.anorm.BalancePostgresDataHandler +import com.xsn.explorer.data.anorm.dao.BalancePostgresDAO +import com.xsn.explorer.data.common.PostgresDataHandlerSpec +import com.xsn.explorer.helpers.DataHelper +import com.xsn.explorer.models.Balance +import org.scalactic.Good + +class BalancePostgresDataHandlerSpec extends PostgresDataHandlerSpec { + + lazy val dataHandler = new BalancePostgresDataHandler(database, new BalancePostgresDAO) + + "upsert" should { + "create an empty balance" in { + val address = DataHelper.createAddress("XxQ7j37LfuXgsLd5DZAwFKhT3s2ZMkW85F") + val balance = Balance(address) + + val result = dataHandler.upsert(balance) + result mustEqual Good(balance) + } + + "set the available amount" in { + val address = DataHelper.createAddress("Xbh5pJdBNm8J9PxnEmwVcuQKRmZZ7DkpcF") + val balance = Balance(address, received = BigDecimal(10), spent = BigDecimal(5)) + + val result = dataHandler.upsert(balance) + result mustEqual Good(balance) + database.withConnection { implicit conn => + val available = _root_.anorm + .SQL(s"SELECT available FROM balances WHERE address = '${address.string}'") + .as(_root_.anorm.SqlParser.get[BigDecimal]("available").single) + + available mustEqual balance.available + } + } + + "update an existing balance" in { + val address = DataHelper.createAddress("XfAATXtkRgCdMTrj2fxHvLsKLLmqAjhEAt") + val initialBalance = Balance(address, received = BigDecimal(10), spent = BigDecimal(5)) + val patch = Balance(address, received = BigDecimal(10), spent = BigDecimal(10)) + val expected = combine(initialBalance, patch) + + dataHandler.upsert(initialBalance) + + val result = dataHandler.upsert(patch) + result mustEqual Good(expected) + } + + "allow to set received coins and then, spend them all" in { + pending // TODO: remove it when database checks are enabled + val address = DataHelper.createAddress("XjfNeGJhLgW3egmsZqdbpCNGfysPs7jTNm") + val initialBalance = Balance(address, received = BigDecimal(10)) + val patch = Balance(address, spent = BigDecimal(10)) + val expected = combine(initialBalance, patch) + + dataHandler.upsert(initialBalance).isGood mustEqual true + + val result = dataHandler.upsert(patch) + result mustEqual Good(expected) + } + + "fail to set received as negative" in { + pending // TODO: remove it when database checks are enabled + val address = DataHelper.createAddress("XdhDFQBfk4E7GE3GVRe4X1bzxiyxRiN2kr") + val balance = Balance(address, received = BigDecimal(-1)) + + val result = dataHandler.upsert(balance) + println(result) + result.isBad mustEqual true + } + + "fail to set spent as negative" in { + pending // TODO: remove it when database checks are enabled + val address = DataHelper.createAddress("Xry2cCLNDMqLmENGW49vYJZPXHPgpqDZ8K") + + val balance = Balance(address, spent = BigDecimal(-1)) + + val result = dataHandler.upsert(balance) + println(result) + result.isBad mustEqual true + } + + "fail to set spent > received" in { + pending // TODO: remove it when database checks are enabled + val address = DataHelper.createAddress("XmCEMpTo4r68N7hsmrYhNbfSqSVNJGb6qx") + + val balance = Balance(address, received = BigDecimal(9), spent = BigDecimal(10)) + + val result = dataHandler.upsert(balance) + println(result) + result.isBad mustEqual true + } + + "fail to set go negative on received" in { + pending // TODO: remove it when database checks are enabled + val address = DataHelper.createAddress("XauduFtKWMNZaPxqruayxp3S1kj9rvDxjN") + val initialBalance = Balance(address, received = BigDecimal(10)) + val patch = Balance(address, spent = BigDecimal(11)) + + dataHandler.upsert(initialBalance) + + val result = dataHandler.upsert(patch) + println(result) + result.isBad mustEqual true + } + } + + private def combine(balances: Balance*): Balance = { + balances.reduce { (a, b) => + Balance(a.address, a.received + b.received, a.spent + b.spent) + } + } +}