diff --git a/core/src/main/java/google/registry/beam/comparedb/ValidateSqlUtils.java b/core/src/main/java/google/registry/beam/comparedb/ValidateSqlUtils.java index 130f2de51..2c7ea5619 100644 --- a/core/src/main/java/google/registry/beam/comparedb/ValidateSqlUtils.java +++ b/core/src/main/java/google/registry/beam/comparedb/ValidateSqlUtils.java @@ -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()); - } } diff --git a/core/src/main/java/google/registry/model/billing/BillingEvent.java b/core/src/main/java/google/registry/model/billing/BillingEvent.java index af436282d..1fe08e7a2 100644 --- a/core/src/main/java/google/registry/model/billing/BillingEvent.java +++ b/core/src/main/java/google/registry/model/billing/BillingEvent.java @@ -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. */ diff --git a/core/src/main/java/google/registry/model/tld/Registry.java b/core/src/main/java/google/registry/model/tld/Registry.java index faa81b26a..fdf06d601 100644 --- a/core/src/main/java/google/registry/model/tld/Registry.java +++ b/core/src/main/java/google/registry/model/tld/Registry.java @@ -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; /** diff --git a/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java b/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java index f90123113..2f7b0af1b 100644 --- a/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java +++ b/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java @@ -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); } } diff --git a/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java b/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java new file mode 100644 index 000000000..681ce0a57 --- /dev/null +++ b/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java @@ -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}. + * + *
{@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. + * + *
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. + * + *
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. + * + *
Usage: + * + *
{@code + * '@'Type(type = JodaMoneyType.TYPE_NAME) + * '@'Columns( + * columns = { + * '@'Column(name = "cost_amount"), + * '@'Column(name = "cost_currency") + * } + * ) + * Money cost; + * }+ */ +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
{@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. - * - *
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}. - * - *
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