mirror of
https://github.com/google/nomulus.git
synced 2025-07-06 11:13:35 +02:00
Properly handle Joda Money in JPA (#1447)
* Properly handle Joda Money in JPA Joda Money has BigDecimal as amount, which is mapped to a numeric(19,2) column in the database. As a result, the Money amount load from DB has scale 2. This becomes a problem with currencies such as JPY, which requires scale to be 0. To properly load a currency, we must adjust the scale post-load. The current approach, which uses Hibernate component mapping, puts the burden of post-load cleanup on each entity type that uses Money. It is easy to forget this, as we just discovered. This PR uses a CompositeUserType to map Money. It adjusts the scale properly when loading Money instances. Although CompositeUserType appear to be deprecated in Hibernate 6, it is the only proper solution right now for mapping non-owned classes.
This commit is contained in:
parent
8a121a7b79
commit
fb8864acef
7 changed files with 273 additions and 158 deletions
|
@ -19,12 +19,9 @@ import static google.registry.persistence.transaction.TransactionManagerFactory.
|
|||
|
||||
import com.google.common.base.Preconditions;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableSortedMap;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import google.registry.model.EppResource;
|
||||
import google.registry.model.ImmutableObject;
|
||||
import google.registry.model.billing.BillingEvent.OneTime;
|
||||
import google.registry.model.contact.ContactBase;
|
||||
import google.registry.model.contact.ContactHistory;
|
||||
import google.registry.model.domain.DomainContent;
|
||||
|
@ -34,7 +31,6 @@ import google.registry.model.host.HostHistory;
|
|||
import google.registry.model.poll.PollMessage;
|
||||
import google.registry.model.replay.SqlEntity;
|
||||
import google.registry.model.reporting.HistoryEntry;
|
||||
import google.registry.model.tld.Registry;
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.util.HashMap;
|
||||
|
@ -48,7 +44,6 @@ import org.apache.beam.sdk.metrics.Metrics;
|
|||
import org.apache.beam.sdk.transforms.DoFn;
|
||||
import org.apache.beam.sdk.values.KV;
|
||||
import org.apache.beam.sdk.values.TupleTag;
|
||||
import org.joda.money.Money;
|
||||
|
||||
/** Helpers for use by {@link ValidateSqlPipeline}. */
|
||||
final class ValidateSqlUtils {
|
||||
|
@ -195,12 +190,6 @@ final class ValidateSqlUtils {
|
|||
if (sqlEntity instanceof HistoryEntry) {
|
||||
return (SqlEntity) normalizeHistoryEntry((HistoryEntry) sqlEntity);
|
||||
}
|
||||
if (sqlEntity instanceof Registry) {
|
||||
return normalizeRegistry((Registry) sqlEntity);
|
||||
}
|
||||
if (sqlEntity instanceof OneTime) {
|
||||
return normalizeOnetime((OneTime) sqlEntity);
|
||||
}
|
||||
return sqlEntity;
|
||||
}
|
||||
|
||||
|
@ -278,44 +267,4 @@ final class ValidateSqlUtils {
|
|||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Registry normalizeRegistry(Registry registry) {
|
||||
if (registry.getStandardCreateCost().getAmount().scale() == 0) {
|
||||
return registry;
|
||||
}
|
||||
return registry
|
||||
.asBuilder()
|
||||
.setCreateBillingCost(normalizeMoney(registry.getStandardCreateCost()))
|
||||
.setRestoreBillingCost(normalizeMoney(registry.getStandardRestoreCost()))
|
||||
.setServerStatusChangeBillingCost(normalizeMoney(registry.getServerStatusChangeCost()))
|
||||
.setRegistryLockOrUnlockBillingCost(
|
||||
normalizeMoney(registry.getRegistryLockOrUnlockBillingCost()))
|
||||
.setRenewBillingCostTransitions(
|
||||
ImmutableSortedMap.copyOf(
|
||||
Maps.transformValues(
|
||||
registry.getRenewBillingCostTransitions(), ValidateSqlUtils::normalizeMoney)))
|
||||
.setEapFeeSchedule(
|
||||
ImmutableSortedMap.copyOf(
|
||||
Maps.transformValues(
|
||||
registry.getEapFeeScheduleAsMap(), ValidateSqlUtils::normalizeMoney)))
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Normalizes an {@link OneTime} instance for comparison. */
|
||||
static OneTime normalizeOnetime(OneTime oneTime) {
|
||||
Money cost = oneTime.getCost();
|
||||
if (cost.getAmount().scale() == 0) {
|
||||
return oneTime;
|
||||
}
|
||||
try {
|
||||
return oneTime.asBuilder().setCost(normalizeMoney(oneTime.getCost())).build();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Money normalizeMoney(Money original) {
|
||||
// Strips ".00" from the amount.
|
||||
return Money.of(original.getCurrencyUnit(), original.getAmount().stripTrailingZeros());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ import google.registry.persistence.BillingVKey.BillingEventVKey;
|
|||
import google.registry.persistence.BillingVKey.BillingRecurrenceVKey;
|
||||
import google.registry.persistence.VKey;
|
||||
import google.registry.persistence.WithLongVKey;
|
||||
import google.registry.persistence.converter.JodaMoneyType;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
@ -66,6 +67,8 @@ import javax.persistence.Enumerated;
|
|||
import javax.persistence.MappedSuperclass;
|
||||
import javax.persistence.PostLoad;
|
||||
import javax.persistence.Transient;
|
||||
import org.hibernate.annotations.Columns;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.joda.money.Money;
|
||||
import org.joda.time.DateTime;
|
||||
|
||||
|
@ -312,10 +315,8 @@ public abstract class BillingEvent extends ImmutableObject
|
|||
public static class OneTime extends BillingEvent implements DatastoreAndSqlEntity {
|
||||
|
||||
/** The billable value. */
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "money.amount", column = @Column(name = "cost_amount")),
|
||||
@AttributeOverride(name = "money.currency", column = @Column(name = "cost_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(columns = {@Column(name = "cost_amount"), @Column(name = "cost_currency")})
|
||||
Money cost;
|
||||
|
||||
/** When the cost should be billed. */
|
||||
|
|
|
@ -64,6 +64,7 @@ import google.registry.model.replay.DatastoreAndSqlEntity;
|
|||
import google.registry.model.tld.label.PremiumList;
|
||||
import google.registry.model.tld.label.ReservedList;
|
||||
import google.registry.persistence.VKey;
|
||||
import google.registry.persistence.converter.JodaMoneyType;
|
||||
import google.registry.util.Idn;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
@ -72,13 +73,13 @@ import java.util.concurrent.ExecutionException;
|
|||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.persistence.AttributeOverride;
|
||||
import javax.persistence.AttributeOverrides;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.EnumType;
|
||||
import javax.persistence.Enumerated;
|
||||
import javax.persistence.PostLoad;
|
||||
import javax.persistence.Transient;
|
||||
import org.hibernate.annotations.Columns;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.joda.money.CurrencyUnit;
|
||||
import org.joda.money.Money;
|
||||
import org.joda.time.DateTime;
|
||||
|
@ -466,47 +467,39 @@ public class Registry extends ImmutableObject
|
|||
CurrencyUnit currency = DEFAULT_CURRENCY;
|
||||
|
||||
/** The per-year billing cost for registering a new domain name. */
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(
|
||||
name = "money.amount",
|
||||
column = @Column(name = "create_billing_cost_amount")),
|
||||
@AttributeOverride(
|
||||
name = "money.currency",
|
||||
column = @Column(name = "create_billing_cost_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(
|
||||
columns = {
|
||||
@Column(name = "create_billing_cost_amount"),
|
||||
@Column(name = "create_billing_cost_currency")
|
||||
})
|
||||
Money createBillingCost = DEFAULT_CREATE_BILLING_COST;
|
||||
|
||||
/** The one-time billing cost for restoring a domain name from the redemption grace period. */
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(
|
||||
name = "money.amount",
|
||||
column = @Column(name = "restore_billing_cost_amount")),
|
||||
@AttributeOverride(
|
||||
name = "money.currency",
|
||||
column = @Column(name = "restore_billing_cost_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(
|
||||
columns = {
|
||||
@Column(name = "restore_billing_cost_amount"),
|
||||
@Column(name = "restore_billing_cost_currency")
|
||||
})
|
||||
Money restoreBillingCost = DEFAULT_RESTORE_BILLING_COST;
|
||||
|
||||
/** The one-time billing cost for changing the server status (i.e. lock). */
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(
|
||||
name = "money.amount",
|
||||
column = @Column(name = "server_status_change_billing_cost_amount")),
|
||||
@AttributeOverride(
|
||||
name = "money.currency",
|
||||
column = @Column(name = "server_status_change_billing_cost_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(
|
||||
columns = {
|
||||
@Column(name = "server_status_change_billing_cost_amount"),
|
||||
@Column(name = "server_status_change_billing_cost_currency")
|
||||
})
|
||||
Money serverStatusChangeBillingCost = DEFAULT_SERVER_STATUS_CHANGE_BILLING_COST;
|
||||
|
||||
/** The one-time billing cost for a registry lock/unlock action initiated by a registrar. */
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(
|
||||
name = "money.amount",
|
||||
column = @Column(name = "registry_lock_or_unlock_cost_amount")),
|
||||
@AttributeOverride(
|
||||
name = "money.currency",
|
||||
column = @Column(name = "registry_lock_or_unlock_cost_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(
|
||||
columns = {
|
||||
@Column(name = "registry_lock_or_unlock_cost_amount"),
|
||||
@Column(name = "registry_lock_or_unlock_cost_currency")
|
||||
})
|
||||
Money registryLockOrUnlockBillingCost = DEFAULT_REGISTRY_LOCK_OR_UNLOCK_BILLING_COST;
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
package google.registry.persistence;
|
||||
|
||||
import google.registry.persistence.converter.IntervalDescriptor;
|
||||
import google.registry.persistence.converter.JodaMoneyType;
|
||||
import google.registry.persistence.converter.StringCollectionDescriptor;
|
||||
import google.registry.persistence.converter.StringMapDescriptor;
|
||||
import java.sql.Types;
|
||||
|
@ -34,6 +35,7 @@ public class NomulusPostgreSQLDialect extends PostgreSQL95Dialect {
|
|||
registerColumnType(IntervalDescriptor.COLUMN_TYPE, IntervalDescriptor.COLUMN_NAME);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation") // See comments below on JodaMoneyType.
|
||||
@Override
|
||||
public void contributeTypes(
|
||||
TypeContributions typeContributions, ServiceRegistry serviceRegistry) {
|
||||
|
@ -44,5 +46,8 @@ public class NomulusPostgreSQLDialect extends PostgreSQL95Dialect {
|
|||
typeContributions.contributeSqlTypeDescriptor(StringMapDescriptor.getInstance());
|
||||
typeContributions.contributeJavaTypeDescriptor(IntervalDescriptor.getInstance());
|
||||
typeContributions.contributeSqlTypeDescriptor(IntervalDescriptor.getInstance());
|
||||
// Below method (contributing CompositeUserType) is deprecated. Please see javadoc of
|
||||
// JodaMoneyType for reasons.
|
||||
typeContributions.contributeType(JodaMoneyType.INSTANCE, JodaMoneyType.TYPE_NAME);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,178 @@
|
|||
// Copyright 2021 The Nomulus Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package google.registry.persistence.converter;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
import org.hibernate.HibernateException;
|
||||
import org.hibernate.engine.spi.SharedSessionContractImplementor;
|
||||
import org.hibernate.type.StandardBasicTypes;
|
||||
import org.hibernate.type.Type;
|
||||
import org.hibernate.usertype.CompositeUserType;
|
||||
import org.joda.money.CurrencyUnit;
|
||||
import org.joda.money.Money;
|
||||
|
||||
/**
|
||||
* Defines JPA mapping for {@link Money Joda Money type}.
|
||||
*
|
||||
* <p>{@code Money} is mapped to two table columns, a text {@code currency} column that stores the
|
||||
* currency code, and a numeric {@code amount} column that stores the amount.
|
||||
*
|
||||
* <p>The main purpose of this class is to normalize the amount loaded from the database. To support
|
||||
* all currency types, the scale of the numeric column is set to 2. As a result, the {@link
|
||||
* BigDecimal} instances obtained from query ResultSets all have their scale at 2. However, some
|
||||
* currency types, e.g., JPY requires that the scale be zero. This class strips trailing zeros from
|
||||
* each loaded BigDecimal, then calls the appropriate factory method for Money, which will adjust
|
||||
* the scale appropriately.
|
||||
*
|
||||
* <p>Although {@link CompositeUserType} is likely to suffer breaking change in Hibernate 6, it is
|
||||
* the only option. The suggested alternatives such as Hibernate component or Java Embeddable do not
|
||||
* work in this case. Hibernate component (our previous solution that is replaced by this class)
|
||||
* does not allow manipulation of the loaded amount objects. Java Embeddable is not applicable since
|
||||
* we do not own the Joda money classes.
|
||||
*
|
||||
* <p>Usage:
|
||||
*
|
||||
* <pre>{@code
|
||||
* '@'Type(type = JodaMoneyType.TYPE_NAME)
|
||||
* '@'Columns(
|
||||
* columns = {
|
||||
* '@'Column(name = "cost_amount"),
|
||||
* '@'Column(name = "cost_currency")
|
||||
* }
|
||||
* )
|
||||
* Money cost;
|
||||
* }</pre>
|
||||
*/
|
||||
public class JodaMoneyType implements CompositeUserType {
|
||||
|
||||
public static final JodaMoneyType INSTANCE = new JodaMoneyType();
|
||||
|
||||
/** The name of this type registered with JPA. See the example in class doc. */
|
||||
public static final String TYPE_NAME = "JodaMoney";
|
||||
|
||||
// JPA property names that can be used in JPQL queries.
|
||||
private static final ImmutableList<String> JPA_PROPERTY_NAMES =
|
||||
ImmutableList.of("amount", "currency");
|
||||
private static final ImmutableList<Type> PROPERTY_TYPES =
|
||||
ImmutableList.of(StandardBasicTypes.BIG_DECIMAL, StandardBasicTypes.STRING);
|
||||
private static final int AMOUNT_ID = JPA_PROPERTY_NAMES.indexOf("amount");
|
||||
private static final int CURRENCY_ID = JPA_PROPERTY_NAMES.indexOf("currency");
|
||||
|
||||
@Override
|
||||
public String[] getPropertyNames() {
|
||||
return JPA_PROPERTY_NAMES.toArray(new String[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type[] getPropertyTypes() {
|
||||
return PROPERTY_TYPES.toArray(new Type[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPropertyValue(Object component, int property) throws HibernateException {
|
||||
if (property >= JPA_PROPERTY_NAMES.size()) {
|
||||
throw new HibernateException("Property index too large: " + property);
|
||||
}
|
||||
Money money = (Money) component;
|
||||
return property == AMOUNT_ID ? money.getAmount() : money.getCurrencyUnit().getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPropertyValue(Object component, int property, Object value)
|
||||
throws HibernateException {
|
||||
throw new HibernateException("Money is immutable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class returnedClass() {
|
||||
return Money.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object x, Object y) throws HibernateException {
|
||||
return Objects.equals(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode(Object x) throws HibernateException {
|
||||
return Objects.hashCode(x);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object nullSafeGet(
|
||||
ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner)
|
||||
throws HibernateException, SQLException {
|
||||
BigDecimal amount = StandardBasicTypes.BIG_DECIMAL.nullSafeGet(rs, names[AMOUNT_ID], session);
|
||||
CurrencyUnit currencyUnit =
|
||||
CurrencyUnit.of(StandardBasicTypes.STRING.nullSafeGet(rs, names[CURRENCY_ID], session));
|
||||
if (amount != null && currencyUnit != null) {
|
||||
return Money.of(currencyUnit, amount.stripTrailingZeros());
|
||||
}
|
||||
if (amount == null && currencyUnit == null) {
|
||||
return null;
|
||||
}
|
||||
throw new HibernateException("Mismatching null state between currency and amount.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void nullSafeSet(
|
||||
PreparedStatement st, Object value, int index, SharedSessionContractImplementor session)
|
||||
throws HibernateException, SQLException {
|
||||
BigDecimal amount = value == null ? null : ((Money) value).getAmount();
|
||||
String currencyUnit = value == null ? null : ((Money) value).getCurrencyUnit().getCode();
|
||||
|
||||
if ((amount == null && currencyUnit != null) || (amount != null && currencyUnit == null)) {
|
||||
throw new HibernateException("Mismatching null state between currency and amount.");
|
||||
}
|
||||
StandardBasicTypes.BIG_DECIMAL.nullSafeSet(st, amount, index, session);
|
||||
StandardBasicTypes.STRING.nullSafeSet(st, currencyUnit, index + 1, session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object deepCopy(Object value) throws HibernateException {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMutable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Serializable disassemble(Object value, SharedSessionContractImplementor session)
|
||||
throws HibernateException {
|
||||
return ((Money) value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object assemble(
|
||||
Serializable cached, SharedSessionContractImplementor session, Object owner)
|
||||
throws HibernateException {
|
||||
return cached;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object replace(
|
||||
Object original, Object target, SharedSessionContractImplementor session, Object owner)
|
||||
throws HibernateException {
|
||||
return original;
|
||||
}
|
||||
}
|
|
@ -4,13 +4,6 @@
|
|||
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
|
||||
http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd"
|
||||
version="2.2">
|
||||
<embeddable class="org.joda.money.Money" access="FIELD" />
|
||||
<embeddable class="org.joda.money.BigMoney" access="FIELD">
|
||||
<attributes>
|
||||
<basic name="amount" access="FIELD"/>
|
||||
</attributes>
|
||||
</embeddable>
|
||||
|
||||
<persistence-unit-metadata>
|
||||
<persistence-unit-defaults>
|
||||
<entity-listeners>
|
||||
|
|
|
@ -26,8 +26,6 @@ import java.math.BigDecimal;
|
|||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.persistence.AttributeOverride;
|
||||
import javax.persistence.AttributeOverrides;
|
||||
import javax.persistence.CollectionTable;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.ElementCollection;
|
||||
|
@ -36,30 +34,14 @@ import javax.persistence.FetchType;
|
|||
import javax.persistence.Id;
|
||||
import javax.persistence.JoinColumn;
|
||||
import javax.persistence.MapKeyColumn;
|
||||
import javax.persistence.PostLoad;
|
||||
import org.hibernate.annotations.Columns;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.joda.money.CurrencyUnit;
|
||||
import org.joda.money.Money;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
/**
|
||||
* Unit tests for embeddable {@link Money}.
|
||||
*
|
||||
* <p>{@link Money} is a wrapper around {@link org.joda.money.BigMoney} which itself contains two
|
||||
* fields: a {@link BigDecimal} {@code amount} and a {@link CurrencyUnit} {@code currency}. When we
|
||||
* store an entity with a {@link Money} field, we would like to store it in two columns, for the
|
||||
* amount and the currency separately, so that it is easily queryable. This requires that we make
|
||||
* {@link Money} a nested embeddable object.
|
||||
*
|
||||
* <p>However becaues {@link Money} is not a class that we control, we cannot use annotation-based
|
||||
* mapping. Therefore there is no {@code JodaMoneyConverter} class. Instead, we define the mapping
|
||||
* in {@code META-INF/orm.xml}.
|
||||
*
|
||||
* <p>Also note that any entity that contains a {@link Money} should should implement a
|
||||
* {@link @PostLoad} callback that converts the amount in the {@link Money} to a scale that is
|
||||
* appropriate for the currency. This is espcially necessary for currencies like JPY where the scale
|
||||
* is 0, which is different from the default scale that {@link BigDecimal} is persisted in database.
|
||||
*/
|
||||
/** Unit tests for embeddable {@link JodaMoneyType}. */
|
||||
public class JodaMoneyConverterTest {
|
||||
|
||||
@RegisterExtension
|
||||
|
@ -70,7 +52,8 @@ public class JodaMoneyConverterTest {
|
|||
|
||||
@Test
|
||||
void roundTripConversion() {
|
||||
Money money = Money.of(CurrencyUnit.USD, 100);
|
||||
Money money = Money.of(CurrencyUnit.USD, 100.12);
|
||||
assertThat(money.getAmount().scale()).isEqualTo(2);
|
||||
TestEntity entity = new TestEntity(money);
|
||||
insertInDb(entity);
|
||||
List<?> result =
|
||||
|
@ -83,15 +66,55 @@ public class JodaMoneyConverterTest {
|
|||
"SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'")
|
||||
.getResultList());
|
||||
assertThat(result.size()).isEqualTo(1);
|
||||
// The amount property, when loaded as a raw value, has the same scale as the table column,
|
||||
// which is 2.
|
||||
assertThat(Arrays.asList((Object[]) result.get(0)))
|
||||
.containsExactly(
|
||||
BigDecimal.valueOf(100).setScale(CurrencyUnit.USD.getDecimalPlaces()), "USD")
|
||||
.containsExactly(BigDecimal.valueOf(100.12).setScale(2), "USD")
|
||||
.inOrder();
|
||||
TestEntity persisted =
|
||||
jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id"));
|
||||
assertThat(persisted.money).isEqualTo(money);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripConversion_scale0() {
|
||||
Money money = Money.ofMajor(CurrencyUnit.JPY, 100);
|
||||
assertThat(money.getAmount().scale()).isEqualTo(0); // JPY's amount has scale at 0.
|
||||
TestEntity entity = new TestEntity(money);
|
||||
insertInDb(entity);
|
||||
List<?> result =
|
||||
jpaTm()
|
||||
.transact(
|
||||
() ->
|
||||
jpaTm()
|
||||
.getEntityManager()
|
||||
.createNativeQuery(
|
||||
"SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'")
|
||||
.getResultList());
|
||||
assertThat(result.size()).isEqualTo(1);
|
||||
/* The amount property, when loaded as a raw value, has the same scale as the table column,
|
||||
which is 2. */
|
||||
assertThat(Arrays.asList((Object[]) result.get(0)))
|
||||
.containsExactly(BigDecimal.valueOf(100).setScale(2), "JPY")
|
||||
.inOrder();
|
||||
|
||||
result =
|
||||
jpaTm()
|
||||
.transact(
|
||||
() ->
|
||||
jpaTm()
|
||||
.getEntityManager()
|
||||
.createQuery("SELECT money FROM TestEntity WHERE name" + " = 'id'")
|
||||
.getResultList());
|
||||
|
||||
// When the money field is loaded as an embedded entity, it has the desired scale (0).
|
||||
assertThat(result.get(0)).isEqualTo(money);
|
||||
|
||||
TestEntity persisted =
|
||||
jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id"));
|
||||
assertThat(persisted.money).isEqualTo(money);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripConversionWithComplexEntity() {
|
||||
Money myMoney = Money.of(CurrencyUnit.USD, 100);
|
||||
|
@ -132,11 +155,6 @@ public class JodaMoneyConverterTest {
|
|||
jpaTm().transact(() -> jpaTm().getEntityManager().find(ComplexTestEntity.class, "id"));
|
||||
assertThat(result.size()).isEqualTo(1);
|
||||
|
||||
// Note that the amount has two decimal places even though JPY is supposed to have scale 0.
|
||||
// This is due to the unfournate fact that we need to accommodate differet currencies stored
|
||||
// in the same table so that the scale has to be set to the largest (2). When a Money field is
|
||||
// persisted in an entity, the entity should always have a @PostLoad callback to convert the
|
||||
// Money to the correct scale.
|
||||
assertThat(Arrays.asList((Object[]) result.get(0)))
|
||||
.containsExactly(BigDecimal.valueOf(2000).setScale(2), "JPY")
|
||||
.inOrder();
|
||||
|
@ -147,13 +165,15 @@ public class JodaMoneyConverterTest {
|
|||
}
|
||||
|
||||
// Override entity name to exclude outer-class name in table name. Not necessary if class is not
|
||||
// inner class. The double quotes are added to conform to our schema generation convention.
|
||||
@Entity(name = "\"TestEntity\"")
|
||||
// inner class.
|
||||
@Entity(name = "TestEntity")
|
||||
@EntityForTesting
|
||||
public static class TestEntity extends ImmutableObject {
|
||||
|
||||
@Id String name = "id";
|
||||
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(columns = {@Column(name = "amount"), @Column(name = "currency")})
|
||||
Money money;
|
||||
|
||||
public TestEntity() {}
|
||||
|
@ -164,50 +184,26 @@ public class JodaMoneyConverterTest {
|
|||
}
|
||||
|
||||
// See comments on the annotation for TestEntity above for reason.
|
||||
@Entity(name = "\"ComplexTestEntity\"")
|
||||
@Entity(name = "ComplexTestEntity")
|
||||
@EntityForTesting
|
||||
// This entity is used to test column override for embedded fields and collections.
|
||||
public static class ComplexTestEntity extends ImmutableObject {
|
||||
|
||||
// After the entity is loaded from the database, go through the money map and make sure that
|
||||
// the scale is consistent with the currency. This is necessary for currency like JPY where
|
||||
// the scale is 0 but the amount is persisteted as BigDecimal with scale 2.
|
||||
@PostLoad
|
||||
void setCurrencyScale() {
|
||||
moneyMap
|
||||
.entrySet()
|
||||
.forEach(
|
||||
entry -> {
|
||||
Money money = entry.getValue();
|
||||
if (!money.toBigMoney().isCurrencyScale()) {
|
||||
CurrencyUnit currency = money.getCurrencyUnit();
|
||||
BigDecimal amount = money.getAmount().setScale(currency.getDecimalPlaces());
|
||||
entry.setValue(Money.of(currency, amount));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Id String name = "id";
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "MoneyMap", joinColumns = @JoinColumn(name = "entity_name"))
|
||||
@MapKeyColumn(name = "map_key")
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "value.money.amount", column = @Column(name = "map_amount")),
|
||||
@AttributeOverride(name = "value.money.currency", column = @Column(name = "map_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(columns = {@Column(name = "map_amount"), @Column(name = "map_currency")})
|
||||
Map<String, Money> moneyMap;
|
||||
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "money.amount", column = @Column(name = "my_amount")),
|
||||
@AttributeOverride(name = "money.currency", column = @Column(name = "my_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(columns = {@Column(name = "my_amount"), @Column(name = "my_currency")})
|
||||
Money myMoney;
|
||||
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "money.amount", column = @Column(name = "your_amount")),
|
||||
@AttributeOverride(name = "money.currency", column = @Column(name = "your_currency"))
|
||||
})
|
||||
@Type(type = JodaMoneyType.TYPE_NAME)
|
||||
@Columns(columns = {@Column(name = "your_amount"), @Column(name = "your_currency")})
|
||||
Money yourMoney;
|
||||
|
||||
public ComplexTestEntity() {}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue