diff --git a/core/src/main/java/google/registry/model/GetterDelegate.java b/core/src/main/java/google/registry/model/GetterDelegate.java new file mode 100644 index 000000000..ce34da279 --- /dev/null +++ b/core/src/main/java/google/registry/model/GetterDelegate.java @@ -0,0 +1,49 @@ +// Copyright 2025 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.model; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * A delegate getter method to be used when getting the value of an {@link ImmutableObject} field. + * + *

This is useful because Hibernate has limitations on what kinds of types can be used to + * represent a field value, the most relevant being that it must be mutable. Since we use Guava's + * ImmutableCollections widely, this means that a frequent pattern is to e.g. have a field be + * declared as a Set (with a HashSet implementation), but then implement a getter method for that + * field that returns the desired ImmutableSet or ImmutableSortedSet. For purposes where it matters + * that the field be represented using the appropriate type, such as for outputting in sorted order + * via toString, then declare a getter delegate as follows: + * + *

{@code
+ * @GetterDelegate(methodName = "getAllowedTlds")
+ * Set allowedTlds;
+ *
+ * public ImmutableSortedSet getAllowedTlds() {
+ *   return nullToEmptyImmutableSortedCopy(allowedTlds);
+ * }
+ * }
+ */ +@Documented +@Retention(RUNTIME) +@Target(FIELD) +public @interface GetterDelegate { + String methodName(); +} diff --git a/core/src/main/java/google/registry/model/ModelUtils.java b/core/src/main/java/google/registry/model/ModelUtils.java index e8f19431f..cc9dde20b 100644 --- a/core/src/main/java/google/registry/model/ModelUtils.java +++ b/core/src/main/java/google/registry/model/ModelUtils.java @@ -76,11 +76,20 @@ public class ModelUtils { return ALL_FIELDS_CACHE.get(clazz); } - /** Retrieves a field value via reflection. */ + /** + * Retrieves a field value via reflection, using the field's {@link GetterDelegate} if present. + */ static Object getFieldValue(Object instance, Field field) { try { - return field.get(instance); - } catch (IllegalAccessException e) { + if (field.isAnnotationPresent(GetterDelegate.class)) { + return instance + .getClass() + .getMethod(field.getAnnotation(GetterDelegate.class).methodName()) + .invoke(instance); + } else { + return field.get(instance); + } + } catch (Exception e) { throw new IllegalStateException(e); } } diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java index 37fdd80b8..132d5a5e8 100644 --- a/core/src/main/java/google/registry/model/registrar/Registrar.java +++ b/core/src/main/java/google/registry/model/registrar/Registrar.java @@ -54,6 +54,7 @@ import com.google.gson.annotations.Expose; import com.google.re2j.Pattern; import google.registry.model.Buildable; import google.registry.model.CreateAutoTimestamp; +import google.registry.model.GetterDelegate; import google.registry.model.JsonMapBuilder; import google.registry.model.Jsonifiable; import google.registry.model.UpdateAutoTimestamp; @@ -245,12 +246,15 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J State state; /** The set of TLDs which this registrar is allowed to access. */ - @Expose Set allowedTlds; + @GetterDelegate(methodName = "getAllowedTlds") + @Expose + Set allowedTlds; /** Host name of WHOIS server. */ @Expose String whoisServer; /** Base URLs for the registrar's RDAP servers. */ + @GetterDelegate(methodName = "getRdapBaseUrls") Set rdapBaseUrls; /** diff --git a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java index 5f9bedf17..6203c31c2 100644 --- a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java +++ b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java @@ -27,6 +27,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedSet; import com.google.gson.annotations.Expose; import google.registry.model.Buildable; +import google.registry.model.GetterDelegate; import google.registry.model.ImmutableObject; import google.registry.model.JsonMapBuilder; import google.registry.model.Jsonifiable; @@ -110,6 +111,7 @@ public class RegistrarPoc extends ImmutableObject implements Jsonifiable, Unsafe * data is internal to the registry. */ @Enumerated(EnumType.STRING) + @GetterDelegate(methodName = "getTypes") @Expose Set types; diff --git a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java index 326dee354..1a0fd51f9 100644 --- a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java +++ b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java @@ -23,6 +23,7 @@ import static google.registry.testing.CertificateSamples.SAMPLE_CERT2_HASH; import static google.registry.testing.CertificateSamples.SAMPLE_CERT_HASH; import static google.registry.testing.DatabaseHelper.cloneAndSetAutoTimestamps; import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.createTlds; import static google.registry.testing.DatabaseHelper.newTld; import static google.registry.testing.DatabaseHelper.persistResource; import static google.registry.testing.DatabaseHelper.persistResources; @@ -760,4 +761,16 @@ class RegistrarTest extends EntityTestCase { .setAllowedTlds(ImmutableSet.of("tld", "xn--q9jyb4c")) .build(); } + + @Test + void testToString_sortsAllowedTlds() { + createTlds("foo", "bar", "baz", "gon", "tri"); + persistResource( + registrar + .asBuilder() + .setAllowedTlds(ImmutableSet.of("gon", "bar", "foo", "tri", "baz")) + .build()); + assertThat(Registrar.loadByRegistrarId("registrar").toString()) + .contains("allowedTlds=[bar, baz, foo, gon, tri]"); + } } diff --git a/core/src/test/java/google/registry/persistence/EntitiesTest.java b/core/src/test/java/google/registry/persistence/EntitiesTest.java new file mode 100644 index 000000000..ca060ce5f --- /dev/null +++ b/core/src/test/java/google/registry/persistence/EntitiesTest.java @@ -0,0 +1,52 @@ +// Copyright 2025 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; + +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.google.common.collect.ImmutableSet; +import google.registry.model.GetterDelegate; +import jakarta.persistence.Entity; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +/** Unit tests for Hibernate entities. */ +public class EntitiesTest { + + @Test + void getterDelegates_allMethodNamesExist() throws Exception { + ImmutableSet> entityClasses = + PersistenceXmlUtility.getManagedClasses().stream() + .filter(clazz -> clazz.isAnnotationPresent(Entity.class)) + .collect(toImmutableSet()); + for (Class clazz : entityClasses) { + for (Field field : clazz.getDeclaredFields()) { + if (field.isAnnotationPresent(GetterDelegate.class)) { + String methodName = field.getAnnotation(GetterDelegate.class).methodName(); + // Note that calling getDeclaredMethod(methodName) specifically looks for a method with + // no parameters; if there were a method that took e.g. one parameter, you'd need + // getDeclaredMethod(methodName, param1) to find it. + assertDoesNotThrow( + () -> clazz.getDeclaredMethod(methodName), + String.format( + "Method %s() specified in the GetterDelegate " + + "annotation of field %s could not be found on class %s", + methodName, field.getName(), clazz.getCanonicalName())); + } + } + } + } +} diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java index dd3eff5b7..7f8d15e1f 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java @@ -203,7 +203,9 @@ class ConsoleUpdateRegistrarActionTest extends ConsoleActionBaseTestCase { "The following changes were made in registry unittest environment to the" + " registrar TheRegistrar by admin fte@email.tld:\n" + "\n" - + "allowedTlds: null -> [app, dev]\n" + + "allowedTlds:\n" + + " ADDED: [app, dev]\n" + + " FINAL CONTENTS: [app, dev]\n" + "lastPocVerificationDate: 1970-01-01T00:00:00.000Z ->" + " 2023-12-12T00:00:00.000Z\n") .setRecipients(ImmutableList.of(new InternetAddress("notification@test.example")))