commit 7153ffba0a060cb6131421933fd8f978d4c9487a
parent 77ea0eb003a2157172350d737573ed587d556a6d
Author: Antoine A <>
Date: Mon, 24 Jun 2024 18:05:40 +0200
bank: add database benchmark and add new performance indexes
Diffstat:
12 files changed, 654 insertions(+), 64 deletions(-)
diff --git a/Makefile b/Makefile
@@ -120,3 +120,7 @@ doc:
./gradlew dokkaHtmlMultiModule
echo "Open build/dokka/htmlMultiModule/index.html"
+.PHONY: bench-db
+bench-db: install-nobuild-files
+ ./gradlew :bank:test --tests Bench.benchDb -i
+
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -28,7 +28,7 @@ import com.github.ajalt.clikt.parameters.groups.OptionGroup
import com.github.ajalt.clikt.parameters.groups.cooccurring
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.*
-import com.github.ajalt.clikt.parameters.types.boolean
+import com.github.ajalt.clikt.parameters.types.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -530,7 +530,7 @@ class AccountDAO(private val db: Database) {
}
/** Get a page of all public accounts */
- suspend fun pagePublic(params: AccountParams, ctx: BankPaytoCtx): List<PublicAccount>
+ suspend fun pagePublic(params: AccountParams, ctx: BankPaytoCtx): List<PublicAccount>
= db.page(
params.page,
"bank_account_id",
@@ -546,11 +546,17 @@ class AccountDAO(private val db: Database) {
bank_account_id
FROM bank_accounts JOIN customers
ON owning_customer_id = customer_id
- WHERE is_public=true AND name LIKE ? AND deleted_at IS NULL AND
+ WHERE is_public=true AND
+ ${if (params.loginFilter != null) "name LIKE ? AND" else ""}
+ deleted_at IS NULL AND
""",
{
- setString(1, params.loginFilter)
- 1
+ if (params.loginFilter != null) {
+ setString(1, params.loginFilter)
+ 1
+ } else {
+ 0
+ }
}
) {
PublicAccount(
@@ -595,11 +601,15 @@ class AccountDAO(private val db: Database) {
END as status
FROM bank_accounts JOIN customers
ON owning_customer_id = customer_id
- WHERE name LIKE ? AND
+ WHERE ${if (params.loginFilter != null) "name LIKE ? AND" else ""}
""",
{
- setString(1, params.loginFilter)
- 1
+ if (params.loginFilter != null) {
+ setString(1, params.loginFilter)
+ 1
+ } else {
+ 0
+ }
}
) {
AccountMinimalData(
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -132,9 +132,7 @@ class CashoutDAO(private val db: Database) {
/** Get a page of all cashout operations */
suspend fun pageAll(params: PageParams): List<GlobalCashoutInfo> =
db.page(params, "cashout_id", """
- SELECT
- cashout_id
- ,login
+ SELECT cashout_id, login
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -152,9 +150,11 @@ class CashoutDAO(private val db: Database) {
db.page(params, "cashout_id", """
SELECT cashout_id
FROM cashout_operations
- JOIN bank_accounts ON bank_account=bank_account_id
- JOIN customers ON owning_customer_id=customer_id
- WHERE login = ? AND
+ WHERE bank_account=(
+ SELECT bank_account_id
+ FROM bank_accounts JOIN customers ON owning_customer_id=customer_id
+ WHERE deleted_at IS NULL AND login = ?
+ ) AND
""",
bind = {
setString(1, login)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
@@ -107,7 +107,7 @@ class TokenDAO(private val db: Database) {
execute()
}
- /** Get a page of all public accounts */
+ /** Get a page of all tokens of [login] accounts */
suspend fun page(params: PageParams, login: String): List<TokenInfo>
= db.page(
params,
@@ -121,9 +121,10 @@ class TokenDAO(private val db: Database) {
description,
last_access,
bearer_token_id
- FROM bearer_tokens JOIN customers
- ON bank_customer=customer_id
- WHERE deleted_at IS NULL AND login = ? AND
+ FROM bearer_tokens
+ WHERE
+ bank_customer=(SELECT customer_id FROM customers WHERE deleted_at IS NULL AND login = ?)
+ AND
""",
{
setString(1, login)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt b/bank/src/main/kotlin/tech/libeufin/bank/params.kt
@@ -83,11 +83,11 @@ data class MonitorParams(
}
data class AccountParams(
- val page: PageParams, val loginFilter: String
+ val page: PageParams, val loginFilter: String?
) {
companion object {
fun extract(params: Parameters): AccountParams {
- val loginFilter = params["filter_name"]?.run { "%$this%" } ?: "%"
+ val loginFilter = params["filter_name"]?.run { "%$this%" }
return AccountParams(PageParams.extract(params), loginFilter)
}
}
diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt
@@ -0,0 +1,406 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import org.junit.*
+import io.ktor.client.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
+import tech.libeufin.common.crypto.PwCrypto
+import tech.libeufin.bank.*
+import tech.libeufin.bank.db.*
+import org.postgresql.jdbc.PgConnection
+import kotlin.math.*
+import kotlin.time.*
+import kotlin.time.Duration.*
+import java.util.UUID
+import java.time.*
+import kotlin.random.Random
+
+class Bench {
+
+ /** Generate [amount] rows to fill the database */
+ fun genData(conn: PgConnection, amount: Int) {
+ // Skip 4 accounts created by bankSetup
+ val skipAccount = 4
+ // Customer account will be used in tests so we want to generate more data for him
+ val customerAccount = 3
+ val exchangeAccount = 2
+ // In general half of the data is for generated account and half is for customer
+ val mid = amount / 2
+
+
+ val copyManager = conn.getCopyAPI()
+ val password = PwCrypto.hashpw("password")
+ fun gen(table: String, lambda: (Int) -> String) {
+ println("Gen rows for $table")
+ val full = buildString(150*amount) {
+ repeat(amount) {
+ append(lambda(it+1))
+ }
+ }
+ copyManager.copyIn("COPY $table FROM STDIN", full.reader())
+ }
+
+ val token32 = ByteArray(32)
+ val token64 = ByteArray(64)
+
+ gen("customers(login, name, password_hash, cashout_payto)") {
+ "account_$it\t$password\tMr n°$it\t$unknownPayto\n"
+ }
+ gen("bank_accounts(internal_payto_uri, owning_customer_id, is_public)") {
+ "payto://x-taler-bank/localhost/account_$it\t${it+skipAccount}\t${it%3==0}\n"
+ }
+ gen("bearer_tokens(content, creation_time, expiration_time, scope, is_refreshable, bank_customer, description, last_access)") {
+ val account = if (it > mid) customerAccount else it+4
+ Random.nextBytes(token32)
+ val hex = token32.encodeHex()
+ "\\\\x$hex\t0\t0\treadonly\tfalse\t$account\t\\N\t0\n"
+ }
+ gen("bank_account_transactions(creditor_payto_uri, creditor_name, debtor_payto_uri, debtor_name, subject, amount, transaction_date, direction, bank_account_id)") {
+ val account = if (it > mid) customerAccount else it+4
+ "creditor_payto\tcreditor_name\tdebtor_payto\tdebtor_name\tsubject\t(42,0)\t0\tcredit\t$exchangeAccount\n" +
+ "creditor_payto\tcreditor_name\tdebtor_payto\tdebtor_name\tsubject\t(42,0)\t0\tdebit\t$account\n"
+ }
+ gen("bank_transaction_operations") {
+ Random.nextBytes(token32)
+ val hex = token32.encodeHex()
+ "\\\\x$hex\t$it\n"
+ }
+ gen("tan_challenges(body, op, code, creation_date, expiration_date, retry_counter, customer)") {
+ val account = if (it > mid) customerAccount else it+4
+ "body\taccount_reconfig\tcode\t0\t0\t0\t$account\n"
+ }
+ gen("taler_withdrawal_operations(withdrawal_uuid, wallet_bank_account, reserve_pub, creation_date)") {
+ val account = if (it > mid) customerAccount else it+4
+ Random.nextBytes(token32)
+ val hex = token32.encodeHex()
+ val uuid = UUID.randomUUID()
+ "$uuid\t$account\t\\\\x$hex\t0\n"
+ }
+ gen("taler_exchange_outgoing(wtid, request_uid, exchange_base_url, bank_transaction, creditor_account_id)") {
+ Random.nextBytes(token32)
+ val hex32 = token32.encodeHex()
+ Random.nextBytes(token64)
+ val hex64 = token64.encodeHex()
+ "\\\\x$hex32\t\\\\x$hex64\turl\t${it*2-1}\t$it\n"
+ }
+ gen("taler_exchange_incoming(reserve_pub, bank_transaction)") {
+ Random.nextBytes(token32)
+ val hex = token32.encodeHex()
+ "\\\\x$hex\t${it*2}\n"
+ }
+ gen("bank_stats(timeframe, start_time)") {
+ val instant = Instant.ofEpochSecond(it.toLong())
+ val date = LocalDateTime.ofInstant(instant, ZoneId.of("UTC"))
+ "day\t$date\n"
+ }
+ gen("cashout_operations(request_uid,amount_debit,amount_credit,subject,creation_time,bank_account,local_transaction)") {
+ val account = if (it > mid) customerAccount else it+4
+ Random.nextBytes(token32)
+ val hex = token32.encodeHex()
+ "\\\\x$hex\t(0,0)\t(0,0)\tsubject\t0\t$account\t$it\n"
+ }
+
+ // Update database statistics for better perf
+ conn.execSQLUpdate("VACUUM ANALYZE");
+ }
+
+ @Test
+ fun benchDb() {
+ val ITER = System.getenv("BENCH_ITER")?.toIntOrNull() ?: 0
+ val AMOUNT = System.getenv("BENCH_AMOUNT")?.toIntOrNull() ?: 0
+
+ if (ITER == 0) {
+ println("Skip benchmark, missing BENCH_ITER")
+ return
+ }
+ println("Bench $ITER times with $AMOUNT rows")
+
+ val WARN = 4.toDuration(DurationUnit.MILLISECONDS)
+ val ERR = 50.toDuration(DurationUnit.MILLISECONDS)
+
+
+ suspend fun fmtMeasures(times: LongArray): List<String> {
+ val min: Long = times.min()
+ val max: Long = times.max()
+ val mean: Long = times.average().toLong()
+ val variance = times.map { (it.toDouble() - mean).pow(2) }.average()
+ val stdVar: Long = sqrt(variance.toDouble()).toLong()
+ return sequenceOf(min, mean, max, stdVar).map {
+ val duration = it.toDuration(DurationUnit.MICROSECONDS)
+ val str = duration.toString()
+ if (duration > ERR) {
+ ANSI.red(str)
+ } else if (duration > WARN) {
+ ANSI.yellow(str)
+ } else {
+ ANSI.green(str)
+ }
+
+ }.toList()
+ }
+
+ val measures: MutableList<List<String>> = mutableListOf()
+
+ suspend fun <R> measureAction(name: String, lambda: suspend (Int) -> R): List<R> {
+ val results = mutableListOf<R>()
+ val times = LongArray(ITER) { idx ->
+ measureTime {
+ val result = lambda(idx)
+ results.add(result)
+ }.inWholeMicroseconds
+ }
+ measures.add(listOf(ANSI.magenta(name)) + fmtMeasures(times))
+ return results
+ }
+
+ bankSetup { db ->
+ // Prepare custoemr accounts
+ fillCashoutInfo("customer")
+ setMaxDebt("customer", "KUDOS:1000000")
+
+ // Generate data
+ db.conn { genData(it, AMOUNT) }
+
+ // Accounts
+ val paytos = measureAction("account_create") {
+ client.post("/accounts") {
+ json {
+ "username" to "account_bench_$it"
+ "password" to "password"
+ "name" to "Bench Account $it"
+ }
+ }.assertOkJson<RegisterAccountResponse>().internal_payto_uri
+ }
+ measureAction("account_reconfig") {
+ client.patch("/accounts/account_bench_$it") {
+ basicAuth("account_bench_$it", "password")
+ json {
+ "name" to "New Bench Account $it"
+ }
+ }.assertNoContent()
+ }
+ measureAction("account_reconfig_auth") {
+ client.patch("/accounts/account_bench_$it/auth") {
+ basicAuth("account_bench_$it", "password")
+ json {
+ "old_password" to "password"
+ "new_password" to "password"
+ }
+ }.assertNoContent()
+ }
+ measureAction("account_list") {
+ client.get("/accounts") {
+ basicAuth("admin", "admin-password")
+ }.assertOk()
+ }
+ measureAction("account_list_public") {
+ client.get("/public-accounts").assertOk()
+ }
+ measureAction("account_get") {
+ client.get("/accounts/account_bench_$it") {
+ basicAuth("account_bench_$it", "password")
+ }.assertOk()
+ }
+ measureAction("account_delete") {
+ client.delete("/accounts/account_bench_$it") {
+ basicAuth("account_bench_$it", "password")
+ }.assertNoContent()
+ }
+
+ // Tokens
+ val tokens = measureAction("token_create") {
+ client.postA("/accounts/customer/token") {
+ json {
+ "scope" to "readonly"
+ "refreshable" to true
+ }
+ }.assertOkJson<TokenSuccessResponse>().access_token
+ }
+ measureAction("token_refresh") {
+ client.post("/accounts/customer/token") {
+ headers["Authorization"] = "Bearer ${tokens[it]}"
+ json { "scope" to "readonly" }
+ }.assertOk()
+ }
+ measureAction("token_list") {
+ client.getA("/accounts/customer/tokens").assertOk()
+ }
+ measureAction("token_delete") {
+ client.delete("/accounts/customer/token") {
+ headers["Authorization"] = "Bearer ${tokens[it]}"
+ }.assertNoContent()
+ }
+
+ // Transaction
+ val transactions = measureAction("transaction_create") {
+ client.postA("/accounts/customer/transactions") {
+ json {
+ "payto_uri" to "$merchantPayto?receiver-name=Test&message=payout"
+ "amount" to "KUDOS:0.0001"
+ }
+ }.assertOkJson<TransactionCreateResponse>().row_id
+ }
+ measureAction("transaction_get") {
+ client.getA("/accounts/customer/transactions/${transactions[it]}").assertOk()
+ }
+ measureAction("transaction_history") {
+ client.getA("/accounts/customer/transactions").assertOk()
+ }
+ measureAction("transaction_revenue") {
+ client.getA("/accounts/merchant/taler-revenue/history").assertOk()
+ }
+
+ // Withdrawal
+ val withdrawals = measureAction("withdrawal_create") {
+ client.postA("/accounts/customer/withdrawals") {
+ json {
+ "amount" to "KUDOS:0.0001"
+ }
+ }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id
+ }
+ measureAction("withdrawal_get") {
+ client.get("/withdrawals/${withdrawals[it]}").assertOk()
+ }
+ measureAction("withdrawal_status") {
+ client.get("/taler-integration/withdrawal-operation/${withdrawals[it]}").assertOk()
+ }
+ measureAction("withdrawal_select") {
+ client.post("/taler-integration/withdrawal-operation/${withdrawals[it]}") {
+ json {
+ "reserve_pub" to EddsaPublicKey.rand()
+ "selected_exchange" to exchangePayto
+ }
+ }.assertOk()
+ }
+ measureAction("withdrawal_confirm") {
+ client.postA("/accounts/customer/withdrawals/${withdrawals[it]}/confirm")
+ .assertNoContent()
+ }
+ measureAction("withdrawal_abort") {
+ val uuid = client.postA("/accounts/customer/withdrawals") {
+ json {
+ "amount" to "KUDOS:0.0001"
+ }
+ }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id
+ client.post("/taler-integration/withdrawal-operation/$uuid/abort")
+ .assertNoContent()
+ }
+
+ // Cashout
+ val converted = convert("KUDOS:0.1")
+ val cashouts = measureAction("cashout_create") {
+ client.postA("/accounts/customer/cashouts") {
+ json {
+ "request_uid" to ShortHashCode.rand()
+ "amount_debit" to "KUDOS:0.1"
+ "amount_credit" to convert("KUDOS:0.1")
+ }
+ }.assertOkJson<CashoutResponse>().cashout_id
+ }
+ measureAction("cashout_get") {
+ client.getA("/accounts/customer/cashouts/${cashouts[it]}").assertOk()
+ }
+ measureAction("cashout_history") {
+ client.getA("/accounts/customer/cashouts").assertOk()
+ }
+ measureAction("cashout_history_admin") {
+ client.get("/cashouts") {
+ pwAuth("admin")
+ }.assertOk()
+ }
+
+ // Wire gateway
+ measureAction("wg_transfer") {
+ client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ json {
+ "request_uid" to HashCode.rand()
+ "amount" to "KUDOS:0.0001"
+ "exchange_base_url" to "http://exchange.example.com/"
+ "wtid" to ShortHashCode.rand()
+ "credit_account" to customerPayto.canonical
+ }
+ }.assertOk()
+ }
+ measureAction("wg_add") {
+ client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ json {
+ "amount" to "KUDOS:0.0001"
+ "reserve_pub" to EddsaPublicKey.rand()
+ "debit_account" to customerPayto.canonical
+ }
+ }.assertOk()
+ }
+ measureAction("wg_incoming") {
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming")
+ .assertOk()
+ }
+ measureAction("wg_outgoing") {
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing")
+ .assertOk()
+ }
+
+ // TAN challenges
+ val challenges = measureAction("tan_send") {
+ val id = client.patchA("/accounts/customer") {
+ json {
+ "contact_data" to obj {
+ "phone" to "+99"
+ "email" to "email@example.com"
+ }
+ "tan_channel" to "sms"
+ }
+ }.assertAcceptedJson<TanChallenge>().challenge_id
+ val res = client.postA("/accounts/customer/challenge/$id").assertOkJson<TanTransmission>()
+ val code = tanCode(res.tan_info)
+ Pair(id, code)
+ }
+ measureAction("tan_send") {
+ val (id, code) = challenges[it]
+ client.postA("/accounts/customer/challenge/$id/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+ }
+
+ // Other
+ measureAction("monitor") {
+ val uuid = client.get("/monitor") {
+ pwAuth("admin")
+ }.assertOk()
+ }
+ db.gc.collect(Instant.now(), java.time.Duration.ZERO, java.time.Duration.ZERO, java.time.Duration.ZERO)
+ measureAction("gc") {
+ db.gc.collect(Instant.now(), java.time.Duration.ZERO, java.time.Duration.ZERO, java.time.Duration.ZERO)
+ }
+ }
+
+ val cols = IntArray(5) { 0 }
+
+ printTable(
+ listOf("benchmark", "min", "mean", "max", "std").map { ANSI.bold(it) },
+ measures,
+ ' ',
+ listOf(ColumnStyle.DEFAULT) + List(5) { ColumnStyle(false) }
+ )
+ }
+}
+\ No newline at end of file
diff --git a/common/src/main/kotlin/AnsiColor.kt b/common/src/main/kotlin/AnsiColor.kt
@@ -0,0 +1,80 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common
+
+object ANSI {
+ private val ANSI_PATTERN = Regex("\\u001B\\[[;\\d]*m")
+
+ enum class Color(val code: Int) {
+ BLACK(0),
+ RED(1),
+ GREEN(2),
+ YELLOW(3),
+ BLUE(4),
+ MAGENTA(5),
+ CYAN(6),
+ WHITE(7)
+ }
+
+ fun espacedSize(msg: String): Int {
+ // TODO remove allocation ?
+ val clean = msg.replace(ANSI_PATTERN, "")
+ return clean.length
+ }
+
+ fun fmt(msg: String, fg: Color? = null, bg: Color? = null, bold: Boolean = false): String {
+ if (fg == null && bg == null && !bold) {
+ return msg
+ }
+ return buildString {
+ fun next() {
+ val c = last()
+ if (c != '[' && c != ';') {
+ append(';')
+ }
+ }
+
+ append("\u001b[")
+ if (bold) {
+ next()
+ append('1')
+ }
+ if (fg != null) {
+ next()
+ append('3')
+ append(fg.code.toString())
+ }
+ if (bg != null) {
+ next()
+ append('4')
+ append(bg.code.toString())
+ }
+ append('m')
+ append(msg)
+ append("\u001b[0m")
+ }
+ }
+
+ fun red(msg: String) = fmt(msg, Color.RED)
+ fun green(msg: String) = fmt(msg, Color.GREEN)
+ fun yellow(msg: String) = fmt(msg, Color.YELLOW)
+ fun magenta(msg: String) = fmt(msg, Color.MAGENTA)
+ fun bold(msg: String) = fmt(msg, bold = true)
+}
+\ No newline at end of file
diff --git a/common/src/main/kotlin/Table.kt b/common/src/main/kotlin/Table.kt
@@ -0,0 +1,85 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common
+
+import kotlin.math.max
+
+data class ColumnStyle(
+ val alignLeft: Boolean = true
+) {
+ companion object {
+ val DEFAULT = ColumnStyle()
+ }
+}
+
+fun printTable(
+ columns: List<String>,
+ rows: List<List<String>>,
+ separator: Char = '|',
+ colStyle: List<ColumnStyle> = listOf()
+) {
+ val cols: List<Pair<String, Int>> = columns.mapIndexed { i, name ->
+ val maxRow: Int = rows.asSequence().map {
+ ANSI.espacedSize(it[i])
+ }.maxOrNull() ?: 0
+ Pair(name, max(ANSI.espacedSize(name), maxRow))
+ }
+ val table = buildString {
+ fun padding(length: Int) {
+ repeat(length) { append (" ") }
+ }
+ var first = true
+ for ((name, len) in cols) {
+ if (!first) {
+ append(separator)
+ } else {
+ first = false
+ }
+ val pad = len - ANSI.espacedSize(name)
+ padding(pad / 2)
+ append(name)
+ padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 })
+ }
+ append("\n")
+ for (row in rows) {
+ var first = true
+ cols.forEachIndexed { i, met ->
+ val str = row[i]
+ val style = colStyle.getOrNull(i) ?: ColumnStyle.DEFAULT
+ if (!first) {
+ append(separator)
+ } else {
+ first = false
+ }
+ val (name, len) = met
+ val pad = len - ANSI.espacedSize(str)
+ if (style.alignLeft) {
+ append(str)
+ padding(pad)
+ } else {
+ padding(pad)
+ append(str)
+ }
+ }
+ append("\n")
+ }
+ }
+ print(table)
+}
+\ No newline at end of file
diff --git a/database-versioning/libeufin-bank-0006.sql b/database-versioning/libeufin-bank-0006.sql
@@ -0,0 +1,38 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2024 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+BEGIN;
+
+SELECT _v.register_patch('libeufin-bank-0006', NULL, NULL);
+SET search_path TO libeufin_bank;
+
+-- Add missing index for common queries
+
+CREATE INDEX bank_accounts_public_index ON bank_accounts (bank_account_id) WHERE is_public = true;
+COMMENT ON INDEX bank_accounts_public_index IS 'for listing public accounts';
+
+CREATE INDEX bank_account_transactions_index ON bank_account_transactions (bank_account_id, bank_transaction_id);
+COMMENT ON INDEX bank_accounts_public_index IS 'for listing bank account''s transaction';
+
+CREATE INDEX bearer_tokens_index ON bearer_tokens USING btree (bank_customer, bearer_token_id);
+COMMENT ON INDEX bearer_tokens_index IS 'for listing bank customer''s bearer token';
+
+CREATE INDEX cashout_operations_index ON cashout_operations USING btree (bank_account, cashout_id);
+COMMENT ON INDEX cashout_operations_index IS 'for listing bank customer''s cashout operations';
+
+CREATE INDEX customers_deleted_index ON customers (customer_id) WHERE deleted_at IS NOT NULL;
+COMMENT ON INDEX customers_deleted_index IS 'for garbage collection';
+
+COMMIT;
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -53,7 +53,6 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
-import kotlin.math.max
internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus")
@@ -393,44 +392,7 @@ class ListCmd: CliktCommand("List nexus transactions", name = "list") {
)
}
}
- val cols: List<Pair<String, Int>> = columnNames.mapIndexed { i, name ->
- val maxRow: Int = rows.asSequence().map { it[i].length }.maxOrNull() ?: 0
- Pair(name, max(name.length, maxRow))
- }
- val table = buildString {
- fun padding(length: Int) {
- repeat(length) { append (" ") }
- }
- var first = true
- for ((name, len) in cols) {
- if (!first) {
- append("|")
- } else {
- first = false
- }
- val pad = len - name.length
- padding(pad / 2)
- append(name)
- padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 })
- }
- append("\n")
- for (row in rows) {
- var first = true
- for ((met, str) in cols.zip(row)) {
- if (!first) {
- append("|")
- } else {
- first = false
- }
- val (name, len) = met
- val pad = len - str.length
- append(str)
- padding(pad)
- }
- append("\n")
- }
- }
- print(table)
+ printTable(columnNames, rows)
}
}
}
diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt
@@ -34,21 +34,22 @@ import tech.libeufin.nexus.loadBankKeys
import tech.libeufin.nexus.loadClientKeys
import tech.libeufin.nexus.loadConfig
import tech.libeufin.nexus.loadJsonFile
+import tech.libeufin.common.ANSI
import kotlin.io.path.*
val nexusCmd = LibeufinNexusCommand()
val client = HttpClient(CIO)
fun step(name: String) {
- println("\u001b[35m$name\u001b[0m")
+ println(ANSI.magenta(name))
}
fun msg(msg: String) {
- println("\u001b[33m$msg\u001b[0m")
+ println(ANSI.yellow(msg))
}
fun ask(question: String): String? {
- print("\u001b[;1m$question\u001b[0m")
+ print(ANSI.bold(question))
System.out.flush()
return readlnOrNull()
}
@@ -57,9 +58,9 @@ fun CliktCommand.run(arg: String): Boolean {
val res = this.test(arg)
print(res.output)
if (res.statusCode != 0) {
- println("\u001b[;31mERROR ${res.statusCode}\u001b[0m")
+ println(ANSI.red("ERROR ${res.statusCode}"))
} else {
- println("\u001b[;32mOK\u001b[0m")
+ println(ANSI.green("OK"))
}
return res.statusCode == 0
}