Skip to content

Commit

Permalink
fix: EXPOSED-407 compositeMoney() nullability definition issues (#2137)
Browse files Browse the repository at this point in the history
* fix: EXPOSED-407 compositeMoney() nullability definition issues

Defining a composite money column as nullable returns a different type, as
each component column is made nullable, but not the actual expression itself.

This fix adds an overload that returns the expected type and ensures that the
expression is also made to be nullable overall. Tests for using the component
columns individually have also been included.
  • Loading branch information
bog-walk authored Jul 2, 2024
1 parent 80d2df4 commit cd27534
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 35 deletions.
3 changes: 2 additions & 1 deletion exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ public final class org/jetbrains/exposed/sql/Between : org/jetbrains/exposed/sql
}

public abstract class org/jetbrains/exposed/sql/BiCompositeColumn : org/jetbrains/exposed/sql/CompositeColumn {
public fun <init> (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
public fun <init> (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Z)V
public synthetic fun <init> (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
protected final fun getColumn1 ()Lorg/jetbrains/exposed/sql/Column;
protected final fun getColumn2 ()Lorg/jetbrains/exposed/sql/Column;
public fun getRealColumns ()Ljava/util/List;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ abstract class BiCompositeColumn<C1, C2, T>(
/** Transformation that receives the column's composite value and returns the parsed values of the underlying columns. */
val transformFromValue: (T) -> Pair<C1?, C2?>,
/** Transformation that receives the retrieved values of [column1] and [column2] and returns a composite value. */
val transformToValue: (Any?, Any?) -> T
val transformToValue: (Any?, Any?) -> T,
nullable: Boolean = false
) : CompositeColumn<T>() {
init {
this.nullable = nullable
}

override fun getRealColumns(): List<Column<*>> = listOf(column1, column2)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,7 @@ open class Table(name: String = "") : ColumnSet(), DdlAware {

/** Marks this [CompositeColumn] as nullable. */
@Suppress("UNCHECKED_CAST")
@LowPriorityInOverloadResolution
fun <T : Any, C : CompositeColumn<T>> C.nullable(): CompositeColumn<T?> = apply {
nullable = true
getRealColumns().filter { !it.columnType.nullable }.forEach { (it as Column<Any>).nullable() }
Expand Down
4 changes: 3 additions & 1 deletion exposed-money/api/exposed-money.api
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
public final class org/jetbrains/exposed/sql/money/CompositeMoneyColumn : org/jetbrains/exposed/sql/BiCompositeColumn {
public fun <init> (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;)V
public fun <init> (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Z)V
public synthetic fun <init> (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getAmount ()Lorg/jetbrains/exposed/sql/Column;
public final fun getCurrency ()Lorg/jetbrains/exposed/sql/Column;
}

public final class org/jetbrains/exposed/sql/money/CompositeMoneyColumnKt {
public static final fun CompositeMoneyColumn (Lorg/jetbrains/exposed/sql/Table;IILjava/lang/String;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/money/CompositeMoneyColumn;
public static final fun nullable (Lorg/jetbrains/exposed/sql/money/CompositeMoneyColumn;)Lorg/jetbrains/exposed/sql/money/CompositeMoneyColumn;
}

public final class org/jetbrains/exposed/sql/money/CompositeMoneyColumnTypeKt {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import javax.money.Monetary
import javax.money.MonetaryAmount

/**
* Represents amount of money and currency using Java Money API. Data are stored using two composite columns.
* Represents amount of money and currency using Java Money API. Data is stored using two composite columns.
*
* @author Vladislav Kisel
*/
class CompositeMoneyColumn<T1 : BigDecimal?, T2 : CurrencyUnit?, R : MonetaryAmount?>(val amount: Column<T1>, val currency: Column<T2>) :
class CompositeMoneyColumn<T1 : BigDecimal?, T2 : CurrencyUnit?, R : MonetaryAmount?>(
val amount: Column<T1>,
val currency: Column<T2>,
nullable: Boolean = false
) :
BiCompositeColumn<T1, T2, R>(
column1 = amount,
column2 = currency,
Expand All @@ -36,12 +40,26 @@ class CompositeMoneyColumn<T1 : BigDecimal?, T2 : CurrencyUnit?, R : MonetaryAmo

result.create() as R
}
}
},
nullable
)

/**
* Creates a composite column made up of a numeric column, with the specified [amountName], for storing numbers with
* the specified [precision] and [scale], as wel as a character column, with the specified [currencyName], for storing
* currency (as javax.money.CurrencyUnit).
*/
@Suppress("FunctionNaming")
fun CompositeMoneyColumn(table: Table, precision: Int, scale: Int, amountName: String, currencyName: String) =
CompositeMoneyColumn<BigDecimal, CurrencyUnit, MonetaryAmount>(
amount = Column(table, amountName, DecimalColumnType(precision, scale)),
currency = Column(table, currencyName, CurrencyColumnType())
)

/** Marks this [CompositeMoneyColumn] as nullable. */
@Suppress("UNCHECKED_CAST")
fun <T1 : BigDecimal, T2 : CurrencyUnit, R : MonetaryAmount> CompositeMoneyColumn<T1, T2, R>.nullable(): CompositeMoneyColumn<T1?, T2?, R?> {
return with(amount.table) {
(this@nullable as BiCompositeColumn<T1, T2, R>).nullable() as CompositeMoneyColumn<T1?, T2?, R?>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import javax.money.MonetaryAmount
fun Table.compositeMoney(precision: Int, scale: Int, amountName: String, currencyName: String = amountName + "_C") =
registerCompositeColumn(CompositeMoneyColumn(this, precision, scale, amountName, currencyName))

/** Creates a composite column made up of a decimal column and a currency column. */
/**
* Creates a composite column made up of a decimal column and a currency column.
*
* @sample org.jetbrains.exposed.sql.money.MoneyBaseTest.testUsingManualCompositeMoneyColumns
*/
fun Table.compositeMoney(
amountColumn: Column<BigDecimal>,
currencyColumn: Column<CurrencyUnit>
Expand All @@ -28,13 +32,17 @@ fun Table.compositeMoney(
}
}

/** Creates a composite column made up of a nullable decimal column and a nullable currency column. */
/**
* Creates a composite column made up of a nullable decimal column and a nullable currency column.
*
* @sample org.jetbrains.exposed.sql.money.MoneyBaseTest.testUsingManualCompositeMoneyColumns
*/
@JvmName("compositeMoneyNullable")
fun Table.compositeMoney(
amountColumn: Column<BigDecimal?>,
currencyColumn: Column<CurrencyUnit?>
): CompositeMoneyColumn<BigDecimal?, CurrencyUnit?, MonetaryAmount?> {
return CompositeMoneyColumn<BigDecimal?, CurrencyUnit?, MonetaryAmount?>(amountColumn, currencyColumn).also {
return CompositeMoneyColumn<BigDecimal?, CurrencyUnit?, MonetaryAmount?>(amountColumn, currencyColumn, true).also {
if (amountColumn !in columns && currencyColumn !in columns) {
registerCompositeColumn(it)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import org.jetbrains.exposed.sql.tests.DatabaseTestsBase
import org.jetbrains.exposed.sql.tests.TestDB
import org.jetbrains.exposed.sql.tests.shared.assertEquals
import org.jetbrains.exposed.sql.tests.shared.expectException
import org.junit.Ignore
import org.junit.Test
import java.math.BigDecimal
import javax.money.CurrencyUnit
import javax.money.Monetary
import javax.money.MonetaryAmount

private const val AMOUNT_SCALE = 5
Expand All @@ -24,29 +24,48 @@ open class MoneyBaseTest : DatabaseTestsBase() {

@Test
fun testInsertSelectMoney() {
testInsertedAndSelect(Money.of(BigDecimal.TEN, "USD"))
withTables(Account) {
assertInsertOfCompositeValueReturnsEquivalentOnSelect(Money.of(BigDecimal.TEN, "USD"))
Account.deleteAll()
assertInsertOfComponentValuesReturnsEquivalentOnSelect(Money.of(BigDecimal.TEN, "USD"))
}
}

@Test
fun testInsertSelectFloatingMoney() {
testInsertedAndSelect(Money.of(BigDecimal("0.12345"), "USD"))
withTables(Account) {
assertInsertOfCompositeValueReturnsEquivalentOnSelect(Money.of(BigDecimal("0.12345"), "USD"))
Account.deleteAll()
assertInsertOfComponentValuesReturnsEquivalentOnSelect(Money.of(BigDecimal("0.12345"), "USD"))
}
}

@Test
@Ignore // TODO not supported yet
fun testInsertSelectNull() {
testInsertedAndSelect(null)
withTables(Account) {
assertInsertOfCompositeValueReturnsEquivalentOnSelect(null)
Account.deleteAll()
assertInsertOfComponentValuesReturnsEquivalentOnSelect(null)
}
}

@Test
fun testInsertSelectOutOfLength() {
val toInsert = Money.of(BigDecimal.valueOf(12345678901), "CZK")
val amount = BigDecimal.valueOf(12345678901)
val toInsert = Money.of(amount, "CZK")
withTables(excludeSettings = listOf(TestDB.SQLITE), Account) {
expectException<ExposedSQLException> {
val accountID = Account.insertAndGetId {
Account.insertAndGetId {
it[composite_money] = toInsert
}
}

expectException<ExposedSQLException> {
Account.insertAndGetId {
it[composite_money.amount] = amount
it[composite_money.currency] = toInsert.currency
}
}
}
}

Expand Down Expand Up @@ -78,40 +97,89 @@ open class MoneyBaseTest : DatabaseTestsBase() {
}

@Test
fun testNullableCompositeColumnInsertAndSelect() {
val table = object : IntIdTable("CompositeTable") {
val composite_money = compositeMoney(8, AMOUNT_SCALE, "composite_money").nullable()
fun testUsingManualCompositeMoneyColumns() {
val tester = object : Table("tester") {
val money = compositeMoney(
decimal("amount", 8, AMOUNT_SCALE),
currency("currency")
)
val nullableMoney = compositeMoney(
decimal("nullable_amount", 8, AMOUNT_SCALE).nullable(),
currency("nullable_currency").nullable()
)
}

withTables(table) {
val id = table.insertAndGetId {
it[composite_money] = null
withTables(tester) {
val amount = BigDecimal(99).setScale(AMOUNT_SCALE)
val currencyUnit = Monetary.getCurrency("EUR")
tester.insert {
it[money.amount] = amount
it[money.currency] = currencyUnit
it[nullableMoney.amount] = null
it[nullableMoney.currency] = null
}

val resultRow = table.selectAll().where { table.id.eq(id) }.single()
val result = resultRow[table.composite_money]
val result1 = tester
.selectAll()
.where { tester.nullableMoney.amount.isNull() and tester.nullableMoney.currency.isNull() }
.single()
assertEquals(amount, result1[tester.money.amount])

assertEquals(null, result)
tester.update {
it[tester.nullableMoney.amount] = amount
it[tester.nullableMoney.currency] = currencyUnit
}

val result2 = tester
.select(tester.money.currency, tester.nullableMoney.currency)
.where { tester.money.amount.isNotNull() and tester.nullableMoney.amount.isNotNull() }
.single()
assertEquals(currencyUnit, result2[tester.money.currency])
assertEquals(currencyUnit, result2[tester.nullableMoney.currency])

// manual composite columns should still accept composite values
val compositeMoney = Money.of(BigDecimal(10), "CAD")
tester.insert {
it[money] = compositeMoney
it[nullableMoney] = null
}
tester.insert {
it[money] = compositeMoney
}

assertEquals(2, tester.selectAll().where { tester.nullableMoney eq null }.count())
}
}

private fun testInsertedAndSelect(toInsert: Money?) {
withTables(Account) {
val accountID = Account.insertAndGetId {
it[composite_money] = toInsert!!
}
private fun Transaction.assertInsertOfCompositeValueReturnsEquivalentOnSelect(toInsert: Money?) {
val accountID = Account.insertAndGetId {
it[composite_money] = toInsert
}

val single = Account.select(Account.composite_money).where { Account.id.eq(accountID) }.single()
val inserted = single[Account.composite_money]

val single = Account.select(Account.composite_money).where { Account.id.eq(accountID) }.single()
val inserted = single[Account.composite_money]
assertEquals(toInsert, inserted)
}

assertEquals(toInsert, inserted)
private fun Transaction.assertInsertOfComponentValuesReturnsEquivalentOnSelect(toInsert: Money?) {
val amount: BigDecimal? = toInsert?.numberStripped?.setScale(AMOUNT_SCALE)
val currencyUnit: CurrencyUnit? = toInsert?.currency
val accountID = Account.insertAndGetId {
it[composite_money.amount] = amount
it[composite_money.currency] = currencyUnit
}

val single = Account.select(Account.composite_money).where { Account.id eq accountID }.single()

assertEquals(amount, single[Account.composite_money.amount])
assertEquals(currencyUnit, single[Account.composite_money.currency])
}
}

class AccountDao(id: EntityID<Int>) : IntEntity(id) {

val money: MonetaryAmount by Account.composite_money
val money: MonetaryAmount? by Account.composite_money

val currency: CurrencyUnit? by Account.composite_money.currency

Expand All @@ -122,5 +190,5 @@ class AccountDao(id: EntityID<Int>) : IntEntity(id) {

object Account : IntIdTable("AccountTable") {

val composite_money = compositeMoney(8, AMOUNT_SCALE, "composite_money")
val composite_money = compositeMoney(8, AMOUNT_SCALE, "composite_money").nullable()
}

0 comments on commit cd27534

Please sign in to comment.