Alexis Hernandez
7 years ago
7 changed files with 241 additions and 0 deletions
@ -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)) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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) } |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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; |
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue