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